wandres.dev
TESTING · kotlin.test y corrutinas

kotlin.test: una fachada común sobre cuatro runners

La biblioteca kotlin.test no es un framework de pruebas: es una fachada deliberadamente minúscula de anotaciones y aserciones que el compilador redirige, en cada objetivo, hacia el runner nativo de esa plataforma. Esta lección disecciona el mecanismo de expect y actual con alias de tipo que hace posible esa redirección, explica por qué en la JVM la elección entre JUnit 4, JUnit 5 y TestNG la decide Gradle y no tu código, cómo Kotlin/Native genera un runner sin reflexión alguna, y qué disciplina hay que imponerse para que un mismo fichero de commonTest compile y pase en los cuatro backends sin una sola bifurcación.

⏱ 22 min

Casi todo el mundo llega a kotlin.test esperando encontrar un framework de pruebas y se marcha decepcionado: no hay ejecución en paralelo, no hay parámetros, no hay ciclo de vida configurable, no hay extensiones. Esa pobreza es el diseño. kotlin.test es una capa de compatibilidad cuyo único cometido es ofrecer un vocabulario común —seis anotaciones y dos docenas de aserciones— que cada plataforma pueda traducir a su propia infraestructura sin coste en tiempo de ejecución. Entender que las anotaciones no son anotaciones sino alias de tipo declarados como actual cambia por completo la forma de leer los errores que aparecen cuando un test compila en la JVM y no en Native.

🎯 Al terminar esta lección sabrás
  • Explicar el mecanismo de expect y actual con alias de tipo que permite a kotlin.test delegar en el runner de cada objetivo.
  • Describir cómo se resuelve la variante de la biblioteca en la JVM y por qué depende de la configuración del task de Gradle.
  • Enumerar las capacidades ausentes de la API común y justificar cada ausencia desde la plataforma que la impide.
  • Escribir un fichero de commonTest que compile y pase en JVM, JS, Native y Wasm sin bifurcaciones.

Una fachada de anotaciones, no un framework

El artefacto que declaras como kotlin("test") expone en el conjunto de fuentes común un puñado de declaraciones marcadas con expect: las anotaciones Test, BeforeTest, AfterTest, Ignore y las funciones de aserción. Ninguna de ellas contiene lógica. La anotación común se declara así, y la variante de la JVM la resuelve con un alias hacia la anotación del framework real.

// En el conjunto común de la biblioteca
public expect annotation class Test()

// En la variante para JUnit 4
public actual typealias Test = org.junit.Test

// En la variante para JUnit 5
public actual typealias Test = org.junit.jupiter.api.Test

La consecuencia es más profunda de lo que parece. Al ser un alias, la anotación que acaba en el bytecode es literalmente la del framework anfitrión: JUnit no ve una capa de adaptación, ve sus propias anotaciones, y por tanto todo el ecosistema de informes, integración con el IDE y filtrado por etiquetas funciona sin que nadie haya escrito un puente. No hay indirección, no hay reflexión adicional, no hay penalización.

Las aserciones siguen la misma filosofía de mínimo común denominador, pero con dos refinamientos que conviene conocer porque ahorran código. assertNotNull devuelve el valor no nulo, de modo que sirve simultáneamente de comprobación y de estrechamiento de tipo; assertIs está marcada como reified y también devuelve el valor ya convertido.

import kotlin.test.*

class PedidoTest {
    @Test
    fun aceptaUnPedidoValido() {
        val resultado = procesar(Pedido(unidades = 3))
        val exito = assertIs<Resultado.Exito>(resultado)  // devuelve el tipo estrecho
        assertEquals(3, exito.confirmadas)
    }

    @Test
    fun rechazaCantidadesNegativas() {
        assertFailsWith<IllegalArgumentException> { Pedido(unidades = -1) }
    }
}
ℹ️
Lo que no vas a encontrar

