Tester votre implémentation de Paging

Restez organisé à l'aide des collections Enregistrez et classez les contenus selon vos préférences.

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 fonction load() doit renvoyer MediatorResult.Success, et la propriété endOfPaginationReached doit être false.
  • Le second cas se produit lorsque mockApi renvoie une réponse positive, mais que les données renvoyées sont vides. La fonction load() doit renvoyer MediatorResult.Success, et la propriété endOfPaginationReached doit être true.
  • 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 fonction load() doit renvoyer MediatorResult.Error.

Procédez comme suit pour tester le premier cas :

  1. Configurez l'élément mockApi avec les données des posts à renvoyer.
  2. Initialisez l'objet RemoteMediator.
  3. 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 :

  1. Initialisation des PagingData avec les données de test.
  2. Exécution des transformations sur les PagingData.
  3. Transmission des données transformées à la différence.
  4. 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.