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. Além disso, escreva testes completos para conferir se todos os componentes na implementação da Paging funcionam corretamente juntos, sem efeitos colaterais inesperados.

Este guia explica como testar a biblioteca Paging nas diferentes camadas de arquitetura do app e como programar testes completos para toda a implementação dessa biblioteca.

Testes de camadas da interface

Como o Compose consome dados da Paging de forma declarativa usando collectAsLazyPagingItems, os testes de camadas da interface podem se concentrar totalmente no Flow<PagingData<Value>> emitido pela ViewModel. Para escrever testes que verifiquem se os dados na interface são como você espera, inclua a dependência paging-testing. Ela contém a asSnapshot extensão em um Flow<PagingData<Value>>. Ela oferece APIs no receptor lambda que permitem simulações de interações de rolagem. Ela retorna um List<Value> padrão produzido pelas interações de rolagem simuladas. Isso permite declarar que os dados paginados contêm os elementos esperados gerados por essas interações. Isso é ilustrado no snippet a seguir:

fun test_items_contain_one_to_ten() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll to the 50th item in the list. This will also suspend till
    // the prefetch requirement is met if there's one.
    // It also suspends until all loading is complete.
    scrollTo(index = 50)
  }

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected values
  assertEquals(
    expected = (0..50).map(Int::toString),
    actual = itemsSnapshot
  )
}

Como alternativa, você pode rolar até que um determinado predicado seja atendido, como mostrado no snippet abaixo:

fun test_footer_is_visible() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll till the footer is visible
    appendScrollWhile {  item: String -> item != "Footer" }
  }

Como testar transformações

Você também precisa programar testes de unidade para todas as transformações aplicadas ao fluxo PagingData. Usar a extensão asPagingSourceFactory. Essa extensão está disponível nestes tipos de dados:

  • List<Value>.
  • Flow<List<Value>>.

A escolha da extensão depende do que você está tentando testar. Usar:

  • List<Value>.asPagingSourceFactory(): se você quiser testar transformações estáticas como map() e insertSeparators() nos dados.
  • Flow<List<Value>>.asPagingSourceFactory(): se você quiser testar como as atualizações nos dados afetam o pipeline de paginação, por exemplo, a gravação na fonte de dados de backup.

Para usar uma dessas extensões, siga este padrão:

  • Crie a PagingSourceFactory usando a extensão apropriada para suas necessidades.
  • Use a PagingSourceFactory retornada em uma implementação simulada do Repository.
  • Transmita esse Repository para o ViewModel.

O ViewModel pode ser testado conforme abordado na seção anterior. Considere o seguinte ViewModel:

class MyViewModel(
  myRepository: myRepository
) {
  val items = Pager(
    config: PagingConfig,
    initialKey = null,
    pagingSourceFactory = { myRepository.pagingSource() }
  )
  .flow
  .map { pagingData ->
    pagingData.insertSeparators<String, String> { before, _ ->
      when {
        // Add a dashed String separator if the prior item is a multiple of 10
        before.last() == '0' -> "---------"
        // Return null to avoid adding a separator between two items.
        else -> null
      }
  }
}

Para testar a transformação no MyViewModel, forneça uma instância fictícia de MyRepository que delegue para uma List estática representando os dados que serão transformados, conforme mostrado neste snippet:

class FakeMyRepository() : MyRepository {
    private val items = (0..100).map(Any::toString)
    private val pagingSourceFactory = items.asPagingSourceFactory()

    // Expose as a function so a new PagingSource instance is
    // created each time it is called by the Pager
    fun pagingSource() = pagingSourceFactory()
}

Em seguida, programe um teste para a lógica do separador, como neste snippet:

fun test_separators_are_added_every_10_items() = runTest {
  // Create your ViewModel
  val viewModel = MyViewModel(
    myRepository = FakeMyRepository()
  )
  // Get the Flow of PagingData from the ViewModel with the separator transformations applied
  val items: Flow<PagingData<String>> = viewModel.items
                  
  val snapshot: List<String> = items.asSnapshot()

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected separators.
}

Testes de camadas de dados

Programe testes de unidade para os componentes na sua camada de dados para garantir que eles carreguem corretamente os dados das origens. Forneça versões simuladas de dependências para verificar se os componentes que estão sendo testados funcionam corretamente quando em isolamento. Os principais componentes que você precisa testar na camada do repositório são PagingSource e RemoteMediator.

Testes de PagingSource

Os testes de unidade para a implementação da PagingSource envolvem a configuração da instância PagingSource e o carregamento de dados dela com um TestPager.

Para configurar a instância PagingSource para testes, forneça dados fictícios ao construtor. Isso permite controlar os dados nos testes. No exemplo abaixo, o RedditApi parâmetro é 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, substituir as funções necessárias e fornecer métodos de conveniência para configurar como o objeto simulado precisa reagir aos testes.

Depois que os dados fictícios estiverem prontos, configure as dependências e inicialize o objeto PagingSource no teste. O exemplo abaixo demonstra a inicialização do objeto FakeRedditApi com uma lista de postagens de teste e o teste da instância RedditPagingSource:

class SubredditPagingSourceTest {
  private val mockPosts = listOf(
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT)
  )
  private val fakeApi = FakeRedditApi().apply {
    mockPosts.forEach { post -> addPost(post) }
  }

  @Test
  fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
    val pagingSource = RedditPagingSource(
      fakeApi,
      DEFAULT_SUBREDDIT
    )

    val pager = TestPager(CONFIG, pagingSource)

    val result = pager.refresh() as LoadResult.Page

    // Write assertions against the loaded data
    assertThat(result.data)
    .containsExactlyElementsIn(mockPosts)
    .inOrder()
  }
}

