Cómo probar tu implementación de Paging

La implementación de la biblioteca de Paging en tu app debe sincronizarse con una sólida estrategia de prueba. Debes probar los componentes de carga de datos, como PagingSource y RemoteMediator, para asegurarte de que funcionan como se espera. Además, debes escribir pruebas de extremo a extremo para verificar que todos los componentes de la implementación de Paging funcionen correctamente en conjunto, sin efectos secundarios imprevistos.

En esta guía, se explica cómo probar la biblioteca de Paging en las diferentes capas de arquitectura de la app y cómo escribir pruebas de extremo a extremo para toda la implementación de Paging.

Pruebas de la capa de la IU

Como Compose consume datos de Paging de forma declarativa a través de collectAsLazyPagingItems, las pruebas de la capa de la IU pueden enfocarse por completo en el Flow<PagingData<Value>> que emite tu ViewModel. Para escribir pruebas que verifiquen que los datos de la IU sean los esperados, incluye la dependencia paging-testing. Contiene la asSnapshot extensión en un Flow<PagingData<Value>>. Ofrece APIs en su receptor lambda que permiten simular interacciones de desplazamiento. Muestra un List<Value> estándar producido por las interacciones de desplazamiento simuladas. Esto te permite confirmar que los datos paginados contienen los elementos esperados generados por esas interacciones. Esto se ilustra en el siguiente fragmento:

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, puedes desplazarte hasta que se cumpla un predicado determinado, como se muestra en el siguiente fragmento:

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

Pruebas de transformaciones

También debes escribir pruebas de unidades que abarquen todas las transformaciones que apliques al flujo de PagingData. Usa la extensión asPagingSourceFactory. Esta extensión está disponible en los siguientes tipos de datos:

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

La opción de usar la extensión depende de lo que intentes probar. Usar:

  • List<Value>.asPagingSourceFactory(): Si quieres probar transformaciones estáticas, como map() y insertSeparators(), en los datos.
  • Flow<List<Value>>.asPagingSourceFactory(): Si quieres probar cómo las actualizaciones de tus datos, como escribir en la fuente de datos de copia de seguridad, afectan tu canalización de paginación.

Para usar cualquiera de estas extensiones, sigue el siguiente patrón:

  • Crea el PagingSourceFactory con la extensión adecuada para tus necesidades.
  • Usa el objeto PagingSourceFactory que se muestra en una implementación falsa para tu Repository.
  • Pasa ese Repository a tu ViewModel.

Luego, se puede probar el ViewModel como se explicó en la sección anterior. Ten en cuenta el siguiente 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 probar la transformación en MyViewModel, proporciona una instancia falsa de MyRepository que se delegue a un List estático que represente los datos que se transformarán, como se muestra en el siguiente fragmento:

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

Luego, puedes escribir una prueba para la lógica del separador, como en el siguiente fragmento:

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

Pruebas de la capa de datos

Escribe pruebas de unidades para los componentes de tu capa de datos y asegúrate de que carguen correctamente los datos de tus fuentes de datos. Proporciona versiones ficticias de dependencias para verificar que los componentes en prueba funcionen correctamente en forma aislada. Los componentes principales que debes probar en la capa del repositorio son PagingSource y RemoteMediator.

Pruebas de PagingSource

Las pruebas de unidades para tu implementación de PagingSource implican configurar la instancia de PagingSource y cargar los datos con una TestPager.

Si deseas configurar la instancia PagingSource para realizar pruebas, proporciona datos falsos al constructor. Esto te permite controlar los datos de las pruebas. En el siguiente ejemplo, el RedditApi parámetro es una interfaz de Retrofit que define las solicitudes del servidor y las clases de respuesta. Una versión ficticia puede implementar la interfaz, anular las funciones necesarias y proporcionar métodos útiles para configurar cómo debe reaccionar el objeto ficticio en las pruebas.

Una vez que se implementen las simulaciones, configura las dependencias y, luego, inicializa el objeto PagingSource en la prueba. En el siguiente ejemplo, se muestra cómo inicializar el objeto FakeRedditApi con una lista de publicaciones de prueba y cómo probar la instancia 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 también te permite hacer lo siguiente:

  • Prueba las cargas consecutivas desde tu PagingSource:
    @Test
    fun test_consecutive_loads() = runTest {

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

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • Prueba situaciones de error en tu 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()
        }
    }

Pruebas de RemoteMediator

El objetivo de las pruebas de unidades de RemoteMediator es verificar que la load() función muestre el MediatorResult correcto. Las pruebas de efectos secundarios, por ejemplo, los datos que se insertan en la base de datos, son más adecuadas para las pruebas de integración.

