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 de paginação de forma declarativa usando
collectAsLazyPagingItems, os testes da camada de interface podem se concentrar principalmente no
Flow<PagingData<Value>> emitido pelo ViewModel. Para programar testes e verificar se os dados na interface funcionam como esperado, inclua a dependência paging-testing. Ela contém a extensão asSnapshot em um Flow<PagingData<Value>>. Ela oferece
APIs no receptor lambda que permitem simulações de interações de rolagem. Ela
retorna uma List<Value> padrão produzida pelas interações de rolagem simuladas.
Isso permite declarar que os dados paginados contêm os elementos esperados
gerados por essas interações. Confira este snippet:
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 comomap()einsertSeparators()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
PagingSourceFactoryusando a extensão apropriada para suas necessidades. - Use a
PagingSourceFactoryretornada em uma implementação simulada doRepository. - Transmita esse
Repositorypara oViewModel.
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.
}
Testar estados de carregamento do Compose
Para testar como os componentes da interface do Compose reagem a diferentes estados de carregamento (como
LoadState.Loading ou LoadState.Error) de forma isolada, emita snapshots estáticos
de PagingData com LoadStates explícitos usando
PagingData.empty e PagingData.from por um MutableStateFlow:
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun userListScreen_displaysLoadingDataAndErrorStates() {
val idle = LoadState.NotLoading(endOfPaginationReached = false)
// Emit static PagingData snapshots to test UI state transitions without a
// Pager or ViewModel.
val pagingDataFlow = MutableStateFlow(PagingData.empty<User>())
composeTestRule.setContent {
val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()
UserListScreen(lazyPagingItems = lazyPagingItems)
}
// Verify the initial loading UI before any items arrive.
pagingDataFlow.value = PagingData.empty(
sourceLoadStates = LoadStates(
refresh = LoadState.Loading,
prepend = idle,
append = idle
)
)
composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()
// Verify that loaded items replace the loading indicator.
pagingDataFlow.value = PagingData.from(
data = listOf(User(id = "1", label = "User A")),
sourceLoadStates = LoadStates(
refresh = idle,
prepend = idle,
append = idle
)
)
composeTestRule.onNodeWithTag("LoadingSpinner").assertIsNotDisplayed()
composeTestRule.onNodeWithText("User A").assertIsDisplayed()
// Verify that a refresh failure surfaces the error message in the UI.
pagingDataFlow.value = PagingData.empty(
sourceLoadStates = LoadStates(
refresh = LoadState.Error(IOException("Network unavailable")),
prepend = idle,
append = idle
)
)
composeTestRule.onNodeWithText("Network unavailable").assertIsDisplayed()
}
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 testados funcionam corretamente quando
isolados. Os principais componentes que você precisa testar na camada do repositório são
PagingSource e RemoteMediator.
PagingSource teste
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 parâmetro RedditApi é uma interface Retrofit (link em inglês) 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()
}
}
RemoteMediator teste
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 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<Int, RedditPost>() {
...
}
É 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.
@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
mockApiretorna dados válidos. A funçãoload()precisa retornarMediatorResult.Successe a propriedadeendOfPaginationReachedprecisa serfalse. - O segundo é quando
mockApiretorna uma resposta bem-sucedida, mas os dados retornados estão vazios. A funçãoload()precisa retornarMediatorResult.Successe a propriedadeendOfPaginationReachedprecisa sertrue. - O terceiro é quando
mockApigera uma exceção ao buscar os dados. A funçãoload()precisa retornarMediatorResult.Error.
Siga estas etapas para testar o primeiro caso:
- Configure a
mockApicom os dados da postagem a serem retornados. - Inicialize o objeto
RemoteMediator. - 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<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 }
}
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<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 }
}
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.
@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 }
}
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), o ViewModel e a interface do Compose se integram
perfeitamente sem efeitos colaterais inesperados. Os testes ainda vão precisar de algumas dependências
simuladas, mas, em geral, vão abranger 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 de ponta a ponta, normalmente você troca
sua 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 forma que permita trocar facilmente as versões simuladas das dependências. O exemplo a seguir usa uma implementação básica de localizador de serviços (link em inglês) 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 dados adicionais 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, é necessário dar tempo para que a biblioteca
Paging busque o carregamento inicial e o emita 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á fazer uma declaração diretamente na árvore semântica
do Compose usando onNodeWithText para verificar se os itens estão realmente renderizados no
LazyColumn.
Outros recursos
Visualiza conteúdo
Recomendados para você
- Observação: o texto do link aparece quando o JavaScript está desativado
- Página da rede e do banco de dados
- Migrar para a Paging 3
- Carregar e exibir dados paginados