L'implémentation de la bibliothèque Paging dans votre application doit être associée à une stratégie de test robuste. Vous devez tester les composants de chargement de données, tels que PagingSource
et RemoteMediator
, pour vous assurer qu'ils fonctionnent comme prévu. Vous devez également écrire des tests de bout en bout pour vérifier que tous les composants de votre implémentation de Paging fonctionnent correctement ensemble, sans effets secondaires inattendus.
Ce guide explique comment tester des sources de données Paging de manière isolée, comment tester les transformations que vous effectuez sur des données paginées et comment écrire des tests de bout en bout pour l'ensemble de votre implémentation de Paging. Les exemples de ce guide sont basés sur l'exemple de Paging avec réseau.
Tests de la couche de dépôt
Créez des tests unitaires pour les composants de la couche de dépôt afin de vous assurer qu'ils chargent les données depuis vos sources de données de manière appropriée. Fournissez des versions fictives des dépendances pour vérifier que les composants testés fonctionnent correctement et de manière isolée. Les principaux composants à tester dans la couche de dépôt sont PagingSource
et RemoteMediator
.
Tests PagingSource
Les tests unitaires pour vos classes PagingSource
impliquent de configurer l'instance PagingSource
et de vérifier que la fonction load()
renvoie les données paginées appropriées en fonction d'un argument LoadParams
. Fournissez des données fictives au constructeur PagingSource
afin de contrôler les données dans vos tests. Vous pouvez transmettre une valeur par défaut pour les types de données primitifs, mais vous devez transmettre une version fictive pour d'autres objets, tels qu'une base de données ou une implémentation d'API. Cela vous permet de contrôler complètement la sortie de la source de données fictive lorsque la PagingSource
testée interagit avec elle.
Les paramètres de constructeur pour votre implémentation PagingSource
spécifique déterminent le type de données de test à transmettre. Dans l'exemple suivant, l'implémentation PagingSource
nécessite un objet RedditApi
ainsi qu'un objet String
pour le nom du subreddit. Vous pouvez transmettre une valeur par défaut pour le paramètre String
. Toutefois, pour le paramètre RedditApi
, vous devez créer une implémentation fictive des tests.
Kotlin
class ItemKeyedSubredditPagingSource( private val redditApi: RedditApi, private val subredditName: String ) : PagingSource<String, RedditPost>() { ... }
Java
public class ItemKeyedSubredditPagingSource extends RxPagingSource<String, RedditPost> { @NonNull private RedditApi redditApi; @NonNull private String subredditName; ItemKeyedSubredditPagingSource( @NotNull RedditApi redditApi, @NotNull String subredditName ) { this.redditApi = redditApi; this.subredditName = subredditName; ... } }
Java
public class ItemKeyedSubredditPagingSource extends ListenableFuturePagingSource<String, RedditPost> { @NonNull private RedditApi redditApi; @NonNull private String subredditName; @NonNull private Executor bgExecutor; public ItemKeyedSubredditPagingSource( @NotNull RedditApi redditApi, @NotNull String subredditName, @NonNull Executor bgExecutor ) { this.redditApi = redditApi; this.subredditName = subredditName; this.bgExecutor = bgExecutor; ... } }
Dans cet exemple, le paramètre RedditApi
est une interface Retrofit qui définit les requêtes du serveur ainsi que les classes de réponse. Une version fictive peut mettre en œuvre l'interface, forcer les fonctions requises et fournir des méthodes pratiques permettant de configurer la réaction de l'objet fictif lors des tests.
Une fois les objets fictifs en place, configurez les dépendances et initialisez l'objet PagingSource
dans le test. L'exemple suivant illustre l'initialisation de l'objet MockRedditApi
avec une liste de posts de test :
Kotlin
@OptIn(ExperimentalCoroutinesApi::class) class SubredditPagingSourceTest { private val postFactory = PostFactory() private val mockPosts = listOf( postFactory.createRedditPost(DEFAULT_SUBREDDIT), postFactory.createRedditPost(DEFAULT_SUBREDDIT), postFactory.createRedditPost(DEFAULT_SUBREDDIT) ) private val mockApi = MockRedditApi().apply { mockPosts.forEach { post -> addPost(post) } } @Test fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest { val pagingSource = ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT) } }
Java
public class SubredditPagingSourceTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); static { for (int i=0; i<3; i++) { mockPosts.add(post); mockApi.addPost(post); } } @After tearDown() { // run. mockApi.setFailureMsg(null); } @Test loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() { ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT); } } RedditPost post=postFactory.createRedditPost(DEFAULT_SUBREDDIT); public void Clear the failure message after each test public void throws InterruptedException ItemKeyedSubredditPagingSource pagingSource=new>
Java
public class SubredditPagingSourceTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); static { for (int i=0; i<3; i++) { mockPosts.add(post); mockApi.addPost(post); } } @After tearDown() { // run. mockApi.setFailureMsg(null); } @Test loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() ExecutionException, { ItemKeyedSubredditPagingSource( mockApi, DEFAULT_SUBREDDIT, CurrentThreadExecutor() ); } } RedditPost post=postFactory.createRedditPost(DEFAULT_SUBREDDIT); public void Clear the failure message after each test public void throws InterruptedException ItemKeyedSubredditPagingSource pagingSource=new new>
L'étape suivante consiste à tester l'objet PagingSource
. La méthode load()
est l'objet principal des tests. L'exemple suivant montre une assertion vérifiant que la méthode load()
renvoie les données appropriées, la clé précédente et la clé suivante pour un paramètre LoadParams
donné :
Kotlin
@Test // Since load is a suspend function, runTest is used to ensure that it // runs on the test thread. fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest { val pagingSource = ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT) assertEquals( expected = Page( data = listOf(mockPosts[0], mockPosts[1]), prevKey = mockPosts[0].name, nextKey = mockPosts[1].name ), actual = pagingSource.load( Refresh( key = null, loadSize = 2, placeholdersEnabled = false ) ), ) }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() throws InterruptedException { ItemKeyedSubredditPagingSource pagingSource = new ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT); LoadParams.Refresh<String> refreshRequest = new LoadParams.Refresh<>(null, 2, false); pagingSource.loadSingle(refreshRequest) .test() .await() .assertValueCount(1) .assertValue(new LoadResult.Page<>( mockPosts.subList(0, 2), mockPosts.get(0).getName(), mockPosts.get(1).getName() )); }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() throws ExecutionException, InterruptedException { ItemKeyedSubredditPagingSource pagingSource = new ItemKeyedSubredditPagingSource( mockApi, DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingSource.LoadParams.Refresh<String> refreshRequest = new PagingSource.LoadParams.Refresh<>(null, 2, false); PagingSource.LoadResult<String, RedditPost> result = pagingSource.loadFuture(refreshRequest).get(); PagingSource.LoadResult<String, RedditPost> expected = new PagingSource.LoadResult.Page<>( mockPosts.subList(0, 2), mockPosts.get(0).getName(), mockPosts.get(1).getName() ); assertThat(result, equalTo(expected)); }
L'exemple précédent montre comment tester une PagingSource
qui utilise une pagination basée sur des éléments appariés. Si la source de données que vous utilisez repose sur une pagination basée sur des pages appariées, les tests PagingSource
seront différents. La principale différence réside dans les clés précédentes et suivantes attendues de la méthode load()
.
Kotlin
@Test fun loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() = runTest { val pagingSource = PageKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT) assertEquals( expected = Page( data = listOf(mockPosts[0], mockPosts[1]), prevKey = null, nextKey = mockPosts[1].id ), actual = pagingSource.load( Refresh( key = null, loadSize = 2, placeholdersEnabled = false ) ), ) }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() throws InterruptedException { PageKeyedSubredditPagingSource pagingSource = new PageKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT); LoadParams.Refresh<String> refreshRequest = new LoadParams.Refresh<>(null, 2, false); pagingSource.loadSingle(refreshRequest) .test() .await() .assertValueCount(1) .assertValue(new LoadResult.Page<>( mockPosts.subList(0, 2), null, mockPosts.get(1).getId() )); }
Java
@Test public void loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() throws InterruptedException, ExecutionException { PageKeyedSubredditPagingSource pagingSource = new PageKeyedSubredditPagingSource( mockApi, DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingSource.LoadParams.Refresh<String> refreshRequest = new PagingSource.LoadParams.Refresh<>(null, 2, false); PagingSource.LoadResult<String, RedditPost> result = pagingSource.loadFuture(refreshRequest).get(); PagingSource.LoadResult<String, RedditPost> expected = new PagingSource.LoadResult.Page<>( mockPosts.subList(0, 2), null, mockPosts.get(1).getId() ); assertThat(result, equalTo(expected)); }
Tests RemoteMediator
L'objectif des tests unitaires RemoteMediator
est de vérifier que la fonction load()
renvoie la bonne valeur MediatorResult
.
Les tests liés aux effets secondaires, tels que l'insertion de données dans la base de données, sont plus adaptés aux tests d'intégration.
La première étape consiste à déterminer les dépendances dont votre implémentation RemoteMediator
a besoin. L'exemple suivant illustre une implémentation RemoteMediator
nécessitant une base de données Room, une interface Retrofit et une chaîne de recherche :
Kotlin
@OptIn(ExperimentalPagingApi::class) class PageKeyedRemoteMediator( private val db: RedditDb, private val redditApi: RedditApi, private val subredditName: String ) : RemoteMediator<Int, RedditPost>() { ... }
Java
public class PageKeyedRemoteMediator extends RxRemoteMediator<Integer, RedditPost> { @NonNull private RedditDb db; @NonNull private RedditPostDao postDao; @NonNull private SubredditRemoteKeyDao remoteKeyDao; @NonNull private RedditApi redditApi; @NonNull private String subredditName; public PageKeyedRemoteMediator( @NonNull RedditDb db, @NonNull RedditApi redditApi, @NonNull String subredditName ) { this.db = db; this.postDao = db.posts(); this.remoteKeyDao = db.remoteKeys(); this.redditApi = redditApi; this.subredditName = subredditName; ... } }
Java
public class PageKeyedRemoteMediator extends ListenableFutureRemoteMediator<Integer, RedditPost> { @NonNull private RedditDb db; @NonNull private RedditPostDao postDao; @NonNull private SubredditRemoteKeyDao remoteKeyDao; @NonNull private RedditApi redditApi; @NonNull private String subredditName; @NonNull private Executor bgExecutor; public PageKeyedRemoteMediator( @NonNull RedditDb db, @NonNull RedditApi redditApi, @NonNull String subredditName, @NonNull Executor bgExecutor ) { this.db = db; this.postDao = db.posts(); this.remoteKeyDao = db.remoteKeys(); this.redditApi = redditApi; this.subredditName = subredditName; this.bgExecutor = bgExecutor; ... } }
Vous pouvez fournir l'interface Retrofit et la chaîne de recherche comme indiqué dans la section Tests PagingSource. Il est très difficile de fournir une version fictive de la base de données Room. Il est donc plus simple de fournir une implémentation en mémoire de la base de données plutôt qu'une version complète. La création d'une base de données Room nécessite un objet Context
. Vous devez donc placer ce test RemoteMediator
dans le répertoire androidTest
et l'exécuter avec l'exécuteur de test AndroidJUnit4 afin qu'il ait accès à un contexte d'application test. Pour en savoir plus sur les tests instrumentés, consultez Créer des tests unitaires instrumentés.
Définissez des fonctions de suppression pour vous assurer que l'état ne fuit pas entre les fonctions de test. Cela garantit des résultats cohérents entre les exécutions de test.
Kotlin
@ExperimentalPagingApi @OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class PageKeyedRemoteMediatorTest { private val postFactory = PostFactory() private val mockPosts = listOf( postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT), postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT), postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT) ) private val mockApi = mockRedditApi() private val mockDb = RedditDb.create( ApplicationProvider.getApplicationContext(), useInMemory = true ) @After fun tearDown() { mockDb.clearAllTables() // Clear out failure message to default to the successful response. mockApi.failureMsg = null // Clear out posts after each test run. mockApi.clearPosts() } }
Java
@RunWith(AndroidJUnit4.class) public class PageKeyedRemoteMediatorTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); private RedditDb mockDb = RedditDb.Companion.create( ApplicationProvider.getApplicationContext(), true ); static { for (int i=0; i<3; i++) { RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT); mockPosts.add(post); } } @After public void tearDown() { mockDb.clearAllTables(); // Clear the failure message after each test run. mockApi.setFailureMsg(null); // Clear out posts after each test run. mockApi.clearPosts(); } }
Java
@RunWith(AndroidJUnit4.class) public class PageKeyedRemoteMediatorTest { static PostFactory postFactory = new PostFactory(); static List<RedditPost> mockPosts = new ArrayList<>(); static MockRedditApi mockApi = new MockRedditApi(); private RedditDb mockDb = RedditDb.Companion.create( ApplicationProvider.getApplicationContext(), true ); static { for (int i=0; i<3; i++) { RedditPost post = postFactory.createRedditPost(DEFAULT_SUBREDDIT); mockPosts.add(post); } } @After public void tearDown() { mockDb.clearAllTables(); // Clear the failure message after each test run. mockApi.setFailureMsg(null); // Clear out posts after each test run. mockApi.clearPosts(); } }
L'étape suivante consiste à tester la fonction load()
. Dans cet exemple, trois cas sont à tester :
- Le premier cas se présente lorsque
mockApi
renvoie des données valides. La fonctionload()
doit renvoyerMediatorResult.Success
, et la propriétéendOfPaginationReached
doit êtrefalse
. - Le second cas se produit lorsque
mockApi
renvoie une réponse positive, mais que les données renvoyées sont vides. La fonctionload()
doit renvoyerMediatorResult.Success
, et la propriétéendOfPaginationReached
doit êtretrue
. - Le troisième cas de figure se produit lorsque
mockApi
génère une exception lors de la récupération des données. La fonctionload()
doit renvoyerMediatorResult.Error
.
Procédez comme suit pour tester le premier cas :
- Configurez l'élément
mockApi
avec les données des posts à renvoyer. - Initialisez l'objet
RemoteMediator
. - Testez la fonction
load()
.
Kotlin
@Test fun refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() = runTest { // Add mock results for the API to return. mockPosts.forEach { post -> mockApi.addPost(post) } val remoteMediator = PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ) val pagingState = PagingState<Int, RedditPost>( listOf(), null, PagingConfig(10), 10 ) val result = remoteMediator.load(LoadType.REFRESH, pagingState) assertTrue { result is MediatorResult.Success } assertFalse { (result as MediatorResult.Success).endOfPaginationReached } }
Java
@Test public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() throws InterruptedException { // Add mock results for the API to return. for (RedditPost post: mockPosts) { mockApi.addPost(post); } PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success && ((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == false); }
Java
@Test public void refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() throws InterruptedException, ExecutionException { // Add mock results for the API to return. for (RedditPost post: mockPosts) { mockApi.addPost(post); } PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class)); assertFalse(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached()); }
Le deuxième test nécessite que mockApi
renvoie un résultat vide. Comme vous effacez les données de mockApi
après chaque exécution, un résultat vide sera renvoyé par défaut.
Kotlin
@Test fun refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() = runTest { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. val remoteMediator = PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ) val pagingState = PagingState<Int, RedditPost>( listOf(), null, PagingConfig(10), 10 ) val result = remoteMediator.load(LoadType.REFRESH, pagingState) assertTrue { result is MediatorResult.Success } assertTrue { (result as MediatorResult.Success).endOfPaginationReached } }
Java
@Test public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() throws InterruptedException() { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Success && ((RemoteMediator.MediatorResult.Success) value).endOfPaginationReached() == true); }
Java
@Test public void refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() throws InterruptedException, ExecutionException { // To test endOfPaginationReached, don't set up the mockApi to return post // data here. PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Success.class)); assertTrue(((RemoteMediator.MediatorResult.Success) result).endOfPaginationReached()); }
Le test final nécessite que mockApi
génère une exception afin que le test puisse vérifier que la fonction load()
renvoie correctement MediatorResult.Error
.
Kotlin
@Test fun refreshLoadReturnsErrorResultWhenErrorOccurs() = runTest { // Set up failure message to throw exception from the mock API. mockApi.failureMsg = "Throw test failure" val remoteMediator = PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ) val pagingState = PagingState<Int, RedditPost>( listOf(), null, PagingConfig(10), 10 ) val result = remoteMediator.load(LoadType.REFRESH, pagingState) assertTrue {result is MediatorResult.Error } }
Java
@Test public void refreshLoadReturnsErrorResultWhenErrorOccurs() throws InterruptedException { // Set up failure message to throw exception from the mock API. mockApi.setFailureMsg("Throw test failure"); PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); remoteMediator.loadSingle(LoadType.REFRESH, pagingState) .test() .await() .assertValueCount(1) .assertValue(value -> value instanceof RemoteMediator.MediatorResult.Error); }
Java
@Test public void refreshLoadReturnsErrorResultWhenErrorOccurs() throws InterruptedException, ExecutionException { // Set up failure message to throw exception from the mock API. mockApi.setFailureMsg("Throw test failure"); PageKeyedRemoteMediator remoteMediator = new PageKeyedRemoteMediator( mockDb, mockApi, SubRedditViewModel.DEFAULT_SUBREDDIT, new CurrentThreadExecutor() ); PagingState<Integer, RedditPost> pagingState = new PagingState<>( new ArrayList(), null, new PagingConfig(10), 10 ); RemoteMediator.MediatorResult result = remoteMediator.loadFuture(LoadType.REFRESH, pagingState).get(); assertThat(result, instanceOf(RemoteMediator.MediatorResult.Error.class)); }
Tests de transformation
Vous devez également écrire des tests unitaires pour couvrir toutes les transformations que vous appliquez au flux PagingData
. Si votre implémentation Paging effectue un mappage ou un filtrage de données, vous devez tester ces transformations pour vous assurer qu'elles fonctionnent comme prévu. Vous devez utiliser AsyncPagingDataDiffer
dans ces tests, car vous ne pouvez pas accéder directement à la sortie du flux PagingData
.
L'exemple suivant illustre quelques transformations de base appliquées à un objet PagingData
à des fins de test :
Kotlin
fun PagingData<Int>.myHelperTransformFunction(): PagingData<Int> { return this.map { item -> item * item }.filter { item -> item % 2 == 0 } }
Java
public PagingData<Integer> myHelperTransformFunction(PagingData<Integer> pagingData) { PagingData<Integer> mappedData = PagingDataTransforms.map( pagingData, new CurrentThreadExecutor(), ((item) -> item * item) ); return PagingDataTransforms.filter( mappedData, new CurrentThreadExecutor(), ((item) -> item % 2 == 0) ); }
Java
public PagingData<Integer> myHelperTransformFunction(PagingData<Integer> pagingData) { PagingData<Integer> mappedData = PagingDataTransforms.map( pagingData, new CurrentThreadExecutor(), ((item) -> item * item) ); return PagingDataTransforms.filter( mappedData, new CurrentThreadExecutor(), ((item) -> item % 2 == 0) ); }
L'objet AsyncPagingDataDiffer
nécessite plusieurs paramètres, mais la plupart peuvent être des implémentations vides à des fins de test. Deux de ces paramètres sont des implémentations DiffUtil.ItemCallback
et ListUpdateCallback
:
Kotlin
class NoopListCallback : ListUpdateCallback { override fun onChanged(position: Int, count: Int, payload: Any?) {} override fun onMoved(fromPosition: Int, toPosition: Int) {} override fun onInserted(position: Int, count: Int) {} override fun onRemoved(position: Int, count: Int) {} } class MyDiffCallback : DiffUtil.ItemCallback<Int>() { override fun areItemsTheSame(oldItem: Int, newItem: Int): Boolean { return oldItem == newItem } override fun areContentsTheSame(oldItem: Int, newItem: Int): Boolean { return oldItem == newItem } }
Java
class NoopListCallback implements ListUpdateCallback { @Override public void onInserted(int position, int count) {} @Override public void onRemoved(int position, int count) {} @Override public void onMoved(int fromPosition, int toPosition) {} @Override public void onChanged( int position, int count, @Nullable Object payload ) { } } class MyDiffCallback extends DiffUtil.ItemCallback<Integer> { @Override public boolean areItemsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } }
Java
class NoopListCallback implements ListUpdateCallback { @Override public void onInserted(int position, int count) {} @Override public void onRemoved(int position, int count) {} @Override public void onMoved(int fromPosition, int toPosition) {} @Override public void onChanged( int position, int count, @Nullable Object payload ) { } } class MyDiffCallback extends DiffUtil.ItemCallback<Integer> { @Override public boolean areItemsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame( @NonNull Integer oldItem, @NonNull Integer newItem ) { return oldItem.equals(newItem); } }
Une fois ces dépendances en place, vous pouvez écrire les tests de transformation. Les tests doivent effectuer les étapes suivantes :
- Initialisation des
PagingData
avec les données de test. - Exécution des transformations sur les
PagingData
. - Transmission des données transformées à la différence.
- Une fois que la différence a analysé les données, accédez à un instantané de la sortie de la différence et vérifiez que les données sont correctes.
L'exemple suivant illustre ce processus :
Kotlin
@ExperimentalCoroutinesApi class PagingDataTransformTest { private val testScope = TestScope() private val testDispatcher = StandardTestDispatcher(testScope.testScheduler) @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After fun tearDown() { Dispatchers.resetMain() } @Test fun differTransformsData() = testScope.runTest { val data = PagingData.from(listOf(1, 2, 3, 4)).myHelperTransformFunction() val differ = AsyncPagingDataDiffer( diffCallback = MyDiffCallback(), updateCallback = NoopListCallback(), workerDispatcher = Dispatchers.Main ) // You don't need to use lifecycleScope.launch() if you're using // PagingData.from() differ.submitData(data) // Wait for transforms and the differ to process all updates. advanceUntilIdle() assertEquals(listOf(4, 16), differ.snapshot().items) } }
Java
List<Integer> data = Arrays.asList(1, 2, 3, 4); PagingData<Integer> pagingData = PagingData.from(data); PagingData<Integer> transformedData = myHelperTransformFunction(pagingData); Executor executor = new CurrentThreadExecutor(); CoroutineDispatcher dispatcher = ExecutorsKt.from(executor); AsyncPagingDataDiffer<Integer> differ = new AsyncPagingDataDiffer<Integer>( new MyDiffCallback(), new NoopListCallback(), dispatcher, dispatcher ); TestLifecycleOwner lifecycleOwner = new TestLifecycleOwner(Lifecycle.State.RESUMED, dispatcher); differ.submitData(lifecycleOwner.getLifecycle(), transformedData); // Wait for submitData() to present some data. while (differ.getItemCount() == 0) { Thread.sleep(100); } assertEquals(Arrays.asList(4, 16), differ.snapshot().getItems());
Java
List<Integer> data = Arrays.asList(1, 2, 3, 4); PagingData<Integer> pagingData = PagingData.from(data); PagingData<Integer> transformedData = myHelperTransformFunction(pagingData); Executor executor = new CurrentThreadExecutor(); CoroutineDispatcher dispatcher = ExecutorsKt.from(executor); AsyncPagingDataDiffer<Integer> differ = new AsyncPagingDataDiffer<Integer>( new MyDiffCallback(), new NoopListCallback(), dispatcher, dispatcher ); TestLifecycleOwner lifecycleOwner = new TestLifecycleOwner(Lifecycle.State.RESUMED, dispatcher); differ.submitData(lifecycleOwner.getLifecycle(), transformedData); // Wait for submitData() to present some data. while (differ.getItemCount() == 0) { Thread.sleep(100); } assertEquals(Arrays.asList(4, 16), differ.snapshot().getItems());
Tests de bout en bout
Les tests unitaires garantissent que les composants Paging individuels fonctionnent de manière isolée, mais les tests de bout en bout indiquent que l'application fonctionne dans son ensemble. Ces tests auront toujours besoin de certaines dépendances, mais ils couvrent généralement la plus grande partie du code de votre application.
L'exemple de cette section utilise une dépendance d'API fictive pour éviter d'utiliser le réseau dans les tests. L'API fictive est configurée pour renvoyer un ensemble cohérent de données de test, ce qui génère des tests reproductibles. Déterminez les dépendances à échanger pour les implémentations fictives selon la fonction de chaque dépendance, la cohérence de sa sortie et la fidélité nécessaire pour vos tests.
Écrivez votre code de manière à pouvoir facilement interchanger des versions fictives de vos dépendances. L'exemple suivant utilise une implémentation d'outil de localisation de services de base pour fournir et modifier les dépendances si nécessaire. Dans les applications plus importantes, l'utilisation d'une bibliothèque d'injection de dépendances comme Hilt peut permettre de gérer des graphes de dépendances plus complexes.
Kotlin
class RedditActivityTest { companion object { private const val TEST_SUBREDDIT = "test" } private val postFactory = PostFactory() private val mockApi = MockRedditApi().apply { addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)) addPost(postFactory.createRedditPost(TEST_SUBREDDIT)) addPost(postFactory.createRedditPost(TEST_SUBREDDIT)) } @Before fun init() { val app = ApplicationProvider.getApplicationContext<Application>() // Use a controlled service locator with a mock API. ServiceLocator.swap( object : DefaultServiceLocator(app = app, useInMemoryDb = true) { override fun getRedditApi(): RedditApi = mockApi } ) } }
Java
public class RedditActivityTest { public static final String TEST_SUBREDDIT = "test"; private static PostFactory postFactory = new PostFactory(); private static MockRedditApi mockApi = new MockRedditApi(); static { mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); } @Before public void setup() { Application app = ApplicationProvider.getApplicationContext(); // Use a controlled service locator with a mock API. ServiceLocator.Companion.swap( new DefaultServiceLocator(app, true) { @NotNull @Override public RedditApi getRedditApi() { return mockApi; } } ); } }
Java
public class RedditActivityTest { public static final String TEST_SUBREDDIT = "test"; private static PostFactory postFactory = new PostFactory(); private static MockRedditApi mockApi = new MockRedditApi(); static { mockApi.addPost(postFactory.createRedditPost(DEFAULT_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); mockApi.addPost(postFactory.createRedditPost(TEST_SUBREDDIT)); } @Before public void setup() { Application app = ApplicationProvider.getApplicationContext(); // Use a controlled service locator with a mock API. ServiceLocator.Companion.swap( new DefaultServiceLocator(app, true) { @NotNull @Override public RedditApi getRedditApi() { return mockApi; } } ); } }
Après avoir configuré la structure de test, l'étape suivante consiste à vérifier que les données renvoyées par l'implémentation de Pager
sont correctes. Un test doit garantir que l'objet Pager
charge les données par défaut lors du chargement initial de la page, et un autre test doit vérifier que l'objet Pager
charge correctement les données supplémentaires en fonction de l'entrée utilisateur. Dans l'exemple suivant, le test vérifie que l'objet Pager
met à jour RecyclerView.Adapter
avec le nombre correct d'éléments renvoyés par l'API lorsque l'utilisateur effectue une recherche à partir d'un autre subreddit.
Kotlin
@Test fun loadsTheDefaultResults() { ActivityScenario.launch(RedditActivity::class.java) onView(withId(R.id.list)).check { view, noViewFoundException -> if (noViewFoundException != null) { throw noViewFoundException } val recyclerView = view as RecyclerView assertEquals(1, recyclerView.adapter?.itemCount) } } @Test // Verify that the default data is swapped out when the user searches for a // different subreddit. fun loadsTheTestResultsWhenSearchingForSubreddit() { ActivityScenario.launch(RedditActivity::class.java ) onView(withId(R.id.list)).check { view, noViewFoundException -> if (noViewFoundException != null) { throw noViewFoundException } val recyclerView = view as RecyclerView // Verify that it loads the default data first. assertEquals(1, recyclerView.adapter?.itemCount) } // Search for test subreddit instead of default to trigger new data load. onView(withId(R.id.input)).perform( replaceText(TEST_SUBREDDIT), pressKey(KeyEvent.KEYCODE_ENTER) ) onView(withId(R.id.list)).check { view, noViewFoundException -> if (noViewFoundException != null) { throw noViewFoundException } val recyclerView = view as RecyclerView assertEquals(2, recyclerView.adapter?.itemCount) } }
Java
@Test public void loadsTheDefaultResults() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(1, recyclerView.getAdapter().getItemCount()); }); } @Test // Verify that the default data is swapped out when the user searches for a // different subreddit. public void loadsTheTestResultsWhenSearchingForSubreddit() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; // Verify that it loads the default data first. assertEquals(1, recyclerView.getAdapter().getItemCount()); }); // Search for test subreddit instead of default to trigger new data load. onView(withId(R.id.input)).perform( replaceText(TEST_SUBREDDIT), pressKey(KeyEvent.KEYCODE_ENTER) ); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(2, recyclerView.getAdapter().getItemCount()); }); }
Java
@Test public void loadsTheDefaultResults() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(1, recyclerView.getAdapter().getItemCount()); }); } @Test // Verify that the default data is swapped out when the user searches for a // different subreddit. public void loadsTheTestResultsWhenSearchingForSubreddit() { ActivityScenario.launch(RedditActivity.class); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; // Verify that it loads the default data first. assertEquals(1, recyclerView.getAdapter().getItemCount()); }); // Search for test subreddit instead of default to trigger new data load. onView(withId(R.id.input)).perform( replaceText(TEST_SUBREDDIT), pressKey(KeyEvent.KEYCODE_ENTER) ); onView(withId(R.id.list)).check((view, noViewFoundException) -> { if (noViewFoundException != null) { throw noViewFoundException; } RecyclerView recyclerView = (RecyclerView) view; assertEquals(2, recyclerView.getAdapter().getItemCount()); }); }
Les tests d'instrumentation doivent vérifier que les données s'affichent correctement dans l'interface utilisateur. Pour ce faire, vous pouvez vérifier que le nombre d'éléments approprié existe dans RecyclerView.Adapter
, ou itérer l'ensemble des vues de lignes individuelles et vérifier que le format des données est correct.