Probar el puente en iOS
Al terminar: Probar el presentador de la paginación en la JVM y en el simulador de iOS.
El presentador de la lección anterior solo existe en iOS, así que sus pruebas también: viven en iosTest, la carpeta (el source set) de los tests que solo corren en el simulador. Al terminar esta lección tendrás dos tests del presentador en verde, los dobles de prueba de todo el módulo reunidos en un solo fichero, y habrás visto a propósito qué pasa cuando un test de Paging se queda callado.
Las rutas van desde la raíz del proyecto, en la vista Project del panel de Android Studio, la que muestra las carpetas tal como están en el disco (no la vista Android de Marcador).
Paso 1 · Los dobles, en un solo sitio
El problema:
- Tienes dos tests con su propio
FakePokeApiy su propioTestDispatchersprivados, y el test del presentador, que va a estar en el mismo paquete (feature.list) que el de Paging, necesitaría una copia más. - Ya viste en Testing del núcleo compartido que dos clases con el mismo nombre en un mismo paquete dan
Redeclaration:, aunque seanprivate. - Aquí además se descubre tarde:
commonTesteiosTestsolo se compilan juntos para el simulador, así que la JVM no avisa y el error sale al ejecutariosSimulatorArm64Test.
La solución es la que ya usaste en Testing del núcleo compartido para evitar el Redeclaration:: los dobles que comparten paquete, en un solo fichero. Son relleno: lo que importa es dónde viven, no escribirlos, así que Dobles.kt se copia con el botón.
- Clic derecho sobre la carpeta
shared→ New → File →src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt(crea también la carpetadobles). - Pulsa Copiar fichero en el bloque de abajo.
- En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propia línea
package.
Dentro hay dos clases, ambas internal: son visibles solo dentro del módulo, como la interfaz PokeApi que implementan (una clase pública no podría exponer un tipo interno en su firma, y el compilador lo rechazaría). Son FakePokeApi, un PokeApi de mentira que devuelve Pokémon pokemon-1, pokemon-2… y puede fallar a propósito (con la IOException que corregiste en Errores que se pueden contar), y TestDispatchers, que apunta los tres dispatchers al de prueba.
Ahora los dos tests de antes dejan de llevar su copia:
PokemonRepositoryTest.kt(enshared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/) se sustituye entero: es el mismo test de Del JSON al Pokémon, con la diferencia de que los Pokémon del doble se llamanpokemon-1,pokemon-2… en vez debulbasaur,ivysaur… Abre el fichero, selecciona todo, y repite los pasos 2 y 3 con este botón.
PokemonPagingSourceTest.kt(enshared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/list/): sus afirmaciones no cambian, porque el doble deDobles.kttambién llamapokemon-1,pokemon-2… a sus Pokémon. Solo borra las dos clasesprivateque hay encima declass PokemonPagingSourceTest(FakePokeApiyTestDispatchers, completas, con su comentario) y añade estas dos líneas con los demás imports. Los imports que se queden en gris (los de los DTO,PokeApi,CoroutineDispatcher,AppDispatchersy elIOException) los quita el menú Code → Optimize Imports; si no lo encuentras, bórralos a mano.
import com.sebastiannarvaez.pokedex.dobles.FakePokeApi // el doble, ahora de un solo sitio
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers // los tres dispatchers de pruebaCompruébalo: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test debe seguir en BUILD SUCCESSFUL, con los mismos tests de antes: 34 en la JVM, 33 en Android host y 33 en iOS con tus ejercicios (27 / 26 / 28 sin ellos). La JVM tiene uno más que Android por ModulesTest, el test de Koin, que solo corre en la JVM.
Paso 2 · El test del presentador
El archivo. PokemonListPresenterTest.kt, en iosTest. Prueba dos cosas: que la primera página llega al estado y que un fallo de red llega como un error que se puede reintentar.
Créalo. Clic derecho sobre shared → New → File → src/iosTest/kotlin/com/sebastiannarvaez/pokedex/feature/list/PokemonListPresenterTest.kt (el menú crea las carpetas: el source set iosTest ya existía, solo le faltaba carpeta).
El concepto. El dispatcher de prueba que se usa es UnconfinedTestDispatcher, el mismo del ejercicio de la lección anterior (TestCoroutineScheduler es el tipo del reloj virtual, el testScheduler que te da runTest; por eso la función presentador lo recibe): el presentador de Paging encadena varias corrutinas antes de publicar el primer estado, y con el dispatcher estándar habría que adelantar el reloj a mano en cada eslabón; con el «no confinado», cada paso corre en cuanto puede. Todavía es una API experimental de las corrutinas, por eso la clase lleva @OptIn (el permiso que Kotlin pide para usarla).
Como el estado puede traer ya la página cuando el test se suscribe, el test no cuenta emisiones: espera, con un while, al primer estado que tenga datos (o error). Y solo afirma lo que es estable: que empiece por el primero, que haya al menos una página y que no haya repetidos. Para lo último, el test mira los identificadores de los Pokémon con .map { it.id } y los pasa por .distinct(), que devuelve la lista sin valores repetidos: si el tamaño no cambia, no había ninguno repetido.
package com.sebastiannarvaez.pokedex.feature.list
import app.cash.turbine.test // `flujo.test { … }`
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.dobles.FakePokeApi
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
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 {
private fun presentador(total: Int, falla: Boolean, scheduler: TestCoroutineScheduler): PokemonListPresenter {
// Todos los hilos de la app apuntan al dispatcher de prueba, que manda el reloj virtual.
val d = TestDispatchers(UnconfinedTestDispatcher(scheduler))
val repo = PokemonRepository(FakePokeApi(total, falla), d)
return PokemonListPresenter(PokemonListViewModel(repo), d)
}
@Test
fun laPrimeraPaginaLlegaAlEstado() = runTest {
val p = presentador(total = 60, falla = false, scheduler = testScheduler)
p.state.test {
// El primer valor puede traer ya la página (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()
// Se afirma lo que es estable: que empiece por el primero, que llegue al
// menos una página y que no haya repetidos.
assertTrue(estado.items.size >= 20, "llego menos de una pagina: ${estado.items.size}")
assertEquals("pokemon-1", estado.items.first().name)
assertEquals(estado.items.size, estado.items.map { it.id }.distinct().size, "hay repetidos")
cancelAndIgnoreRemainingEvents() // basta con lo visto: se corta el resto
}
p.close() // se limpia el presentador al terminar
}
@Test
fun unFalloDeRedLlegaComoErrorQueSePuedeReintentar() = 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 error de carga inicial deja la lista vacía
// El `while` de arriba solo termina cuando `error` ya no es `null`, y
// Kotlin lo sabe: por eso aquí se lee `sePuedeReintentar` sin `?`.
assertTrue(estado.error.sePuedeReintentar, "el error deberia ofrecer reintento")
cancelAndIgnoreRemainingEvents()
}
p.close()
}
}Ejecútalo en el simulador (la primera vez compila el binario de tests de Kotlin/Native y tarda más):
./gradlew :shared:iosSimulatorArm64TestDebe terminar en BUILD SUCCESSFUL, con los dos tests del presentador en verde.
Provócalo: quita el mainContext
Un test verde no prueba que la línea clave haga falta. Quítala y mira qué pasa. En PokemonListPresenter.kt (en iosMain) borra el argumento del objeto anónimo, para que quede así:
private val presenter = object : PagingDataPresenter<Pokemon>() {Vuelve a ejecutar ./gradlew :shared:iosSimulatorArm64Test. Falla en los dos tests, y de una forma que desconcierta: la Terminal solo dice
app.cash.turbine.TurbineAssertionError at null:-1y el mensaje entero está en el informe (open shared/build/reports/tests/iosSimulatorArm64Test/index.html, como en expect/actual frente a interfaces):
app.cash.turbine.TurbineAssertionError: No value produced in 3sEl arreglo es devolver el argumento, el dispatcher que ya se inyecta. Vuelve a escribirlo:
private val presenter = object : PagingDataPresenter<Pokemon>(mainContext = dispatchers.main) {Ejecuta de nuevo: BUILD SUCCESSFUL. (Es esa inyección de dispatchers en el constructor, que parecía una comodidad, la que permite arreglarlo con una línea: en la app es el hilo principal y en el test es el de prueba.)
Punto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 34 / 33 / 35 tests (JVM / Android host / iOS) con tus ejercicios, y 27 / 26 / 28 sin ellos. Los ejercicios que hayas hecho suman tests (los de cimientos,climaylector), y la JVM tiene uno más que Android porqueModulesTest, el de Koin, solo corre en la JVM.PokemonListPresenterTestaporta sus 2 tests solo en iOS.PokemonListPresenterpasamainContext = dispatchers.mainaPagingDataPresenter(lo has vuelto a dejar así).PokemonRepositoryTestyPokemonPagingSourceTestya no llevan suFakePokeApini suTestDispatchersprivados: los dos viven endobles/Dobles.kt. (HeartbeatTestconserva el suyo: está solo en su paquete y no choca.)
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Al quitar
mainContextdel presentador, el test falla solo con «No value produced in 3s», sin ninguna excepción que lo explique. ¿Por qué?2.¿Por qué el test usa
UnconfinedTestDispatchery noStandardTestDispatcher?3.
PokemonListPresenterTestva a estar en el mismo paquete quePokemonPagingSourceTest. ¿Por qué se muevenFakePokeApiyTestDispatchersaDobles.kt?4.El test del presentador espera con un
whileal primer estado que tenga datos, en vez de pedir «el segundo valor». ¿Por qué?