Tester votre implémentation de Paging

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 que map() et insertSeparators() 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 PagingSourceFactory renvoyé dans une instance fictive pour votre Repository.
  • Transmettez ce Repository à votre ViewModel.

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&lt;Int, RedditPost&gt;() {
  ...
}

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 mockApi renvoie des données valides. La fonction load() doit renvoyer MediatorResult.Success, et la propriété endOfPaginationReached doit être false.
  • Le second cas se produit lorsque mockApi renvoie une réponse positive, mais que les données renvoyées sont vides. La fonction load() doit renvoyer MediatorResult.Success, et la propriété endOfPaginationReached doit être true.
  • Le troisième cas de figure se produit lorsque mockApi génère une exception lors de la récupération des données. La fonction load() doit renvoyer MediatorResult.Error.

Procédez comme suit pour tester le premier cas :

  1. Configurez l'élément mockApi avec les données des posts à renvoyer.
  2. Initialisez l'objet RemoteMediator.
  3. 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&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 }
}

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&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 }
}

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&lt;Int, RedditPost&gt;(
    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