Probar la paginación
Al terminar: Comprobar con tests que la lista pide páginas cuando toca y para cuando se acaba.
La lista ya sabe pedir páginas, pero nadie lo ha comprobado. Al terminar esta lección tendrás tres tests que lo prueban sin red y sin pantalla: que la lista pide más páginas cuando toca, que se para cuando se acaba y que un fallo de red llega como un error con el que Paging sabe trabajar.
Por el camino hay una trampa que se lleva por delante a casi todo el mundo la primera vez: el test más obvio da un número que no cuadra, y la razón dice mucho de cómo funciona Paging.
Las rutas son las de la vista Project, la que enseña las carpetas tal como están en el disco. Si el panel de la izquierda todavía dice «Android», cámbialo con el selector de arriba del panel.
Paso 1 · Cómo se prueba un carrusel sin pantalla
El concepto. Una pantalla recorre la lista y, al acercarse al final de lo cargado, Paging pide más. Para probarlo sin pantalla, la librería de pruebas trae asSnapshot { … }: ejecuta el flujo de páginas como lo haría la pantalla y devuelve la lista que habría a la vista. Dentro del bloque, scrollTo(index = …) «mira» hasta ese elemento (los índices empiezan en 0), igual que si el usuario hubiera bajado hasta ahí. Lo viste en el ejemplo de los libros de la lección anterior (allí solo lo leíste; aquí lo vas a escribir).
El archivo. PokemonPagingSourceTest.kt prueba la fuente de páginas con un PokeAPI de mentira. Va en commonTest, en el paquete feature.list. Clic derecho sobre la carpeta shared → New → File, con la ruta desde la carpeta del módulo:
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/list/PokemonPagingSourceTest.ktEmpieza por el esqueleto: los dos dobles (el PokeAPI falso y los dispatchers de prueba, con el mismo patrón que PokemonRepositoryTest de Del JSON al Pokémon: DTO y repositorio), un ayudante que monta la fuente y la clase vacía. El ayudante recibe scheduler, el reloj virtual de runTest (el testScheduler de Tests de corrutinas: tiempo virtual), para que los dobles obedezcan al mismo reloj que el test:
package com.sebastiannarvaez.pokedex.feature.list
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.testing.asSnapshot
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.data.network.PokeApi
import com.sebastiannarvaez.pokedex.data.network.dto.NamedRefDto
import com.sebastiannarvaez.pokedex.data.network.dto.PokemonDetailDto
import com.sebastiannarvaez.pokedex.data.network.dto.PokemonPageDto
import com.sebastiannarvaez.pokedex.data.network.dto.PokemonRefDto
import com.sebastiannarvaez.pokedex.data.network.dto.TypeSlotDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
// Un PokeAPI de mentira con `total` Pokémon llamados «pokemon-1», «pokemon-2»…
// Si `falla` es true, el listado lanza un error, como una red caída.
private class FakePokeApi(private val total: Int, private val falla: Boolean = false) : PokeApi {
override suspend fun page(limit: Int, offset: Int): PokemonPageDto {
// `error("…")` es una función de Kotlin que lanza una excepción con ese
// mensaje: aquí hace de «red caída».
if (falla) error("sin red")
// Los números de esta página: desde el siguiente al `offset` hasta el último (sin pasar del total).
val desde = offset + 1
val hasta = minOf(offset + limit, total)
return PokemonPageDto(
count = total,
results = (desde..hasta).map {
PokemonRefDto(name = "pokemon-$it", url = "https://pokeapi.co/api/v2/pokemon/$it/")
},
)
}
// Todos los detalles son del tipo normal: aquí el tipo no se prueba.
override suspend fun detail(id: Int) = PokemonDetailDto(
id = id,
name = "pokemon-$id",
types = listOf(TypeSlotDto(slot = 1, type = NamedRefDto("normal"))),
)
}
// Los tres dispatchers de la app apuntan al de prueba, que obedece al reloj virtual.
private class TestDispatchers(private val d: CoroutineDispatcher) : AppDispatchers {
override val io = d
override val default = d
override val main = d
}
class PokemonPagingSourceTest {
// Una fuente de páginas real (la de la app) sobre el repositorio con el doble.
private fun fuente(total: Int, falla: Boolean = false, scheduler: kotlinx.coroutines.test.TestCoroutineScheduler) =
PokemonPagingSource(PokemonRepository(FakePokeApi(total, falla), TestDispatchers(StandardTestDispatcher(scheduler))))
}Paso 2 · El primer intento, y el número que no cuadraba
Prueba primero con la configuración más corta: páginas de veinte, llegar al elemento 39 y esperar cuarenta elementos. Añade este test dentro de la clase:
@Test
fun elDesplazamientoAvanzaPaginaAPagina() = runTest {
// Sin ajustes propios: los valores por defecto de Paging.
val pager = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { fuente(total = 100, scheduler = testScheduler) },
)
val items = pager.flow.asSnapshot { scrollTo(index = 39) }
assertEquals(40, items.size)
}Ejecuta ./gradlew :shared:jvmTest. Falla. La Terminal solo dice java.lang.AssertionError at PokemonPagingSourceTest.kt:63 (el número de línea puede variar unas pocas según lo que hayas copiado); el mensaje entero está en el informe del test (open shared/build/reports/tests/jvmTest/index.html, como en expect/actual frente a interfaces; pulsa la clase PokemonPagingSourceTest):
java.lang.AssertionError: expected:<40> but was:<60>Dos ajustes de PagingConfig explican lo que acabas de ver, y la documentación los define así:
Paso 3 · El test bueno
Sustituye ese test por el bueno (con el PagingConfig del ViewModel):
@Test
fun elDesplazamientoAvanzaPaginaAPagina() = runTest {
// La misma configuración que usa el ViewModel. Si aquí se pusiera otra, el
// test estaría probando una app que no existe.
val pager = Pager(
config = PagingConfig(
pageSize = PokemonListViewModel.PAGE_SIZE,
prefetchDistance = 5,
enablePlaceholders = false,
),
pagingSourceFactory = { fuente(total = 100, scheduler = testScheduler) },
)
// `asSnapshot` recorre el flujo como lo haría la pantalla; dentro del bloque
// se «mira» hasta el elemento 59, el último de la carga inicial (que son 60).
val items = pager.flow.asSnapshot { scrollTo(index = 59) }
assertEquals("pokemon-1", items.first().name)
// Al acercarse al final de lo cargado, Paging ha pedido otra página.
assertTrue(items.size > 60, "no se pidio otra pagina: hay ${items.size}")
// Y ningún Pokémon está repetido.
assertEquals(items.size, items.map { it.id }.distinct().size, "hay elementos repetidos")
// Refrescar a mitad de la lista vuelve a empezar desde el primero, y la
// lista que queda es continua: del 1 al último, sin huecos ni repetidos.
val tras = pager.flow.asSnapshot {
scrollTo(index = 59)
refresh()
}
assertEquals((1..tras.size).toList(), tras.map { it.id }, "tras refrescar faltan o sobran Pokémon")
}Cuatro afirmaciones y ninguna es un recuento exacto: que empieza por el primero, que llega más allá de la carga inicial (hay más de sesenta, así que Paging pidió otra página al acercarte al final) que no hay repetidos y que, si refrescas con el dedo a mitad de la lista, ésta vuelve a empezar por el primero y queda completa. refresh() es la orden de «tirar para refrescar» dentro de asSnapshot. Eso es cierto siempre. Hoy salen 80, pero cuántas páginas alcanza a pedir Paging antes de la foto es un detalle suyo, que puede cambiar con una versión de la librería; «más de 60», no.
Paso 4 · Los otros dos tests
También dentro de la clase. Van al PagingSource directamente, sin Pager, porque comprueban decisiones suyas: que nextKey se pone a null cuando la última página trae cinco de veinte, y que un fallo del repositorio sale como LoadResult.Error en vez de propagarse como excepción.
@Test
fun laListaSeAcabaCuandoLaPaginaVieneCorta() = runTest {
val fuente = fuente(total = 25, scheduler = testScheduler)
// Se piden las páginas a mano: la primera (`Refresh`, sin clave) y la
// siguiente (`Append`, desde el 20). Con argumentos con nombre: la clave,
// cuántos elementos pedir y si hay huecos reservados.
val primera = fuente.load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false))
val segunda = fuente.load(PagingSource.LoadParams.Append(key = 20, loadSize = 20, placeholdersEnabled = false))
// `x as Tipo` dice «trata `x` como un `Tipo`». Si `primera` no fuera una `Page`
// (por ejemplo, un `Error`), el test se rompería aquí con una excepción de
// conversión, y eso también es un fallo del test.
assertEquals(20, (primera as PagingSource.LoadResult.Page).data.size)
val ultima = segunda as PagingSource.LoadResult.Page
assertEquals(5, ultima.data.size)
// `nextKey` a `null` es lo que apaga la petición de la página siguiente.
assertEquals(null, ultima.nextKey)
}
@Test
fun unFalloDeRedLlegaComoErrorYNoComoExcepcion() = runTest {
val fuente = fuente(total = 60, falla = true, scheduler = testScheduler)
val resultado = fuente.load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false))
// La pantalla necesita un estado de error con botón de reintentar, no una
// excepción que tumbe la corrutina.
assertTrue(resultado is PagingSource.LoadResult.Error)
}Ejecuta los tests de las tres plataformas: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test. Debe acabar en BUILD SUCCESSFUL.
Punto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFUL, con 25 / 24 / 24 tests (JVM / Android host / iOS) con tus ejercicios, o 19 / 18 / 18 sin ellos: a los de antes se suman los 3 dePokemonPagingSourceTesten cada destino. Los «ejercicios» de las lecciones suman tests (los de los que hayas hecho), y la JVM tiene uno más que Android porqueModulesTestsolo corre en la JVM../gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFUL(la app no cambia en esta lección).- El informe de la JVM (
shared/build/reports/tests/jvmTest/index.html) listaPokemonPagingSourceTestcon 3 tests y 0 fallos. - El
PagingConfigdel test y el del ViewModel son el mismo.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Con
PagingConfig(pageSize = 20)y sin tocar nada más, unscrollTo(39)devolvió 60 elementos y no 40. ¿Por qué?2.El test afirma
items.size > 60y que no hay repetidos, en vez de un número exacto. ¿Por qué es mejor?3.El ViewModel pone
prefetchDistance = 5en vez de dejar el valor por defecto. ¿Qué pasaría con el valor por defecto?4.Dos de los tres tests llaman a
fuente.load(…)a mano en vez de usar unPagerconasSnapshot. ¿Por qué?