Testar a implementação da Paging

A implementação da biblioteca Paging no seu app precisa ser pareada com uma estratégia de testes robusta. Teste os componentes de carregamento de dados, como PagingSource e RemoteMediator, para garantir que eles funcionem conforme o esperado. Crie testes completos para verificar se todos os componentes na implementação da Paging funcionam corretamente juntos, sem efeitos colaterais inesperados.

Este guia explica como testar as fontes de dados de paginação de forma isolada, como testar as transformações que você realiza em dados paginados e como escrever testes completos para toda a implementação da Paging. Os exemplos deste guia são baseados no exemplo da Paging com rede (em inglês).

Testes de camada do repositório

Escreva testes de unidade para os componentes na camada do repositório para garantir que eles carreguem os dados das fontes de dados corretamente. Forneça versões simuladas de dependências para verificar se os componentes na função testada funcionam corretamente quando isolados. Os principais componentes que você precisa testar na camada do repositório são PagingSource e RemoteMediator.

Testes do PagingSource

Os testes de unidade para as classes PagingSource envolvem configurar a instância PagingSource e verificar se a função load() retorna os dados corretos paginados com base no argumento LoadParams. Forneça dados simulados ao construtor PagingSource para ter controle sobre os dados nos testes. É possível passar um valor padrão para tipos de dados primitivos, mas você precisa passar uma versão simulada para outros objetos, como um banco de dados ou uma implementação de API. Isso proporciona controle total sobre a saída da fonte de dados de simulação quando a PagingSource em teste interagir com ela.

Os parâmetros do construtor da implementação específica do PagingSource determinam o tipo de dado de teste que você precisa transmitir. No exemplo a seguir, a implementação PagingSource exige um objeto RedditApi, e uma String para o nome do subreddit. É possível transmitir um valor padrão para o parâmetro da String, mas para o parâmetro RedditApi é necessário criar uma implementação simulada para testes.

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;
    ...
  }
}

Neste exemplo, o parâmetro RedditApi é uma interface Retrofit que define as solicitações do servidor e as classes de resposta. Uma versão de simulação pode implementar a interface, modificar as funções necessárias e fornecer métodos de conveniência para configurar como o objeto simulado deve reagir aos testes.

Depois que os objetos simulados estiverem em vigor, configure as dependências e inicialize o objeto PagingSource no teste. O exemplo a seguir demonstra a inicialização do objeto MockRedditApi com uma lista de postagens de teste:

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() = runBlockingTest {
    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>

A próxima etapa é testar o objeto PagingSource. O método load() é o foco principal dos testes. O exemplo a seguir demonstra uma declaração confirmando que o método load() retorna os dados corretos, a chave anterior e a próxima chave para um determinado parâmetro LoadParams:

Kotlin

@Test
// Since load is a suspend function, runBlockingTest is used to ensure that it
// runs on the test thread.
fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runBlockingTest {
  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));
}

O exemplo anterior demonstra como testar um PagingSource que usa paginação com codificação de item. Se a fonte de dados que você está usando for codificada por página, os testes da PagingSource serão diferentes. A principal diferença está nas chaves anteriores e posteriores que são esperadas do método load().

Kotlin

@Test
fun loadReturnsPageWhenOnSuccessfulLoadOfPageKeyedData() = runBlockingTest {
  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));
}

Testes do RemoteMediator

O objetivo dos testes de unidade de RemoteMediator é verificar se a função load() retorna o MediatorResult correto. Testes de efeitos colaterais, como inserção de dados no banco de dados, são mais adequados para testes de integração.

A primeira etapa é determinar de quais dependências sua implementação de RemoteMediator precisa. O exemplo a seguir demonstra uma implementação do RemoteMediator que requer um banco de dados do Room, uma interface da Retrofit e uma string de pesquisa:

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;
    ...
  }
}

É possível fornecer a interface do Retrofit e a string de pesquisa conforme demonstrado na seção Testes de PagingSource. Fornecer uma versão de simulação do banco de dados da Room é muito elaborado. Portanto, pode ser mais fácil fornecer uma implementação na memória do banco de dados em vez de uma versão de simulação completa. Como a criação de um banco de dados da Room requer um objeto Context, você precisa colocar esse teste RemoteMediator no diretório androidTest e executá-lo com o Executor de teste do AndroidJUnit4 para que tenha acesso a um contexto de app de teste. Para mais informações sobre testes de instrumentação, consulte Criar testes de unidade de instrumentação.

Defina funções de desmontagem para garantir que o estado não vaze entre funções de teste. Isso garante resultados consistentes entre as execuções de teste.

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();
  }
}