El primer paso consiste en determinar qué dependencias necesita tu implementación de RemoteMediator. En el siguiente ejemplo, se muestra una implementación de RemoteMediator que requiere una base de datos de Room, una interfaz de Retrofit y una string de búsqueda:

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

Puedes proporcionar la interfaz de Retrofit y la string de búsqueda como se indica en la sección Pruebas de PagingSource. Proporcionar una versión ficticia de la base de datos de Room es muy complicado, así que quizás sea más fácil realizar una implementación en la memoria de la base de datos en lugar de una versión ficticia completa. Como para crear una base de datos de Room se necesita un objeto Context, debes colocar esta prueba de RemoteMediator en el directorio androidTest y ejecutarla con AndroidJUnit4, el corredor de prueba, para que tenga acceso a un contexto de aplicación de prueba. Si quieres obtener más información sobre las pruebas instrumentadas, consulta Cómo compilar pruebas de unidades instrumentadas.

Define las funciones de anulación a fin de asegurarte de que el estado no se fugue entre las funciones de prueba. Esto garantiza resultados coherentes entre las ejecuciones de prueba.

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

Por último, debes probar la función load(). En este ejemplo, hay tres casos para probar:

  • El primer caso es cuando mockApi muestra datos válidos. La función load() debe mostrar MediatorResult.Success y la propiedad endOfPaginationReached debe ser false.
  • El segundo caso es cuando mockApi muestra una respuesta afirmativa, pero los datos están vacíos. La función load() debe mostrar MediatorResult.Success, y la propiedad endOfPaginationReached debe ser true.
  • El tercer caso es cuando mockApi arroja una excepción al recuperar los datos. La función load() debe mostrar MediatorResult.Error.

Sigue estos pasos para probar el primer caso:

  1. Configura la mockApi con los datos de entrada que se mostrarán.
  2. Inicializa el objeto RemoteMediator.
  3. Prueba la función 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 }
}

La segunda prueba requiere que mockApi muestre un resultado vacío. Como borras los datos de mockApi después de cada ejecución de prueba, se mostrará un resultado vacío de forma predeterminada.

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

La prueba final requiere que mockApi arroje una excepción para que la prueba pueda verificar que la función load() muestra correctamente a 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 }
}

Pruebas de extremo a extremo

Las pruebas de unidades garantizan que los componentes individuales de Paging funcionen de forma aislada, pero las pruebas de extremo a extremo brindan más confianza que la aplicación en conjunto. Estas pruebas ayudan a verificar que tu capa de datos (PagingSource o RemoteMediator), ViewModel y la IU de Compose se integren sin problemas y sin efectos secundarios inesperados. Esas pruebas necesitarán algunas dependencias ficticias, pero, en general, cubrirán la mayor parte del código de tu app.

En el ejemplo de esta sección, se usa una dependencia de API ficticia para evitar el uso de la red en las pruebas. La API ficticia está configurada para mostrar un conjunto coherente de datos de prueba, lo que genera pruebas repetibles. Para las pruebas de extremo a extremo, por lo general, cambias tu API de red real por una ficticia, pero aún permites que la biblioteca de Paging controle la recuperación real y el almacenamiento en caché de la base de datos local (si usas un RemoteMediator) para mantener la fidelidad de tus pruebas.

Escribe el código de manera que te permita cambiar con facilidad a versiones ficticias de tus dependencias. En el siguiente ejemplo, se usa una implementación de localizador de servicios básica y se configura una prueba con una API ficticia para verificar que una pantalla de Compose consuma y muestre correctamente los datos paginados. En apps más grandes, el uso de una biblioteca de inyección de dependencias como Hilt puede ayudar a administrar gráficos de dependencias más complejos.

Después de configurar la estructura de prueba, por último, debes verificar que los datos que muestra la implementación de Pager sean correctos. Una prueba debe verificar que la IU de Compose se complete con los elementos correctos cuando la pantalla se carga por primera vez, y otra debe verificar que la IU cargue correctamente datos adicionales según la interacción del usuario.

En el siguiente ejemplo, la prueba verifica que la IU muestre los datos 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> carga datos de forma asíncrona, debes darle tiempo a la biblioteca de Paging para recuperar la carga inicial y emitirla a collectAsLazyPagingItems antes de realizar aserciones. Para ello, usa composeTestRule.waitUntil o waitUntilExactlyOneExists, como se muestra en el ejemplo anterior.

Una vez que se cargan los datos, puedes realizar aserciones directamente en el árbol semántico de Compose con onNodeWithText para verificar que los elementos se rendericen en tu LazyColumn.

Recursos adicionales

Contenido de Views