Los tests del repositorio
Al terminar: Probar con tests que la copia caduca cuando toca y que el repositorio sirve lo guardado, incluido el test del presentador de iOS.
La lista ya lee de la base y la copia caduca a las doce horas, pero ninguna de las dos cosas tiene todavía un solo test, y el test del presentador de iOS ni siquiera compila. Esta lección lo arregla: tres tests de initialize(), tres del repositorio entero y el test del presentador de iOS reescrito.
Para saber que no rompes nada: ./gradlew :shared:jvmTest :shared:testAndroidHostTest da 59 / 37 tests (JVM / Android host) al empezar, sin contar los de la carpeta ejercicios. La de iOS no se puede dar: su test del presentador no compila hasta el Paso 3.
Paso 1 · Los tres tests de initialize()
Primero un retoque pequeño en PokemonRemoteMediatorTest.kt (Cmd+Shift+O para abrirlo). Su estado llevaba el tamaño de página escrito a mano, 20, para que el test del mediador no dependiera de la pantalla: entonces ese número vivía en el ViewModel. Ahora la constante vive en PokemonListRepository, en la capa de datos junto al mediador, así que puede usarla sin ese problema, y si algún día cambia el tamaño de página, el test cambia con él:
private val estado = PagingState<Int, PokemonEntity>(
pages = emptyList(),
anchorPosition = null,
config = PagingConfig(pageSize = PokemonListRepository.PAGE_SIZE),
leadingPlaceholderCount = 0,
)Después, dos import nuevos con los demás (InitializeAction y hours):
import androidx.paging.RemoteMediator.InitializeAction
import kotlin.time.Duration.Companion.hoursY los tres tests, dentro de la clase, después del último:
@Test
fun alAbrirSinCopiaSePideLaRed() = runTest {
assertEquals(InitializeAction.LAUNCH_INITIAL_REFRESH, mediador().initialize())
}
@Test
fun alAbrirConCopiaRecienteNoSePideLaRed() = runTest {
mediador().load(LoadType.REFRESH, estado)
reloj += 1.hours.inWholeMilliseconds
assertEquals(InitializeAction.SKIP_INITIAL_REFRESH, mediador().initialize())
}
@Test
fun alAbrirConCopiaCaducadaSeRefresca() = runTest {
mediador().load(LoadType.REFRESH, estado)
reloj += PokemonRemoteMediator.CADUCIDAD.inWholeMilliseconds + 1
assertEquals(InitializeAction.LAUNCH_INITIAL_REFRESH, mediador().initialize())
}Los tres cubren los tres casos del ejemplo del catálogo. El primero no guarda nada. Los otros dos guardan una copia con un load y luego mueven el reloj: una hora después sigue valiendo; pasadas las doce horas, ya no. El + 1 solo sirve para que el test no dependa de si la regla usa < o <=.
Paso 2 · El repositorio entero, como lo ve la pantalla
El concepto. Estos tests recorren el flujo de páginas con el Pager, el mediador y el PagingSource de Room de verdad; lo único falso es la red. Antes del código, una idea: con mediador, la carga tiene dos estados, no uno, juntos en un objeto, CombinedLoadStates.
sourcees el de la fuente, la base: ¿se leyó bien?mediatores el del mediador, la red: ¿se pudo llenar? Puede sernull(una lista sin mediador no tiene red que llenar), por eso el test escribeit.mediator?.refresh: «si hay mediador, su refresco».- Cada uno es un
LoadState, con tres casos:Loading(cargando),NotLoading(sin cargar ahora mismo y sin error) yError.
CombinedLoadStates ya lo viste en El puente de paginación para iOS; lo nuevo es separar el estado por fuente. La diferencia importa: sin red, la base lee bien (NotLoading) y falla la red (Error). La lección La lista sin red en Android construye el aviso de la pantalla sobre ella; aquí solo la compruebas.
Lo que usa mirar
Uno de los tests necesita un ayudante, mirar. Casi todo lo que usa lo has escrito ya:
Lo nuevo es filterNotNull(), collectLatest, collectFrom, loadStateFlow, snapshot() y lanzar en backgroundScope: se explican una a una en «mirar, línea a línea», justo después del código.
Ahora el fichero. Clic derecho sobre la carpeta shared → New → File:
src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/data/PokemonListRepositoryTest.ktpackage com.sebastiannarvaez.pokedex.data
import androidx.paging.CombinedLoadStates
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.PagingDataEvent
import androidx.paging.PagingDataPresenter
import androidx.paging.testing.asSnapshot
import com.sebastiannarvaez.pokedex.data.local.createDatabase
import com.sebastiannarvaez.pokedex.data.local.getDatabaseBuilder
import com.sebastiannarvaez.pokedex.dobles.FakePokeApi
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import com.sebastiannarvaez.pokedex.domain.Pokemon
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.io.IOException
/**
* El repositorio entero, como lo ve la pantalla: un flujo de paginas.
*
* `asSnapshot` recorre ese flujo como lo haria la interfaz, con el Pager, el
* mediador y el PagingSource de Room de verdad. Lo unico de mentira es la red.
*/
@OptIn(ExperimentalCoroutinesApi::class) // UnconfinedTestDispatcher es experimental
class PokemonListRepositoryTest {
private val db = createDatabase(getDatabaseBuilder())
private var reloj = 1_000_000L
@AfterTest
fun cerrar() = db.close()
private fun TestScope.repo(falla: Boolean = false) = PokemonListRepository(
red = PokemonRepository(FakePokeApi(total = 100, falla = falla), TestDispatchers(StandardTestDispatcher(testScheduler))),
db = db,
ahora = { reloj },
)
/**
* Pinta el flujo como lo haria una pantalla y espera a un estado.
*
* `asSnapshot` no sirve aqui: ante un error del mediador lanza la
* excepcion y no deja ver la lista que sigue en pantalla. Esto es lo mismo
* que hace el presentador de iOS: un `PagingDataPresenter` que recoge.
*/
private suspend fun TestScope.mirar(
flujo: Flow<PagingData<Pokemon>>,
hasta: (CombinedLoadStates) -> Boolean,
): Pair<List<Pokemon>, CombinedLoadStates> {
val hilo = UnconfinedTestDispatcher(testScheduler)
val presentador = object : PagingDataPresenter<Pokemon>(mainContext = hilo) {
override suspend fun presentPagingDataEvent(event: PagingDataEvent<Pokemon>) = Unit
}
backgroundScope.launch(hilo) { flujo.collectLatest { presentador.collectFrom(it) } }
val estados = presentador.loadStateFlow.filterNotNull().first(hasta)
return presentador.snapshot().items to estados
}
@Test
fun laPrimeraVezLaListaLlegaDeLaRedPasandoPorLaBase() = runTest {
val items = repo().pokemon().asSnapshot()
assertEquals("pokemon-1", items.first().name)
// Lo que se ve es lo que se guardo: la base tiene al menos esa pagina.
assertTrue(db.pokemonDao().count() >= items.size)
}
@Test
fun sinRedSeVeLoGuardadoYElFalloLlegaDelMediador() = runTest {
repo().pokemon().asSnapshot()
val guardados = db.pokemonDao().count()
// La copia ha caducado: al abrir se intentara refrescar, y fallara.
reloj += PokemonRemoteMediator.CADUCIDAD.inWholeMilliseconds + 1
val (items, estados) = mirar(repo(falla = true).pokemon()) { it.mediator?.refresh is LoadState.Error }
// La lista sigue ahi, entera: el fallo no vacio la base.
assertEquals("pokemon-1", items.first().name)
assertEquals(guardados, db.pokemonDao().count())
// Y el error es del mediador (la red), no de la fuente (la base), que
// leyo bien. Es la diferencia que la pantalla usa para avisar.
assertIs<LoadState.Error>(estados.mediator?.refresh)
assertIs<LoadState.NotLoading>(estados.source.refresh)
}
@Test
fun sinRedYSinNadaGuardadoFalla() = runTest {
// Lo que antes hacia el PagingSource de red: sin datos, el error llega.
// FakePokeApi con `falla = true` lanza una IOException, como una red caida.
assertFailsWith<IOException> { repo(falla = true).pokemon().asSnapshot() }
}
}mirar, línea a línea
mirar hace de pantalla: recoge el flujo de páginas, espera a que los estados de carga cumplan una condición y devuelve lo que se ve en ese momento.
private suspend fun TestScope.mirar(…):suspendporque tiene que esperar.TestScope.delante del nombre la convierte en una extensión deTestScope(una función que se añade desde fuera a un tipo que ya existe, como elhoursde los números): así puede usartestScheduler(el reloj del test) ybackgroundScope, igual querepo()y elmediador()de El RemoteMediator: la red llena la base.hasta: (CombinedLoadStates) -> Boolean: la condición a esperar, pasada como función. Quien llama la escribe entre llaves, como enmirar(…) { it.mediator?.refresh is LoadState.Error }: «espera a que el refresco del mediador esté en error».Pair<List<Pokemon>, CombinedLoadStates>: devuelve dos cosas, la lista que se ve y el estado de carga. Dentro se crea con… to estados, y en el test se abre conval (items, estados) = mirar(…).val hilo = UnconfinedTestDispatcher(testScheduler): el dispatcher no confinado de Tests con backgroundScope y Turbine, el mismo que usa el puente de iOS: ejecuta cada tarea en cuanto puede, al revés que elStandardTestDispatcherderepo(), que las deja en cola. Así el presentador reacciona al instante a cada página que llega.object : PagingDataPresenter<Pokemon>(mainContext = hilo) { … }: el presentador es una clase con un hueco por rellenar,presentPagingDataEvent: «qué hacer cada vez que cambia la lista». En iOS lo rellena código real que pinta. Aquí no hay pantalla, así que el objeto sin nombre lo rellena con= Unit, el «nada» de Kotlin: no devuelve ni hace nada.backgroundScope.launch(hilo) { flujo.collectLatest { presentador.collectFrom(it) } }: arranca en segundo plano la recogida del flujo. Cada elemento del flujo es unPagingData, una fotografía de la lista;collectLatestentrega cada una al presentador y, si llega otra nueva mientras tanto (por ejemplo porque la base cambió), abandona la anterior. Se lanza enbackgroundScope, que el test cancela solo al acabar.presentador.loadStateFlow.filterNotNull().first(hasta): los estados de carga llegan como un flujo (loadStateFlow) que empieza ennullhasta que hay algo que contar.filterNotNull()descarta losnully deja pasar los demás, yfirst(hasta)espera al primer estado que cumpla la condición y lo devuelve.presentador.snapshot().items to estados:snapshot()es la lista tal como está en ese momento, yitemssus elementos.
Y los otros dos ayudantes, en una frase cada uno: repo() construye el repositorio con una red de mentira (con falla = true, una red que siempre falla), y ahora = { reloj } le da al repositorio (y por él al mediador) la hora que tú controlas.
Cada test cuenta una cosa:
- La primera vez: con la base vacía, la lista aparece igual. Paso por la red, por el mediador y por la base, y llega a la pantalla desde la base.
- Sin red, con copia: se avanza el reloj más allá de las doce horas para que
initialize()decida refrescar, y se cambia la red por una que falla. La lista guardada sigue entera, y el error está enmediator, no ensource. - Sin red y sin nada guardado: el error llega. La red de mentira con
falla = truelanza unaIOException(la excepción de red de Errores que se pueden contar), y el test comprueba que le llega a quien recoge la lista. Es lo que hacía elPagingSourcede red antes; ahora lo hereda el repositorio.
Paso 3 · El test del presentador de iOS
El test de PokemonListPresenterTest dejó de compilar al final de la lección anterior: en presentador(…), el propio test construye PokemonListViewModel(…) con el repositorio antiguo, y ese constructor cambió. Se reescribe de arriba abajo, con tres cambios:
- Una base en memoria, también en iOS: el mismo constructor
Room.inMemoryDatabaseBuilderque ya usan los tests de la JVM (lo escribiste en La base en cada plataforma), sin nombre de fichero, así que no hace falta ninguna ruta. - El repositorio nuevo en lugar del antiguo, con la hora de mentira
reloj. - El hilo principal de prueba (
Dispatchers.setMain). El motivo está en la trampa de abajo.
Abre el fichero (Cmd+Shift+O) y sustituye todo su contenido por:
package com.sebastiannarvaez.pokedex.feature.list
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import app.cash.turbine.test
import com.sebastiannarvaez.pokedex.data.PokemonListRepository
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.data.local.PokedexDatabase
import com.sebastiannarvaez.pokedex.data.local.PokedexDatabaseConstructor
import com.sebastiannarvaez.pokedex.data.local.createDatabase
import com.sebastiannarvaez.pokedex.dobles.FakePokeApi
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.job
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Vive en `iosTest` porque el presentador solo existe en iOS: es el sustituto
* de lo que en Android hace `paging-compose`.
*/
@OptIn(ExperimentalCoroutinesApi::class) // UnconfinedTestDispatcher es experimental
class PokemonListPresenterTest {
// Una base en memoria, tambien en iOS: la lista ya no sale de la red sino
// de aqui. Sin nombre de fichero no hace falta ninguna ruta.
private val db: PokedexDatabase = createDatabase(
Room.inMemoryDatabaseBuilder<PokedexDatabase>(factory = PokedexDatabaseConstructor::initialize),
)
private var reloj = 1_000_000L
/** Lo que cada test enciende, para apagarlo entero al terminar. */
private val presentadores = mutableListOf<PokemonListPresenter>()
private val viewModels = mutableListOf<PokemonListViewModel>()
/**
* El hilo principal, de prueba.
*
* Las paginas se reparten desde `Dispatchers.Main`. Sin este hilo de
* prueba, el test se queda esperando para siempre (mira la trampa de la
* leccion) sin decir por que.
*/
@BeforeTest
fun instalarElHiloPrincipal() = Dispatchers.setMain(UnconfinedTestDispatcher())
@AfterTest
fun cerrar() {
db.close()
Dispatchers.resetMain()
}
/**
* Apaga lo que el test encendio y **espera** a que acabe.
*
* - `close()` cierra los presentadores.
* - `cancel()` cancela el trabajo del ViewModel, como al destruirlo.
* - `join()` espera a que ese trabajo haya terminado de verdad.
*
* Sin la espera, el test cierra la base mientras algo aun la usa y falla
* de vez en cuando.
*/
private suspend fun apagar() {
presentadores.forEach { it.close() }
viewModels.forEach { it.viewModelScope.cancel() }
viewModels.forEach { it.viewModelScope.coroutineContext.job.join() }
}
private fun presentador(total: Int, falla: Boolean, scheduler: TestCoroutineScheduler): PokemonListPresenter {
// Unconfined y no Standard: el presentador de Paging encadena varias
// corrutinas antes de publicar el primer estado, y con el dispatcher
// estandar hay que adelantar el reloj a mano en cada eslabon. Con el
// no confinado, cada paso corre en cuanto puede.
val d = TestDispatchers(UnconfinedTestDispatcher(scheduler))
val red = PokemonRepository(FakePokeApi(total, falla), d)
val lista = PokemonListRepository(red, db) { reloj }
val viewModel = PokemonListViewModel(lista).also { viewModels += it }
return PokemonListPresenter(viewModel, d).also { presentadores += it }
}
@Test
fun laPrimeraPaginaLlegaAlEstado() = runTest {
val p = presentador(total = 60, falla = false, scheduler = testScheduler)
p.state.test {
// El primer valor puede traer ya la pagina: con el dispatcher no
// confinado, Paging corre antes de que el test se suscriba. Por eso
// se espera al primer estado con datos en vez de contar emisiones.
var estado = awaitItem()
while (estado.items.isEmpty()) estado = awaitItem()
// No se afirma un numero exacto de elementos: con el dispatcher no
// confinado, Paging encadena las cargas que puede antes de que el
// test mire, y ese numero depende del planificador, no de la app.
// Lo que si es estable es que empiece por el primero y que no haya
// repetidos.
assertEquals("pokemon-1", estado.items.first().name)
assertEquals(estado.items.size, estado.items.map { it.id }.distinct().size, "hay repetidos")
cancelAndIgnoreRemainingEvents()
}
apagar()
}
@Test
fun sinRedNiNadaGuardadoLlegaUnErrorQueSePuedeReintentar() = runTest {
val p = presentador(total = 60, falla = true, scheduler = testScheduler)
p.state.test {
var estado = awaitItem()
while (estado.error == null) estado = awaitItem()
assertTrue(estado.items.isEmpty())
// El `while` de arriba solo termina cuando `error` ya no es `null`,
// y Kotlin lo sabe: por eso aqui se lee sin `!!` ni `?`.
assertTrue(estado.error.sePuedeReintentar, "el error deberia ofrecer reintento")
cancelAndIgnoreRemainingEvents()
}
apagar()
}
}Cinco cosas del fichero que conviene tener claras:
PokemonListRepository(red, db) { reloj }. La lambda{ reloj }es el tercer parámetro,ahora(la lambda al final, como enmirar): la hora de mentira, la misma que usamirar. Se lee cada vez que el repositorio pregunta qué hora es.PokedexDatabaseConstructor::initializeno es elinitialize()del mediador. Mismo nombre, otra cosa: aquí::initializees la referencia a la función que Room genera para construir la base (la de Lambdas con listas: filter, map y reglas: «la función misma, sin llamarla»). El del mediador decide si se refresca la copia.UnconfinedTestDispatcher()ensetMain, y noStandardTestDispatcher().setMainlo aprendiste en El ViewModel del detalle yFavoritesTestlo repite en Favoritos en el núcleo, pero allí el hilo principal de prueba era unStandardTestDispatcher, que deja las tareas en cola. Aquí el presentador encadena varias corrutinas antes de publicar un estado, y el no confinado las ejecuta según llegan, sin adelantar el reloj a mano..also { viewModels += it }:also, de Funciones de alcance, hace algo con el objeto y lo devuelve tal cual. Aquí apunta el ViewModel en la listaviewModels(+=añade un elemento a la lista) y sigue con él; lo mismo con el presentador. Asíapagar()sabe qué apagar al terminar.apagar()al final de cada test. Es como apagar la cinta de correr antes de salir y esperar a que pare, no solo apagar la luz: al acabar el test hay que apagar lo que encendió. Cerrar el presentador no detiene al ViewModel, que sigue escuchando a Room, y si el test cierra la base mientras tanto, falla de vez en cuando sin que la app tenga la culpa. Por esoapagar()cancela el trabajo del ViewModel y espera a que termine. Dos palabras nuevas: cada corrutina tiene unjob(su «ficha»: dice si sigue en marcha, y se puede cancelar) yjoin()es «espera aquí hasta que esejobhaya terminado».viewModelScope.coroutineContext.jobes la ficha de todo el trabajo del ViewModel.
PokemonListViewModel(lista) lo construye este test, dentro de presentador(…); el presentador recibe el ViewModel ya hecho. Por eso este test, y no el presentador, es lo que dejó de compilar.
Paso 4 · Ejecuta todo
En este orden:
./gradlew :shared:jvmTest
./gradlew :shared:testAndroidHostTest
./gradlew :shared:iosSimulatorArm64Test
./gradlew :androidApp:assembleDebugPunto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 65 tests en la JVM, 37 en Android host y 39 en iOS, sin contar los de la carpetaejercicios.- La JVM suma 6 a los 59 de la lección anterior: tres de
initializey tres dePokemonListRepositoryTest. Android host no cambia (37): los tests dejvmTestno corren allí. iOS pasa de «no compila» a 39: son los comunes y los de iOS, y los tres dePokemonPagingSourceTestque has borrado ya no cuentan (eran 42 al empezar el módulo). - Si hiciste ejercicios en módulos anteriores, tus cifras serán mayores.
./gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFUL.
Ver la lista funcionar en el móvil, con y sin conexión, es el trabajo de la lección siguiente, La lista sin red en Android.
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.En el test de la copia caducada, el reloj avanza
CADUCIDAD.inWholeMilliseconds + 1. ¿Para qué sirve ese «+ 1»?2.Sin red y con copia caducada, ¿qué estados debe encontrar
PokemonListRepositoryTest?3.¿Por qué el segundo test del repositorio usa
mirary noasSnapshot?4.En
mirar, ¿qué hacepresentador.loadStateFlow.filterNotNull().first(hasta)?5.Quitas
Dispatchers.setMaindel test del presentador de iOS. ¿Qué ocurre?6.En
sinRedYSinNadaGuardadoFalla, ¿por qué se espera unaIOExceptiony no una lista?7.¿Por qué
apagar()espera conjoin()antes de salir del test del presentador de iOS?