No hay tests parametrizados, ni suites, ni orden declarado, ni reglas, ni inyección de dependencias, ni asunciones. Cada una de esas piezas existe en al menos un runner y falta en otro, y la regla de la fachada es no exponer nada que no puedan cumplir los cuatro.

Cómo se resuelve el runner en cada objetivo

En la JVM la biblioteca se publica en varias variantes —test-junit, test-junit5, test-testng— y desde hace varias versiones no necesitas nombrarlas: al declarar kotlin("test") a secas, el plugin de Gradle inspecciona el framework configurado en el task de pruebas y selecciona la variante correspondiente mediante atributos de resolución de dependencias. Eso significa que la línea que decide qué anotación acaba en tu bytecode no está en tu código de test sino en la configuración del build.

kotlin {
    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

// Esta línea es la que elige JUnit 5 en lugar de JUnit 4
tasks.withType<Test>().configureEach { useJUnitPlatform() }

Fuera de la JVM el panorama es distinto y más interesante. En Kotlin/Native no hay reflexión ni carga dinámica de clases, así que descubrir tests en tiempo de ejecución es imposible: el compilador recorre el árbol de declaraciones durante la compilación, recoge todo lo anotado y genera un ejecutable con un main sintético que las invoca en un orden fijo. En JS y en Wasm la biblioteca se adapta al runner del host —Mocha, Jasmine o Karma según la configuración— traduciendo cada clase de test a bloques del estilo de describe e it.

flowchart TD
A[commonTest escrito una sola vez] --> B[Compilacion por objetivo]
B -->|JVM| C[Alias de tipo hacia JUnit o TestNG]
B -->|JS| D[Adaptador hacia Mocha o Jasmine]
B -->|Native| E[Runner generado en el binario sin reflexion]
B -->|Wasm| F[Runner sobre el host de JavaScript]
C --> G[Mismo fuente y mismas aserciones]
D --> G
E --> G
F --> G
🧪

Nombres de test

En la JVM puedes usar identificadores entre acentos graves con espacios; en Native y JS el nombre acaba en una cadena y también funciona, pero los caracteres exóticos complican el filtrado desde la línea de órdenes.

⏱️

Tiempo real

Thread.sleep no existe fuera de la JVM y bloquear el hilo en JS congela el bucle de eventos. Cualquier espera en un test común debe expresarse con delay dentro de una corrutina.

📁

Recursos y ficheros

No hay sistema de ficheros común. Los datos de prueba se incrustan como constantes o se generan, nunca se leen de disco desde commonTest.

🔀

Concurrencia

Los runners de JVM pueden paralelizar clases; JS es monohilo. Un test que dependa de ejecutarse en aislamiento o en un orden concreto solo es correcto por accidente.

La disciplina de escribir para cuatro backends

La regla operativa es sencilla de enunciar y difícil de sostener: todo lo que no sea kotlin.test, la biblioteca estándar y kotlinx-coroutines-test pertenece a un conjunto de fuentes específico de plataforma. Si necesitas una comprobación que solo tiene sentido en la JVM —comparar bytecode, verificar una anotación por reflexión, medir un tamaño de objeto— vive en jvmTest, no en commonTest con un expect improvisado.

El segundo hábito es tratar el estado global como veneno. Un object con un contador, un companion object con una caché o una variable de nivel superior mutable sobreviven entre tests dentro del mismo proceso, y como el orden de ejecución no está garantizado ni es el mismo en los cuatro runners, el fallo aparecerá en una plataforma y no en otra. La misma lógica se aplica a @BeforeTest: garantiza que se ejecuta antes de cada test, no que reconstruya lo que otro test rompió.

// commonTest: correcto en los cuatro objetivos
class TarifaTest {
    private lateinit var tarifa: Tarifa

    @BeforeTest
    fun preparar() {
        tarifa = Tarifa(base = 100, recargo = 20)   // estado nuevo por test
    }

    @Test
    fun aplicaElRecargo() = assertEquals(120, tarifa.para(Cliente.Estandar))

    @Test
    @Ignore   // se salta en los cuatro runners, con motivo documentado aparte
    fun tarifaNocturnaPendienteDeEspecificacion() = fail("sin especificar")
}
💡
Ejecuta el objetivo más restrictivo primero

Configura la tarea de Native o la de Wasm para que corra en cada integración continua, no solo la de la JVM. Un test que solo se comprueba en la JVM acabará usando, tarde o temprano, una capacidad que no existe en el resto, y el descubrimiento llegará el día que menos convenga.

La pobreza de kotlin.test no es una limitación pendiente de resolver: es el único diseño que puede sostener la promesa de que un test es una afirmación sobre el lenguaje y no sobre la plataforma

Cuesta aceptar que una biblioteca oficial de pruebas no ofrezca parámetros, suites ni ciclos de vida elaborados, y la reacción instintiva es leerlo como inmadurez. Es lo contrario. Cada capacidad que un framework de pruebas añade es una capacidad que el runner anfitrión debe poder implementar, y en cuanto una de ellas solo existe en la JVM la fachada deja de ser una fachada y se convierte en un conjunto de bibliotecas parecidas que comparten nombre. El mecanismo del alias de tipo es la elección arquitectónica que hace posible esa disciplina, porque a diferencia de un adaptador que envuelve al framework anfitrión, un alias desaparece por completo en la compilación: el bytecode contiene la anotación de JUnit, y por tanto no hay superficie donde alojar comportamiento propio aunque alguien quisiera añadirlo. La biblioteca es incapaz de crecer en la dirección equivocada, y esa incapacidad está construida a propósito. Piensa en qué habría pasado con el diseño opuesto. Si kotlin.test hubiera implementado su propio motor de descubrimiento y ejecución, habría necesitado reflexión, que en Kotlin/Native no existe y en Wasm es prohibitivamente cara; habría tenido que reimplementar el informe de resultados en cuatro formatos; habría roto la integración con el IDE, con los informes de cobertura y con las herramientas de reintento de la industria; y cada versión nueva del compilador habría arrastrado un motor de pruebas que mantener. En lugar de eso, la biblioteca delega absolutamente todo lo que sea infraestructura y se queda solo con el vocabulario, que es justamente la parte que no depende de la plataforma. El efecto secundario más valioso de esa decisión es pedagógico: cuando la única herramienta disponible es una anotación y una aserción, el test tiene que expresar una afirmación sobre el comportamiento del código y no sobre la mecánica del framework. Los tests que resultan son legibles diez años después, sobreviven a la migración de JUnit 4 a JUnit 5 sin tocar una línea y se pueden mover de un módulo de JVM a un módulo multiplataforma copiando el fichero. Ninguna de esas tres propiedades la tiene un test escrito contra las extensiones específicas de un runner concreto, por muy cómodas que resulten el día que se escriben.

⚔️ Un fichero, cuatro objetivos
  1. Crea un módulo multiplataforma con objetivos de JVM, JS, linuxX64 o macosArm64, y wasmJs, y escribe en commonTest una clase con tres tests que usen assertEquals, assertFailsWith y assertIs.
  2. Ejecuta las cuatro tareas de test y compara los informes generados. Localiza en el directorio de compilación el ejecutable de Native y confirma que contiene un punto de entrada propio.
  3. Introduce deliberadamente una llamada a System.currentTimeMillis en el test común y observa en qué fase falla cada objetivo. Sustitúyela por una alternativa común y documenta cuál elegiste.
  4. Cambia useJUnitPlatform por la configuración de JUnit 4 y comprueba con un descompilador qué anotación aparece ahora en el bytecode de la clase de test.
  5. Añade un object con un contador mutable compartido entre dos tests y ejecuta las suites varias veces en los cuatro objetivos hasta reproducir un fallo dependiente del orden. Después arréglalo y explica en dos frases por qué el fallo no aparecía en todas las plataformas.