Testing del núcleo compartido
Al terminar: Escribir tests en commonTest que corran en JVM y en el simulador y atrapen bugs reales.
Un test en commonTest no se ejecuta una vez: se ejecuta una vez por plataforma. El mismo fichero corre en la JVM, en el simulador de iOS y en el host de Android (los tests de Android que corren en tu ordenador, sin emulador; en la tarea se llaman testAndroidHostTest), con tres compiladores distintos. Eso los convierte en algo más que una red de seguridad: son la prueba de que el código común es realmente común.
Al terminar esta lección, la capa de red tendrá tests que corren sin tocar PokeAPI, y un test de flujo comprobará tres segundos de reloj en cero segundos de espera. Por el camino verás una regla de los tests compartidos: dos dobles con el mismo nombre en el mismo paquete no caben, aunque sean privados.
Dónde vive cada test
Las rutas de esta lección se dan en la vista Project del panel de la izquierda (las carpetas tal como están en el disco). Si el selector de arriba del panel dice Android, cámbialo a Project.
Las tres tareas, que puedes lanzar de una vez:
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64TestUn test de commonTest se compila y se ejecuta tres veces. Uno de jvmTest, una. Por eso ModulesTest vive aparte: verify() de Koin funciona por reflexión (mirar un tipo en tiempo de ejecución para ver qué pide su constructor), y Kotlin/Native —el compilador que convierte el código común en código nativo para iOS— no la tiene.
El motor de mentira
Un test de red malo monta su propio HttpClient con su propio JSON y comprueba que ese cliente inventado funciona. No prueba nada: la configuración que va a producción sigue sin mirar. Lo que se quiere es probar el cliente de verdad y cambiar solo lo que hay debajo: el motor.
Es lo que ya preparó createHttpClient(config, engine): con un motor, la función usa HttpClient(engine, configuracion) y todo lo demás (el JSON, los tiempos, la dirección base) es el cliente de verdad. Así se ve un motor de mentira en pequeño, con datos que no son de la app. No lo escribas: es solo para leer.
val servidor = MockEngine { peticion -> // este bloque se ejecuta con cada petición
println("Pidieron: ${peticion.url}") // `peticion` es lo que el cliente intentó pedir
respond( // y esto es lo que "contesta el servidor"
content = """{ "nombre": "Leche", "centimos": 120 }""", // el cuerpo
status = HttpStatusCode.OK, // el código de estado: 200
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), // avisa de que es JSON
)
}
val cliente = HttpClient(servidor) // el cliente usa este motor en vez de la red
val texto = cliente.get("https://tienda.example/producto") { // la petición GET de siempre
parameter("id", 7) // añade `?id=7` a la dirección
}.bodyAsText() // el cuerpo, como texto
println("Recibido: $texto")No sale a internet ni hace falta que tienda.example exista. Al ejecutarlo, imprime:
Pidieron: https://tienda.example/producto?id=7
Recibido: { "nombre": "Leche", "centimos": 120 }Hay además una razón práctica: en la JVM no hay motor de verdad (lo comprobaste en HTTP y Ktor: el cliente de PokeAPI). Si un test creara HttpClient { } sin pasarle motor, fallaría con:
java.lang.ExceptionInInitializerError
Caused by: java.lang.IllegalStateException: Failed to find HTTP client engine implementation: consider adding client engine dependency. See https://ktor.io/docs/http-client-engines.htmlCon MockEngine, el cliente funciona en las tres plataformas.
Instala ktor-client-mock y Turbine
Objetivo: que los tests de commonTest puedan simular un servidor y recoger un Flow evento a evento. Hacen falta ya: el ejercicio de después simula un servidor y el paso 2 prueba un flujo.
1 · Qué instalas. Dos librerías solo de pruebas, que van a commonTest.dependencies y por eso no llegan a la app. ktor-client-mock trae MockEngine: la pieza nueva. Turbine ya la conoces; aquí solo cambia dónde va.
2 · La versión, en [versions]. ktor-client-mock reutiliza la versión ktor (se publica con el resto de Ktor). Solo Turbine necesita una línea; en gradle/libs.versions.toml, al final de [versions], antes de [libraries]:
turbine = "1.2.1"3 · Las librerías, en [libraries]. Al final de la sección, antes de [plugins]:
# MockEngine: el motor de mentira. Misma versión que el resto de Ktor.
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
# test { … } para recoger un Flow evento a evento.
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }4 · Las dependencias, en commonTest. En shared/build.gradle.kts, dentro de commonTest.dependencies { … }, debajo de las que ya hay:
// MockEngine y respond { … }: simulan el servidor.
implementation(libs.ktor.client.mock)
// En commonTest, no en jvmTest: el test del latido corre también en iOS.
implementation(libs.turbine)5 · Sync Now, y comprueba. Pulsa Sync Now; debe terminar sin rojo. Dos errores típicos:
- Turbine en
jvmTesten vez decommonTest. Sync pasa, y los tests de la JVM también, pero al compilar los de Android host (testAndroidHostTest) y los de iOS (iosSimulatorArm64Test) salenUnresolved reference 'app'.enimport app.cash.turbine.testy, debajo,Unresolved reference 'test' on receiver of type 'Flow<Int>'., tresUnresolved reference 'awaitItem'.yUnresolved reference 'cancelAndIgnoreRemainingEvents'.. El test común se compila también para Android y para iOS, y allí Turbine no está. Con el comando de las tres tareas verás primero el fallo de Android. Muévela acommonTest. - Un alias mal escrito.
Unresolved reference 'turbine'(o'mock') enbuild.gradle.kts: compara con el catálogo, leyendo los guiones como puntos.
Para el resto, la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto.
Reto · Ejercicio: un servicio de clima con motor inyectado
Practica la forma que acabas de ver: una clase que recibe el motor desde fuera, para poder probarla en las tres plataformas sin red. Va en la carpeta ejercicios, en una subcarpeta nueva clima (créala con clic derecho sobre shared → New → File, como en expect/actual frente a interfaces):
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/clima/Clima.ktshared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/ejercicios/clima/ServicioDeClimaTest.kt
En Clima.kt, en el paquete com.sebastiannarvaez.pokedex.ejercicios.clima:
- un DTO
ClimaDtoconciudad(texto) ygrados(entero); - una clase
ServicioDeClimaque recibe el motor por su constructor, monta su propio cliente con JSON que ignora las claves desconocidas, y tiene una funciónresumen(ciudad). Esa función pidehttps://clima.example/actualcon el parámetrociudady devuelve, por ejemplo,Cali: 28 °C. - Las dos clases,
internal: así no aparecen en elShared.hque ve Swift. Los tests desharedsí las ven, porque están en el mismo módulo.
En el test, dos pruebas con un motor de mentira:
- si el servidor contesta
{ "ciudad": "Cali", "grados": 28, "humedad": 70 }, el resumen es exactamenteCali: 28 °C; - al pedir «Bogotá», la dirección pedida contiene
ciudad=Bogot: en una URL las letras con tilde se codifican (la «á» viaja como%C3%A1), así que solo se comprueba el principio.
Ejecuta las tres tareas de test.
Resultado esperado:
BUILD SUCCESSFULSi quieres ver los dos tests, abre el informe de la JVM con open shared/build/reports/tests/jvmTest/index.html y entra en ServicioDeClimaTest: salen 2.
Ver solución
package com.sebastiannarvaez.pokedex.ejercicios.clima
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// Lo que llega del servidor del tiempo (solo los dos campos que se usan).
@Serializable
internal data class ClimaDto(val ciudad: String, val grados: Int)
// Recibe el motor desde fuera: en la app sería el de cada plataforma; en el test, uno de mentira.
internal class ServicioDeClima(motor: HttpClientEngine) {
private val cliente = HttpClient(motor) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun resumen(ciudad: String): String {
val clima: ClimaDto = cliente.get("https://clima.example/actual") {
parameter("ciudad", ciudad)
}.body()
return "${clima.ciudad}: ${clima.grados} °C"
}
}package com.sebastiannarvaez.pokedex.ejercicios.clima
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ServicioDeClimaTest {
@Test
fun elResumenLeeElJsonYIgnoraLoQueSobra() = runTest {
val motor = MockEngine {
respond(
content = """{ "ciudad": "Cali", "grados": 28, "humedad": 70 }""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
assertEquals("Cali: 28 °C", ServicioDeClima(motor).resumen("Cali"))
}
@Test
fun laCiudadVaEnLaDireccion() = runTest {
var pedida = ""
val motor = MockEngine { peticion ->
pedida = peticion.url.toString()
respond(
content = """{ "ciudad": "Bogotá", "grados": 14 }""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
ServicioDeClima(motor).resumen("Bogotá")
assertTrue(pedida.contains("ciudad=Bogot"), "url inesperada: $pedida")
}
}Es la misma forma que createHttpClient: el motor entra por fuera, y por eso el test corre también en la JVM, que no tiene motor propio.
Paso 1 · El JSON del test es real, recortado a propósito
El archivo. KtorPokeApiTest.kt prueba la PokeApi de verdad (la de Ktor, con toda su configuración) contra un servidor de mentira. Va en commonTest, en el paquete data.network.
Créalo. Clic derecho sobre la carpeta shared → New → File, con la ruta desde esa carpeta:
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/KtorPokeApiTest.ktEl concepto. Las respuestas falsas no son un invento: son recortes de las reales. El texto va entre tres comillas ("""…"""), y trimIndent() quita la sangría común. Los nombres en mayúsculas (DETALLE_JSON, PAGINA_JSON) son la costumbre para una constante: un valor que no cambia. Escribe el fichero con la primera prueba. Hay dos recortes arriba del todo, uno por tipo de respuesta; la segunda y la tercera prueba usarán el del listado.
package com.sebastiannarvaez.pokedex.data.network
import com.sebastiannarvaez.pokedex.core.AppConfig
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
// Una configuración de mentira con la misma dirección que la de verdad.
// `private`: solo la ve este fichero.
private class FakeConfig : AppConfig {
override val pokeApiBaseUrl = "https://pokeapi.co/api/v2/"
}
// Un recorte de una respuesta real de PokeAPI (la entera ocupa 272 KB).
// Conserva a propósito `base_experience` y `sprites`, dos campos que la app no
// declara: si alguien quita `ignoreUnknownKeys`, el test se pone rojo.
// Dos cambios: la URL de la imagen es inventada y los tipos están en orden
// inverso al real (en la respuesta de verdad, `grass` va primero). Así el test
// comprueba que el cliente no los reordena.
private val DETALLE_JSON = """
{
"id": 1,
"name": "bulbasaur",
"base_experience": 64,
"height": 7,
"weight": 69,
"sprites": { "front_default": "https://example.invalid/1.png" },
"types": [
{ "slot": 2, "type": { "name": "poison", "url": "https://pokeapi.co/api/v2/type/4/" } },
{ "slot": 1, "type": { "name": "grass", "url": "https://pokeapi.co/api/v2/type/12/" } }
]
}
""".trimIndent()
// Un recorte del listado, con sus campos `next` y `previous` como los manda la API.
private val PAGINA_JSON = """
{
"count": 1351,
"next": "https://pokeapi.co/api/v2/pokemon?offset=2&limit=2",
"previous": null,
"results": [
{ "name": "bulbasaur", "url": "https://pokeapi.co/api/v2/pokemon/1/" },
{ "name": "ivysaur", "url": "https://pokeapi.co/api/v2/pokemon/2/" }
]
}
""".trimIndent()
class KtorPokeApiTest {
// Monta una `PokeApi` real (la de Ktor) cuyo servidor es un MockEngine que
// siempre contesta `respuesta`. `capturar` recibe la dirección que se pidió.
private fun api(respuesta: String, capturar: (String) -> Unit = {}): PokeApi {
// El bloque se ejecuta en cada petición; `peticion` es lo que se pidió.
val engine = MockEngine { peticion ->
capturar(peticion.url.toString())
// Responde un 200 con el JSON, avisando de que es JSON.
respond(
content = respuesta,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
// El MockEngine sustituye al motor de la plataforma y nada más: la
// configuración que se prueba es exactamente la de producción.
return KtorPokeApi(createHttpClient(FakeConfig(), engine = engine))
}
@Test
fun elDetalleIgnoraLosCamposQueNoUsamos() = runTest {
val detalle = api(DETALLE_JSON).detail(1)
assertEquals(1, detalle.id)
assertEquals("bulbasaur", detalle.name)
assertEquals(7, detalle.height)
// Llegan en el orden del JSON (poison, grass): ordenarlos es cosa del repositorio.
assertEquals(listOf("poison", "grass"), detalle.types.map { it.type.name })
}
}Esos dos campos de más (base_experience y sprites) son el test. Si mañana alguien borra ignoreUnknownKeys = true por limpieza, esto se pone rojo en su máquina, en vez de descubrirse en producción. Por eso los datos de prueba se recortan por tamaño, nunca por comodidad: los campos que sobran son parte de lo que se está probando.
Dos pruebas más. Dentro de la clase, debajo de la anterior, añade estas (el import de assertTrue aparecía en gris hasta ahora: ya tiene quien lo use). El capturar de la función api(…) (la que monta la PokeApi de prueba) no es decorativo: guarda la dirección que se pidió, y la dirección es la parte del cliente que más fácil se rompe y la que ningún compilador vigila.
@Test
fun elListadoConstruyeLaUrlConLimiteYDesplazamiento() = runTest {
var pedida = ""
val pagina = api(PAGINA_JSON, capturar = { pedida = it }).page(limit = 2, offset = 4)
assertTrue(pedida.contains("limit=2"), "url inesperada: $pedida")
assertTrue(pedida.contains("offset=4"), "url inesperada: $pedida")
assertTrue(pedida.startsWith("https://pokeapi.co/api/v2/pokemon"), "url inesperada: $pedida")
assertEquals(1351, pagina.count)
}
@Test
fun elIdentificadorSeSacaDeLaUrlDelListado() = runTest {
val pagina = api(PAGINA_JSON).page(limit = 2, offset = 0)
assertEquals(listOf(1, 2), pagina.results.map { it.id })
}Ejecuta los tests de las tres plataformas (el comando del principio): BUILD SUCCESSFUL. Para ver las pruebas trabajar, quita un momento ignoreUnknownKeys = true del cliente y repite: fallan los tres tests, porque también el listado trae un campo que la clase no declara (previous). El del detalle dice:
io.ktor.serialization.JsonConvertException: Illegal input: Unexpected JSON token at offset 39: Encountered an unknown key 'base_experience' at path: $Es la excepción de Ktor, que envuelve a la de kotlinx.serialization (JsonDecodingException, en el Caused by: de debajo): el cliente la lanza al fallar la lectura del JSON. Ponlo otra vez.
Paso 2 · Turbine y el tiempo virtual
El archivo. HeartbeatTest.kt prueba el latido de Pokedex, el flujo que no termina nunca y que creaste en Un Flow hasta Swift. Clic derecho sobre shared → New → File:
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/HeartbeatTest.ktEl concepto. Pokedex es la clase que creaste en esa lección, y heartbeat() emite un número (0, 1, 2…) cada segundo con delay(1.seconds). Lo que ya sabes de runTest y Turbine sirve igual. Lo nuevo es un doble: Pokedex pide un AppDispatchers, y el del test apunta los tres dispatchers (io, default, main) al de prueba, para que runTest mande sobre todo lo que lance el código. Sin eso, el flowOn(dispatchers.default) del latido (el operador que hace que el flujo corra en otro dispatcher) correría en un hilo de verdad, con su reloj de verdad: con el Pokedex() normal el mismo test tarda unos 2 segundos (2,09 s medidos) en lugar de unas centésimas (0,065 s).
package com.sebastiannarvaez.pokedex
import app.cash.turbine.test
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
// Un AppDispatchers de mentira: los tres apuntan al dispatcher de prueba, para
// que `runTest` mande sobre todo lo que lance el código.
private class TestDispatchers(private val d: CoroutineDispatcher) : AppDispatchers {
override val io = d
override val default = d
override val main = d
}
class HeartbeatTest {
@Test
fun elLatidoCuentaDeUnoEnUno() = runTest {
// `testScheduler` es el reloj virtual de `runTest`: el dispatcher de
// prueba lo comparte, y así el `delay` del latido salta en vez de esperar.
val pokedex = Pokedex(TestDispatchers(StandardTestDispatcher(testScheduler)))
// `test` es de Turbine: recoge el flujo y deja pedir sus emisiones de una en una.
// `timeout` es lo máximo que Turbine espera cada emisión, en tiempo real
// (no virtual): si el latido no emitiera, el test fallaría a los 5 segundos.
pokedex.heartbeat().test(timeout = 5.seconds) {
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
// El latido no termina nunca: se cancela y se ignoran las que queden.
cancelAndIgnoreRemainingEvents()
}
}
}Tres segundos de reloj, cero segundos de espera. Compruébalo: ejecuta ./gradlew :shared:jvmTest y abre el informe de la JVM con open shared/build/reports/tests/jvmTest/index.html. Entra en la clase HeartbeatTest: junto a elLatidoCuentaDeUnoEnUno aparece su duración, de milisegundos. Con un reloj real tardaría tres segundos y sería inestable en un servidor cargado.
Punto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFUL, con cuatro tests más en cada plataforma que antes de la lección (elDetalleIgnoraLosCamposQueNoUsamos,elListadoConstruyeLaUrlConLimiteYDesplazamiento,elIdentificadorSeSacaDeLaUrlDelListadoyelLatidoCuentaDeUnoEnUno) y dos más del ejercicioclima. En total, con tus ejercicios: 22 en la JVM, 21 en Android y 21 en iOS; sin ellos: 16, 15 y 15. Los «ejercicios» (los de cimientos yclima) suman tests, los de los que hayas hecho, y la JVM tiene uno más que Android porqueModulesTestsolo corre en la JVM.createHttpClient(de la lección anterior) sigue igual: los tests le pasan unMockEnginey el resto de la configuración es la de producción.- Turbine está en
commonTest, no enjvmTest: el test del latido corre también en el simulador. ModulesTestsigue siendo el único que vive enjvmTest.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Por qué
createHttpClientacepta unHttpClientEngine?en vez de que el test monte su propioHttpClientconMockEngine?2.Un test de la JVM crea
HttpClient { }sin pasarle motor y falla con «Failed to find HTTP client engine implementation». ¿Qué lo explica y cómo lo evitacreateHttpClient?3.El JSON de prueba del detalle conserva
base_experienceysprites, que la app no usa. ¿Por qué no se recortan?4.El latido emite un número cada segundo, con
delay(1.seconds).HeartbeatTestcomprueba tres emisiones y termina en milisegundos. ¿Qué lo permite?5.Dos ficheros de test del mismo paquete, cada uno con su
private class TestDispatchers, danRedeclaration:al compilar. ¿Qué se deduce?6.Quitas
ignoreUnknownKeys = truedecreateHttpClienty ejecutasKtorPokeApiTest. ¿Qué ocurre?