L'implémentation de la bibliothèque Paging dans votre application doit être associée à une stratégie de test robuste. Vous devez tester les composants de chargement de données, tels que PagingSource et RemoteMediator, pour vous assurer qu'ils fonctionnent comme prévu. Vous devez également écrire des tests de bout en bout pour vérifier que tous les composants de votre implémentation de Paging fonctionnent correctement ensemble, sans effets secondaires inattendus.
Ce guide explique comment tester la bibliothèque Paging dans les différentes couches d'architecture de votre application et comment écrire des tests de bout en bout pour l'ensemble de votre implémentation de Paging.
Tests de la couche d'interface utilisateur
Étant donné que Compose consomme les données de pagination de manière déclarative via collectAsLazyPagingItems, vos tests de la couche UI peuvent se concentrer entièrement sur le Flow<PagingData<Value>> émis par votre ViewModel. Pour écrire des tests afin de vérifier que les données de l'UI sont conformes à vos attentes, incluez la dépendance paging-testing. Elle contient l'extension asSnapshot au niveau d'une Flow<PagingData<Value>>. Elle propose des API dans son récepteur lambda qui permettent de simuler les interactions de défilement. Elle renvoie une List<Value> standard créée par les interactions de défilement simulées.
Vous pouvez ainsi vérifier que les données paginées contiennent les éléments attendus générés par ces interactions. Ceci est illustré dans l'extrait suivant :
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
)
}
Vous pouvez également faire défiler l'écran jusqu'à un prédicat donné, comme illustré dans l'extrait ci-dessous :
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" }
}
Tester les transformations
Vous devez également écrire des tests unitaires pour couvrir toutes les transformations que vous appliquez au flux PagingData. Utilisez l'extension asPagingSourceFactory. Cette extension est disponible pour les types de données suivants :
List<Value>Flow<List<Value>>
Le choix de l'extension à utiliser dépend de ce que vous essayez de tester. Utilisez :
List<Value>.asPagingSourceFactory()si vous souhaitez tester des transformations statiques telles quemap()etinsertSeparators()sur des données ;Flow<List<Value>>.asPagingSourceFactory()si vous souhaitez tester l'impact des mises à jour de vos données, comme l'écriture dans la source de données de sauvegarde, sur votre pipeline de pagination.
Pour utiliser l'une de ces extensions, procédez comme suit :
- Créez le
PagingSourceFactoryà l'aide de l'extension adaptée à vos besoins. - Utilisez le
PagingSourceFactoryrenvoyé dans une instance fictive pour votreRepository. - Transmettez ce
Repositoryà votreViewModel.
Le ViewModel pourra ensuite être testé comme indiqué dans la section précédente.
Prenons le ViewModel suivant à titre d'exemple :
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
}
}
}
Pour tester la transformation dans MyViewModel, fournissez une instance fictive de MyRepository qui délègue à une List statique représentant les données à transformer, comme indiqué dans l'extrait suivant :
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()
}
Vous pouvez ensuite écrire un test pour la logique de séparateur, comme dans l'extrait suivant :
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.
}
Tests de la couche de données
Créez des tests unitaires pour les composants de la couche de données afin de vous assurer qu'ils chargent les données depuis vos sources de données de manière appropriée. Fournissez des versions fictives des dépendances pour vérifier que les composants testés fonctionnent correctement et de manière isolée. Les principaux composants à tester dans la couche de dépôt sont PagingSource et RemoteMediator.
Tests PagingSource
Les tests unitaires pour votre implémentation de PagingSource impliquent de configurer l'instance de PagingSource et de charger des données à partir de celle-ci avec un TestPager.
Pour configurer l'instance de PagingSource à des fins de test, fournissez des données fictives au constructeur. Vous pouvez ainsi contrôler les données de vos tests.
Dans l'exemple suivant, le paramètre RedditApi est une interface Retrofit qui définit les requêtes du serveur et les classes de réponse.
Une version fictive peut mettre en œuvre l'interface, forcer les fonctions requises et fournir des méthodes pratiques permettant de configurer la réaction de l'objet fictif lors des tests.
Une fois les versions fictives en place, configurez les dépendances et initialisez l'objet PagingSource dans le test. L'exemple suivant démontre l'initialisation de l'objet FakeRedditApi avec une liste de posts de test, et le test de l'instance 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()
}
}
Le TestPager vous permet également d'effectuer les opérations suivantes :
- Tester les chargements consécutifs depuis votre
PagingSource:
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- Tester les scénarios d'erreur dans votre
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()
}
}
Tests RemoteMediator
L'objectif des tests unitaires RemoteMediator est de vérifier que la fonction load() renvoie la bonne valeur MediatorResult.
Les tests liés aux effets secondaires, tels que l'insertion de données dans la base de données, sont plus adaptés aux tests d'intégration.
La première étape consiste à déterminer les dépendances dont votre implémentation RemoteMediator a besoin. L'exemple suivant illustre une implémentation RemoteMediator nécessitant une base de données Room, une interface Retrofit et une chaîne de recherche :
@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
private val db: RedditDb,
private val redditApi: RedditApi,
private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
...
}
Vous pouvez fournir l'interface Retrofit et la chaîne de recherche comme indiqué dans la section Tests PagingSource. Il est très difficile de fournir une version fictive de la base de données Room. Il est donc plus simple de fournir une implémentation en mémoire de la base de données plutôt qu'une version complète. La création d'une base de données Room nécessite un objet Context. Vous devez donc placer ce test RemoteMediator dans le répertoire androidTest et l'exécuter avec l'exécuteur de test AndroidJUnit4 afin qu'il ait accès à un contexte d'application test. Pour en savoir plus sur les tests instrumentés, consultez Créer des tests unitaires instrumentés.
Définissez des fonctions de suppression pour vous assurer que l'état ne fuit pas entre les fonctions de test. Cela garantit des résultats cohérents entre les exécutions de test.
@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()
}
}
L'étape suivante consiste à tester la fonction load(). Dans cet exemple, trois cas sont à tester :
- Le premier cas se présente lorsque
mockApirenvoie des données valides. La fonctionload()doit renvoyerMediatorResult.Success, et la propriétéendOfPaginationReacheddoit êtrefalse. - Le second cas se produit lorsque
mockApirenvoie une réponse positive, mais que les données renvoyées sont vides. La fonctionload()doit renvoyerMediatorResult.Success, et la propriétéendOfPaginationReacheddoit êtretrue. - Le troisième cas de figure se produit lorsque
mockApigénère une exception lors de la récupération des données. La fonctionload()doit renvoyerMediatorResult.Error.
Procédez comme suit pour tester le premier cas :
- Configurez l'élément
mockApiavec les données des posts à renvoyer. - Initialisez l'objet
RemoteMediator. - Testez la fonction
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 }
}
Le deuxième test nécessite que mockApi renvoie un résultat vide. Comme vous effacez les données de mockApi après chaque exécution, un résultat vide sera renvoyé par défaut.
@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 }
}
Le test final nécessite que mockApi génère une exception afin que le test puisse vérifier que la fonction load() renvoie correctement MediatorResult.Error.
@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 }
}
Tests de bout en bout
Les tests unitaires garantissent que les composants Paging individuels fonctionnent de manière isolée, mais les tests de bout en bout indiquent que l'application fonctionne dans son ensemble. Ces tests permettent de vérifier que votre couche de données (PagingSource ou RemoteMediator), ViewModel et l'UI Compose s'intègrent de manière fluide sans effets secondaires inattendus. Ces tests auront toujours besoin de certaines dépendances, mais ils couvrent généralement la plus grande partie du code de votre application.
L'exemple de cette section utilise une dépendance d'API fictive pour éviter d'utiliser le réseau dans les tests. L'API fictive est configurée pour renvoyer un ensemble cohérent de données de test, ce qui génère des tests reproductibles. Pour les tests de bout en bout, vous remplacez généralement votre véritable API réseau par une fausse, mais vous laissez toujours la bibliothèque Paging gérer la récupération et la mise en cache de la base de données locale (si vous utilisez un RemoteMediator) pour maintenir la fidélité de vos tests.
Écrivez votre code de manière à pouvoir facilement interchanger des versions fictives de vos dépendances. L'exemple suivant utilise une implémentation d'outil de localisation de services de base et configure un test avec une API fictive pour vérifier qu'un écran Compose consomme et affiche correctement les données paginées. Dans les applications plus importantes, l'utilisation d'une bibliothèque d'injection de dépendances comme Hilt peut permettre de gérer des graphes de dépendances plus complexes.
Après avoir configuré la structure de test, l'étape suivante consiste à vérifier que les données renvoyées par l'implémentation de Pager sont correctes. Un test doit vérifier que l'UI Compose se remplit avec les bons éléments lors du chargement initial de l'écran, et un autre test doit vérifier que l'UI charge correctement les données supplémentaires en fonction de l'interaction de l'utilisateur.
Dans l'exemple suivant, le test vérifie que l'UI affiche les données paginées attendues.
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()
}
}
Étant donné que Flow<PagingData> charge les données de manière asynchrone, vous devez laisser le temps à la bibliothèque Paging de récupérer le chargement initial et de l'émettre vers collectAsLazyPagingItems avant de faire des assertions. Pour ce faire, utilisez composeTestRule.waitUntil ou waitUntilExactlyOneExists, comme indiqué dans l'exemple précédent.
Une fois les données chargées, vous pouvez effectuer des assertions directement sur l'arborescence sémantique Compose à l'aide de onNodeWithText pour vérifier que les éléments sont réellement affichés dans votre LazyColumn.
Ressources supplémentaires
Afficher le contenu
Recommandations personnalisées
- Remarque : Le texte du lien s'affiche lorsque JavaScript est désactivé
- Page du réseau et de la base de données
- Effectuer une migration vers Paging 3
- Charger et afficher des données paginées