O TestPager também permite fazer o seguinte:

  • Teste carregamentos consecutivos da PagingSource:
    @Test
    fun test_consecutive_loads() = runTest {

      val page = with(pager) {
        refresh()
        append()
        append()
      } as LoadResult.Page

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • Teste os cenários de erro na PagingSource:
    @Test
    fun refresh_returnError() {
        val pagingSource = RedditPagingSource(
          fakeApi,
          DEFAULT_SUBREDDIT
        )
        // Configure your fake to return errors
        fakeApi.setReturnsError()
        val pager = TestPager(CONFIG, source)

        runTest {
            source.errorNextLoad = true
            val result = pager.refresh()
            assertTrue(result is LoadResult.Error)

            val page = pager.getLastLoadedPage()
            assertThat(page).isNull()
        }
    }

Testes de RemoteMediator

O objetivo dos testes de unidade de RemoteMediator é verificar se a load() função 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 de RemoteMediator que requer um banco de dados da Room, uma interface de Retrofit e uma string de pesquisa:

@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
  private val db: RedditDb,
  private val redditApi: RedditApi,
  private val subredditName: String
) : RemoteMediator&lt;Int, RedditPost&gt;() {
  ...
}

É possível fornecer a interface do Retrofit e a string de pesquisa conforme demonstrado em 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.

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

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().
@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&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertFalse { (result as MediatorResult.Success).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.

@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&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertTrue { (result as MediatorResult.Success).endOfPaginationReached }
}

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

@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&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue {result is MediatorResult.Error }
}

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 ajudam a verificar se a camada de dados (PagingSource ou RemoteMediator), a ViewModel e a interface do Compose se integram perfeitamente sem efeitos colaterais inesperados. Os 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. Para testes completos, normalmente você troca a API de rede real por uma falsa, mas ainda permite que a biblioteca Paging processe a busca real e o armazenamento em cache do banco de dados local (se estiver usando um RemoteMediator) para manter a fidelidade 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 básica de localizador de serviço e configura um teste com uma API simulada para verificar se uma tela do Compose consome e mostra corretamente os dados paginados. Em apps maiores, o uso de uma biblioteca de injeção de dependência, como o Hilt, pode ajudar a gerenciar gráficos de dependência mais complexos.

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 verificar se a interface do Compose é preenchida com os itens corretos quando a tela é carregada pela primeira vez, e outro teste precisa verificar se a interface carrega corretamente outros dados com base na interação do usuário.

No exemplo a seguir, o teste verifica se a interface mostra os dados paginados esperados.

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RedditScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    private val postFactory = PostFactory()
    private val mockApi = MockRedditApi()

    @Before
    fun setup() {
        // Pre-populate the mock API with test data for the default subreddit
        mockApi.addPost(postFactory.createRedditPost(subreddit = "androiddev", title = "Jetpack Compose Paging"))

        // Swap your real dependency injection module/Service Locator with the mock API
        ServiceLocator.swap(
            object : DefaultServiceLocator(useInMemoryDb = true) {
                override fun getRedditApi(): RedditApi = mockApi
            }
        )
    }

    @Test
    fun loadsTheDefaultResults() = runTest {
        // 1. Set the Compose UI content
        composeTestRule.setContent {
            MyTheme {
                // Assume that this composable uses `collectAsLazyPagingItems()` internally
                RedditScreen(initialSubreddit = "androiddev")
            }
        }

        // 2. Wait for the asynchronous Paging loads to complete
        composeTestRule.waitUntilExactlyOneExists(
            matcher = hasText("Jetpack Compose Paging"),
            timeoutMillis = 5000
        )

        // 3. Assert that the loaded paged items are displayed correctly on screen
        composeTestRule.onNodeWithText("Jetpack Compose Paging").assertIsDisplayed()
    }

    @Test
    fun loadsNewDataBasedOnUserInput() = runTest {
        // Add data for a different subreddit to the mock API
        mockApi.addPost(postFactory.createRedditPost(subreddit = "compose", title = "Compose Testing"))

        composeTestRule.setContent {
            MyTheme {
                RedditScreen(initialSubreddit = "androiddev")
            }
        }

        // Wait for the initial load to finish
        composeTestRule.waitUntilExactlyOneExists(hasText("Jetpack Compose Paging"))

        // Simulate user entering a new subreddit in a text field and clicking search
        composeTestRule.onNodeWithTag("SubredditInput").performTextClearance()
        composeTestRule.onNodeWithTag("SubredditInput").performTextInput("compose")
        composeTestRule.onNodeWithTag("SearchButton").performClick()

        // Wait for the new paged data to load
        composeTestRule.waitUntilExactlyOneExists(
            matcher = hasText("Compose Testing"),
            timeoutMillis = 5000
        )

        // Assert the old data is gone and the new data is displayed
        composeTestRule.onNodeWithText("Jetpack Compose Paging").assertDoesNotExist()
        composeTestRule.onNodeWithText("Compose Testing").assertIsDisplayed()
    }
}

Como Flow<PagingData> carrega dados de forma assíncrona, você precisa dar tempo para a biblioteca Paging buscar o carregamento inicial e emitir para collectAsLazyPagingItems antes de fazer declarações. Para fazer isso, use composeTestRule.waitUntil ou waitUntilExactlyOneExists, conforme mostrado no exemplo anterior.

Depois que os dados forem carregados, você poderá declarar diretamente na árvore semântica do Compose usando onNodeWithText para verificar se os itens são renderizados no LazyColumn.

Outros recursos

Conteúdo de visualizações