A próxima etapa é testar a função load(). Neste exemplo, há três cenários de teste:

  • O primeiro é quando mockApi retorna dados válidos. A função load() precisa retornar MediatorResult.Success e a propriedade endOfPaginationReached precisa ser false.
  • O segundo é quando mockApi retorna uma resposta bem-sucedida, mas os dados retornados estão vazios. A função load() precisa retornar MediatorResult.Success e a propriedade endOfPaginationReached precisa ser true.
  • O terceiro é quando mockApi gera uma exceção ao buscar os dados. A função load() precisa retornar MediatorResult.Error.

Siga estas etapas para testar o primeiro caso:

  1. Configure a mockApi com os dados da postagem a serem retornados.
  2. Inicialize o objeto RemoteMediator.
  3. Teste a função load().

Kotlin

@Test
fun refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() = runBlocking {
  // 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());
}

O segundo teste exige que mockApi retorne um resultado vazio. Como você limpa os dados da mockApi após cada execução de teste, ela retorna um resultado vazio por padrão.

Kotlin

@Test
fun refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() = runBlocking {
  // 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());
}

O teste final exige que mockApi gere uma exceção para que o teste possa verificar se a função load() retorna MediatorResult.Error corretamente.

Kotlin

@Test
fun refreshLoadReturnsErrorResultWhenErrorOccurs() = runBlocking {
  // 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));
}

Testes de transformação

Você também precisa programar testes de unidade para abranger todas as transformações aplicadas ao fluxo PagingData. Se a implementação da Paging executar qualquer mapeamento ou filtragem de dados, você precisará testar essas transformações para garantir que elas funcionem conforme o esperado. Use AsyncPagingDataDiffer nesses testes porque não é possível acessar diretamente a saída do fluxo PagingData.

O exemplo a seguir demonstra algumas transformações básicas aplicadas a um objeto PagingData para fins de teste:

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)
  );
}

O objeto AsyncPagingDataDiffer exige vários parâmetros, mas a maioria pode ser implementações vazias para fins de teste. Dois desses parâmetros são uma implementação DiffUtil.ItemCallback e 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);
  }
}

Com essas dependências em vigor, você pode programar os testes de transformação. Os testes precisam executar estas etapas:

  1. Inicializar PagingData com dados de teste.
  2. Executar as transformações em PagingData.
  3. Transmitir os dados transformados para o diferenciador.
  4. Depois que o diferenciador analisar os dados, acessar um snapshot da saída do diferenciador e verificar se os dados estão corretos.

O exemplo a seguir demonstra esse processo:

Kotlin

@ExperimentalCoroutinesApi
class PagingDataTransformTest {
  private val testDispatcher = TestCoroutineDispatcher()
  private val testScope = TestCoroutineScope(testDispatcher)

  @Before
  fun setUp() {
    Dispatchers.setMain(testDispatcher)
  }

  @After
  fun tearDown() {
    Dispatchers.resetMain()
  }

  @Test
  fun differTransformsData() = testScope.runBlockingTest {
    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());

Testes de ponta a ponta

Os testes de unidade garantem que os componentes individuais da Paging funcionem no isolamento, mas os testes completos oferecem mais confiança de que o aplicativo funciona como um todo. Esses testes ainda precisarão de algumas dependências simuladas, mas geralmente abrangem a maior parte do código do app.

No exemplo desta seção, usamos uma dependência de API simulada para evitar o uso da rede em testes. A API de simulação está configurada para retornar um conjunto consistente de dados de teste, resultando em testes reproduzíveis. Decida quais dependências trocar por implementações simuladas com base no que cada dependência faz, a consistência da saída e a fidelidade necessária dos testes.

Escreva o código de uma maneira que permita trocar facilmente versões simuladas das suas dependências. O exemplo a seguir usa uma implementação de localizador de serviço básica para fornecer e mudar dependências conforme necessário. Em apps maiores, o uso de uma biblioteca de injeção de dependência, como Hilt, pode ajudar a gerenciar gráficos de dependência mais complexos.

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;
        }
      }
    );
  }
}

Depois de configurar a estrutura de teste, a próxima etapa é verificar se os dados retornados pela implementação de Pager estão corretos. Um teste precisa garantir que o objeto Pager carregue os dados padrão quando a página for carregada pela primeira vez, e outro teste precisa verificar se o objeto Pager carrega corretamente outros dados com base na entrada do usuário. No exemplo a seguir, o teste verifica se o objeto Pager atualiza a RecyclerView.Adapter com o número correto de itens retornados da API quando o usuário digita uma pesquisa de um subreddit diferente.

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());
  });
}

Os testes de instrumentação verificam se os dados são exibidos corretamente na IU. Faça isso verificando se há o número correto de itens no RecyclerView.Adapter, ou iterando as visualizações de linhas individuais e verificando se os dados estão formatados corretamente.