Saltar al contenido
K
La lista de PokémonPaso a paso40 min de lectura · 10 min de máquina Intermedio

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.

shared/src/
commonTest/kotlin/…/HeartbeatTest.ktse crea (paso 2): corre en las tres plataformas
commonTest/kotlin/…/data/network/KtorPokeApiTest.ktse crea (paso 1)
commonTest/kotlin/…/data/PokemonRepositoryTest.ktya lo tienes
jvmTest/kotlin/…/di/ModulesTest.ktsolo JVM: usa reflexión
commonMain/kotlin/…/ejercicios/clima/Clima.kttu ejercicio
gradle
libs.versions.tomlse modifica
shared
build.gradle.ktsse modifica
Dónde va cada fichero de tests: `commonTest` corre en las tres plataformas. Rutas desde la carpeta del proyecto, en la vista Project.

Las tres tareas, que puedes lanzar de una vez:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

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

ejemplo mínimo: un servidor de mentira (con comentarios)
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.html

Con 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]:

[versions]: línea nueva
turbine = "1.2.1"

3 · Las librerías, en [libraries]. Al final de la sección, antes de [plugins]:

[libraries]: dos líneas nuevas
# 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:

build.gradle.kts de shared: commonTest, líneas nuevas
            // 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:

  1. Turbine en jvmTest en vez de commonTest. Sync pasa, y los tests de la JVM también, pero al compilar los de Android host (testAndroidHostTest) y los de iOS (iosSimulatorArm64Test) salen Unresolved reference 'app'. en import app.cash.turbine.test y, debajo, Unresolved reference 'test' on receiver of type 'Flow<Int>'., tres Unresolved reference 'awaitItem'. y Unresolved 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 a commonTest.
  2. Un alias mal escrito. Unresolved reference 'turbine' (o 'mock') en build.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.kt
  • shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/ejercicios/clima/ServicioDeClimaTest.kt

En Clima.kt, en el paquete com.sebastiannarvaez.pokedex.ejercicios.clima:

  • un DTO ClimaDto con ciudad (texto) y grados (entero);
  • una clase ServicioDeClima que recibe el motor por su constructor, monta su propio cliente con JSON que ignora las claves desconocidas, y tiene una función resumen(ciudad). Esa función pide https://clima.example/actual con el parámetro ciudad y devuelve, por ejemplo, Cali: 28 °C.
  • Las dos clases, internal: así no aparecen en el Shared.h que ve Swift. Los tests de shared sí 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 exactamente Cali: 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 SUCCESSFUL

Si 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
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/clima/Clima.kt
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"
    }
}
shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/ejercicios/clima/ServicioDeClimaTest.kt
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.kt

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

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/KtorPokeApiTest.kt
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.

KtorPokeApiTest.kt: las dos pruebas siguientes, dentro de la clase
    @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.kt

El 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).

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/HeartbeatTest.kt
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:iosSimulatorArm64Test termina en BUILD SUCCESSFUL, con cuatro tests más en cada plataforma que antes de la lección (elDetalleIgnoraLosCamposQueNoUsamos, elListadoConstruyeLaUrlConLimiteYDesplazamiento, elIdentificadorSeSacaDeLaUrlDelListado y elLatidoCuentaDeUnoEnUno) y dos más del ejercicio clima. 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 y clima) suman tests, los de los que hayas hecho, y la JVM tiene uno más que Android porque ModulesTest solo corre en la JVM.
  • createHttpClient (de la lección anterior) sigue igual: los tests le pasan un MockEngine y el resto de la configuración es la de producción.
  • Turbine está en commonTest, no en jvmTest: el test del latido corre también en el simulador.
  • ModulesTest sigue siendo el único que vive en jvmTest.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué createHttpClient acepta un HttpClientEngine? en vez de que el test monte su propio HttpClient con MockEngine?

  2. 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 evita createHttpClient?

  3. 3.El JSON de prueba del detalle conserva base_experience y sprites, que la app no usa. ¿Por qué no se recortan?

  4. 4.El latido emite un número cada segundo, con delay(1.seconds). HeartbeatTest comprueba tres emisiones y termina en milisegundos. ¿Qué lo permite?

  5. 5.Dos ficheros de test del mismo paquete, cada uno con su private class TestDispatchers, dan Redeclaration: al compilar. ¿Qué se deduce?

  6. 6.Quitas ignoreUnknownKeys = true de createHttpClient y ejecutas KtorPokeApiTest. ¿Qué ocurre?

Faltan 6 de 6