L'implementazione della libreria Paging nella tua app deve essere abbinata a una strategia di test solida. Devi testare i componenti di caricamento dei dati come
PagingSource e
RemoteMediator
per assicurarti che funzionino come previsto. Devi anche scrivere test end-to-end per verificare che tutti i componenti della tua implementazione di Paging funzionino correttamente insieme senza effetti collaterali imprevisti.
Questa guida spiega come testare la libreria Paging nei diversi livelli di architettura della tua app, nonché come scrivere test end-to-end per l'intera implementazione di Paging.
Test del livello UI
Poiché Compose utilizza i dati di Paging in modo dichiarativo tramite collectAsLazyPagingItems, i test del livello UI possono concentrarsi principalmente su Flow<PagingData<Value>> emesso dal ViewModel. Per scrivere test per verificare che i dati nell'interfaccia utente siano quelli previsti, includi la dipendenza paging-testing. Contiene l'estensione asSnapshot su un Flow<PagingData<Value>>. Offre API nel suo ricevitore lambda che consentono di simulare le interazioni di scorrimento. Restituisce un List<Value> standard prodotto dalle interazioni di scorrimento simulate.
In questo modo puoi verificare che i dati di cui viene eseguita la paginazione contengano gli elementi previsti generati da queste interazioni. Questo è illustrato nel seguente
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
)
}
In alternativa, puoi scorrere fino a quando non viene soddisfatto un determinato predicato, come mostrato nello snippet di seguito:
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" }
}
Test delle trasformazioni
Devi anche scrivere test delle unità che coprano tutte le trasformazioni applicate allo stream PagingData. Utilizza l'estensione asPagingSourceFactory. Questa estensione è disponibile per i seguenti tipi di dati:
List<Value>.Flow<List<Value>>.
La scelta dell'estensione da utilizzare dipende da ciò che vuoi testare. Utilizzo:
List<Value>.asPagingSourceFactory(): se vuoi testare trasformazioni statiche comemap()einsertSeparators()sui dati.Flow<List<Value>>.asPagingSourceFactory(): se vuoi testare l'impatto degli aggiornamenti ai tuoi dati, ad esempio la scrittura nell'origine dati di supporto, sulla pipeline di paging.
Per utilizzare una di queste estensioni, segui questo pattern:
- Crea il
PagingSourceFactoryutilizzando l'estensione appropriata per le tue esigenze. - Utilizza il valore
PagingSourceFactoryrestituito in un falso per il tuoRepository. - Passa il
Repositoryal tuoViewModel.
ViewModel può quindi essere testato come descritto nella sezione precedente.
Considera quanto segue 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
}
}
}
Per testare la trasformazione in MyViewModel, fornisci un'istanza fittizia di MyRepository che delega a un List statico che rappresenta i dati da trasformare, come mostrato nello snippet seguente:
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()
}
Puoi quindi scrivere un test per la logica del separatore come nello snippet seguente:
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.
}
Testa gli stati di caricamento di Compose
Per testare in modo isolato il modo in cui i componenti della UI di Compose reagiscono a diversi stati di caricamento (ad esempio LoadState.Loading o LoadState.Error), genera snapshot PagingData statici con LoadStates esplicito utilizzando PagingData.empty e PagingData.from tramite un 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()
}
Test del livello dati
Scrivi test delle unità per i componenti del livello dati per assicurarti che carichino
i dati dalle origini dati in modo appropriato. Fornisci versioni fittizie delle dipendenze per verificare che i componenti in fase di test funzionino correttamente in isolamento. I componenti principali da testare nel livello del repository sono
PagingSource e RemoteMediator.
PagingSource test
I test delle unità per l'implementazione di PagingSource prevedono la configurazione dell'istanza PagingSource e il caricamento dei dati da questa con un TestPager.
Per configurare l'istanza PagingSource per i test, fornisci dati fittizi al costruttore. In questo modo, hai il controllo sui dati dei test. Nell'esempio seguente, il parametro RedditApi è un'interfaccia Retrofit che definisce le richieste del server e le classi di risposta. Una versione fittizia può implementare l'interfaccia, eseguire l'override di tutte le funzioni richieste e fornire metodi pratici per configurare il modo in cui l'oggetto fittizio deve reagire nei test.
Una volta posizionati i fake, configura le dipendenze e inizializza l'oggetto PagingSource nel test. L'esempio seguente mostra l'inizializzazione dell'oggetto FakeRedditApi con un elenco di post di test e il test dell'istanza 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()
}
}
TestPager consente inoltre di:
- Testa i carichi consecutivi dal tuo
PagingSource:
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- Testa gli scenari di errore nel tuo
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 test
L'obiettivo dei test delle unità RemoteMediator è verificare che la funzione load() restituisca il valore MediatorResult corretto.
I test per gli effetti collaterali, come l'inserimento di dati nel database, sono più adatti ai test di integrazione.
Il primo passaggio consiste nel determinare le dipendenze necessarie per l'implementazione di RemoteMediator. L'esempio seguente mostra un'implementazione di RemoteMediator che richiede un database Room, un'interfaccia Retrofit e una stringa di ricerca:
@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
private val db: RedditDb,
private val redditApi: RedditApi,
private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
...
}
Puoi fornire l'interfaccia Retrofit e la stringa di ricerca come mostrato nella sezione Test di PagingSource. Fornire una versione fittizia del database Room è molto complesso, quindi può essere più semplice fornire un'implementazione in memoria del database anziché una versione fittizia completa. Poiché la creazione di un database Room richiede un oggetto Context, devi inserire questo test RemoteMediator nella directory androidTest ed eseguirlo con l'esecutore di test AndroidJUnit4 in modo che abbia accesso a un contesto dell'applicazione di test. Per saperne di più sui test di instrumentazione, consulta Crea test di unità di instrumentazione.
Definisci le funzioni di chiusura per assicurarti che lo stato non venga divulgato tra le funzioni di test. In questo modo, i risultati saranno coerenti tra le esecuzioni dei test.
@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()
}
}
Il passaggio successivo consiste nel testare la funzione load(). In questo esempio, ci sono tre
casi da testare:
- Il primo caso è quando
mockApirestituisce dati validi. La funzioneload()dovrebbe restituireMediatorResult.Successe la proprietàendOfPaginationReacheddovrebbe esserefalse. - Il secondo caso è quando
mockApirestituisce una risposta positiva, ma i dati restituiti sono vuoti. La funzioneload()deve restituireMediatorResult.Successe la proprietàendOfPaginationReacheddeve esseretrue. - Il terzo caso è quando
mockApigenera un'eccezione durante il recupero dei dati. La funzioneload()deve restituireMediatorResult.Error.
Segui questi passaggi per testare il primo caso:
- Configura
mockApicon i dati POST da restituire. - Inizializza l'oggetto
RemoteMediator. - Testa la funzione
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 }
}
Il secondo test richiede che mockApi restituisca un risultato vuoto. Poiché cancelli i dati da mockApi dopo ogni esecuzione del test, per impostazione predefinita verrà restituito un risultato vuoto.
@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 }
}
Il test finale richiede che mockApi generi un'eccezione in modo che il test possa verificare che la funzione load() restituisca correttamente 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<Int, RedditPost>(
listOf(),
null,
PagingConfig(10),
10
)
val result = remoteMediator.load(LoadType.REFRESH, pagingState)
assertTrue {result is MediatorResult.Error }
}
Test end-to-end
I test delle unità garantiscono che i singoli componenti di Paging funzionino in isolamento, ma i test end-to-end offrono una maggiore sicurezza che l'applicazione funzioni nel suo complesso. Questi test aiutano a verificare che il livello dati (PagingSource o RemoteMediator), ViewModel e la UI di Compose si integrino perfettamente senza effetti collaterali imprevisti. I test avranno comunque bisogno di alcune dipendenze fittizie, ma in genere copriranno la maggior parte del codice dell'app.
L'esempio in questa sezione utilizza una dipendenza API fittizia per evitare di utilizzare la rete nei test. L'API fittizia è configurata per restituire un insieme coerente di dati di test, il che si traduce in test ripetibili. Per i test end-to-end, in genere si sostituisce l'API di rete reale con una fittizia, ma si lascia comunque che la libreria Paging gestisca il recupero effettivo e la memorizzazione nella cache del database locale (se si utilizza un RemoteMediator) per mantenere la fedeltà dei test.
Scrivi il codice in modo da poter sostituire facilmente le versioni fittizie delle tue dipendenze. L'esempio seguente utilizza un'implementazione di base del localizzatore di servizi e configura un test con un'API fittizia per verificare che una schermata di Compose utilizzi e visualizzi correttamente i dati impaginati. Nelle app più grandi, l'utilizzo di una libreria di inserimento delle dipendenze come Hilt può aiutare a gestire grafici delle dipendenze più complessi.
Dopo aver configurato la struttura di test, il passaggio successivo consiste nel verificare che i dati restituiti dall'implementazione di Pager siano corretti. Un test deve verificare che la UI di Compose venga compilata con gli elementi corretti al primo caricamento della schermata, mentre un altro test deve verificare che la UI carichi correttamente i dati aggiuntivi in base all'interazione dell'utente.
Nell'esempio seguente, il test verifica che la UI mostri i dati impaginati previsti.
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()
}
}
Poiché Flow<PagingData> carica i dati in modo asincrono, devi concedere alla libreria Paging il tempo necessario per recuperare il carico iniziale ed emetterlo in collectAsLazyPagingItems prima di effettuare le asserzioni. Per farlo, utilizza composeTestRule.waitUntil o waitUntilExactlyOneExists, come mostrato nell'esempio precedente.
Una volta caricati i dati, puoi eseguire l'assert direttamente sull'albero semantico di Compose utilizzando onNodeWithText per verificare che gli elementi vengano effettivamente visualizzati nel tuo LazyColumn.
Risorse aggiuntive
Visualizza contenuti
Consigliati per te
- Nota: il testo del link viene visualizzato quando JavaScript è disattivato
- Paginazione da rete e database
- Esegui la migrazione a Paging 3
- Carica e visualizza i dati impaginati