Dobles de prueba: clases finales, all-open y fakes escritos a mano
Que las clases de Kotlin sean finales por defecto no es un obstáculo accidental para las bibliotecas de mocking: es la consecuencia directa de una decisión de diseño que declara la herencia como un contrato explícito, y el mocking basado en subclases lleva veinte años apoyándose en la suposición contraria. Esta lección explica por qué un mock no puede extender lo que no es abierto, evalúa las tres salidas habituales —instrumentación del bytecode, el plugin all-open y el rediseño hacia interfaces—, muestra por qué ninguna de las dos primeras funciona en multiplataforma, y defiende el fake escrito a mano como la única alternativa que comprueba comportamiento en lugar de interacciones.
El choque entre Kotlin y las bibliotecas de mocking heredadas de Java es una colisión entre dos filosofías, no un fallo de compatibilidad. El mocking clásico funciona generando en tiempo de ejecución una subclase que redefine todos los métodos, y esa técnica presupone que cualquier clase se puede extender y cualquier método se puede redefinir, que es exactamente lo que Kotlin decidió negar por defecto. Cuando una herramienta te informa de que no puede simular una clase final, no está señalando una carencia del lenguaje: está revelando que tu test necesitaba una capacidad que el diseño de tu código declara inexistente, y a partir de ahí solo hay dos caminos honestos, cambiar el diseño o desactivar la garantía.
- Explicar por qué el mocking por subclase es incompatible con la finalidad por defecto y qué hace la instrumentación para sortearla.
- Configurar el plugin
all-openlimitado a los tests y enumerar con precisión lo que cuesta hacerlo. - Justificar por qué en un proyecto multiplataforma las técnicas de bytecode no son una opción.
- Diseñar un fake escrito a mano y distinguir cuándo comprobar estado y cuándo comprobar interacción.
Final por defecto y el mocking por subclase
Una clase de Kotlin sin el modificador open se compila como final, y sus métodos también. La justificación de ese valor por defecto es el problema de la clase base frágil: heredar de una clase no diseñada para ser heredada acopla al hijo a detalles internos que el autor nunca prometió mantener. Por añadidura, la finalidad permite al compilador y a la máquina virtual devirtualizar llamadas e insertarlas en línea con más agresividad.
Las bibliotecas de mocking tradicionales operan justo en la dirección contraria. Para crear un doble de una clase, generan en tiempo de ejecución una subclase cuyos métodos redefinidos consultan una tabla de comportamientos configurados. Sobre una clase final esa generación es imposible, y el resultado es un error en el momento de crear el doble.
// Producción: final por defecto, tal y como debe ser
class CalculadoraDeEnvio(private val tarifas: TablaDeTarifas) {
fun coste(peso: Int): Int = tarifas.para(peso) + 250
}
// El doble por subclase no puede existir sobre esta clase
Las salidas técnicas son dos y ambas consisten en dejar de usar subclases. La primera es la instrumentación: agentes que reescriben el bytecode de la clase real en el momento de cargarla, sustituyendo los cuerpos de sus métodos por desvíos hacia la tabla de comportamientos. Así funcionan el generador en línea de Mockito y la biblioteca idiomática de Kotlin para este fin, que además de clases finales puede simular declaraciones object, funciones de nivel superior y extensiones. La segunda es el plugin all-open, que actúa antes, en compilación.
Los agentes que se conectan a la máquina virtual en caliente están siendo restringidos progresivamente y ya emiten advertencias. Un proyecto que apoya toda su estrategia de pruebas en esa capacidad está construyendo sobre una superficie que la plataforma ha declarado en retirada.
all-open para tests y su factura
El plugin all-open elimina el modificador final de las clases y los miembros que lleven determinadas anotaciones. Es la maquinaria que hace funcionar a Spring con Kotlin sin obligar a marcar cada componente como abierto, y se puede reutilizar para tests declarando una anotación propia sin significado semántico.
// Definición de la marca
annotation class AbiertoParaPruebas
// build.gradle.kts
plugins { kotlin("plugin.allopen") version "2.2.0" }
allOpen { annotation("com.ejemplo.AbiertoParaPruebas") }
@AbiertoParaPruebas
class CalculadoraDeEnvio(private val tarifas: TablaDeTarifas) {
fun coste(peso: Int): Int = tarifas.para(peso) + 250
}
Conviene ser exacto sobre lo que acaba de ocurrir, porque el discurso habitual lo presenta como una configuración inocua. La clase ya no es final en el artefacto que llega a producción: cualquiera puede heredarla, la máquina virtual pierde la garantía que usaba para devirtualizar, y el compilador de Kotlin deja de aplicar optimizaciones que dependían de la finalidad. La anotación dice abierto para pruebas, pero el bytecode dice abierto, sin más.
La variante que intenta limitar el daño consiste en aplicar el plugin únicamente en una variante de compilación destinada a pruebas, de modo que el binario de producción conserve la finalidad. Es viable en proyectos con sabores de compilación, pero introduce una asimetría incómoda: el código que ejecutan los tests no es exactamente el que se despliega, y la diferencia recae precisamente sobre la propiedad de la que dependen las optimizaciones y el polimorfismo.
Instrumentación
Simula cualquier cosa sin tocar el diseño. A cambio depende de un agente de la máquina virtual, ralentiza el arranque de la suite y solo existe en la JVM.
Plugin all-open
No necesita agente y funciona en compilación. A cambio abre de verdad las clases marcadas y disuelve la garantía que justificaba la finalidad.
Interfaces
Sustituir la dependencia concreta por una interfaz hace innecesaria toda la maquinaria anterior y funciona en los cuatro backends.
Fake escrito a mano
Una implementación real y simple de esa interfaz, con estado propio, comprobable y reutilizable en toda la suite.
Interfaces y fakes: la salida que además mejora el diseño
En un proyecto multiplataforma la discusión anterior se cierra sola: Kotlin/Native no tiene reflexión ni carga dinámica de clases, JS y Wasm tampoco tienen bytecode que reescribir, y por tanto ninguna biblioteca basada en manipulación en tiempo de ejecución puede vivir en commonTest. Existen generadores de dobles basados en procesamiento de símbolos que producen el código en compilación, y precisamente por eso solo pueden sustituir interfaces y clases abiertas: han vuelto al mismo requisito del que se intentaba escapar.
La alternativa que funciona en todas partes es declarar la dependencia como interfaz e implementarla dos veces, una para producción y otra para pruebas. El doble de pruebas no simula: funciona.
interface TablaDeTarifas {
fun para(peso: Int): Int
}
class TarifasFalsas(private val precios: Map<Int, Int> = emptyMap()) : TablaDeTarifas {
var consultas = 0
private set
override fun para(peso: Int): Int {
consultas++
return precios[peso] ?: (peso * 10)
}
}
@Test
fun anadeLaComisionFija() {
val tarifas = TarifasFalsas(mapOf(5 to 1_000))
assertEquals(1_250, CalculadoraDeEnvio(tarifas).coste(5))
assertEquals(1, tarifas.consultas)
}
Un fake bien hecho tiene una propiedad que ningún doble configurado en el test posee: es una implementación coherente, así que un repositorio falso en memoria devuelve lo que se le guardó, respeta sus propias invariantes y falla igual que el real ante entradas inválidas. Eso permite comprobar estado —qué quedó guardado, qué se devolvió— en lugar de interacción —qué método se llamó y con qué argumentos—, y la diferencia es decisiva: las aserciones sobre interacciones se rompen en cada refactorización que no cambia el comportamiento, que es exactamente el tipo de cambio que los tests deberían proteger.
Hay, eso sí, una familia de casos donde la interacción sí forma parte del contrato y comprobarla es legítimo: cuando el efecto observable del sistema es precisamente la llamada. Que se envíe exactamente un correo, que no se cobre dos veces al mismo cliente o que se registre un evento de auditoría por cada acceso son afirmaciones sobre la interacción, y un fake puede sostenerlas sin dejar de ser un fake, sin más que llevar la cuenta por dentro.
Y hay una simplificación que se pasa por alto con demasiada frecuencia. Si el rol que necesitas sustituir tiene una sola operación, no hace falta declarar una interfaz ni escribir una clase: un tipo función es un doble perfectamente válido, se sustituye con una lambda y no obliga a nadie a inventar un nombre para un concepto que no existe.
class CalculadoraDeEnvio(private val tarifaDe: (Int) -> Int) {
fun coste(peso: Int): Int = tarifaDe(peso) + 250
}
@Test
fun usaLaTarifaRecibida() =
assertEquals(1_250, CalculadoraDeEnvio { 1_000 }.coste(5))
flowchart TD
A[Necesito sustituir una dependencia] --> B{Es una interfaz}
B -->|Si| C[Fake escrito a mano]
C --> D[Funciona en los cuatro objetivos]
B -->|No, clase final| E{Proyecto multiplataforma}
E -->|Si| F[Extraer interfaz, no hay otra via]
E -->|No| G{Puedo cambiar el diseno}
G -->|Si| F
G -->|No| H[Instrumentacion o plugin all-open]
H --> I[Deuda tecnica acotada y documentada]Una interfaz con una sola implementación de producción y un fake es una abstracción justificada por el límite del sistema, no por el test. Si la interfaz se limita a duplicar la superficie de la clase concreta sin representar un rol, lo que necesitabas era pasar una función, no declarar un tipo.
Merece la pena reconstruir la cadena de razonamiento completa, porque termina en un sitio distinto del que sugiere la queja inicial. Kotlin hizo finales las clases por defecto para que la extensibilidad fuese una decisión declarada, no un accidente heredado del valor por defecto de otro lenguaje. Las bibliotecas de mocking presuponen extensibilidad universal porque nacieron en un ecosistema donde ese accidente era la norma. Cuando ambas cosas chocan, la lectura instintiva es que el lenguaje estorba a la herramienta, y de ahí salen el agente que reescribe bytecode y el plugin que abre las clases: dos formas de restaurar por la fuerza la suposición que el lenguaje había retirado a conciencia. La lectura correcta es la inversa. Que tu test necesite sustituir una clase concreta significa que tu código depende de una implementación y no de un rol, y esa dependencia es un problema con independencia de que exista o no una herramienta capaz de disimularla. La prueba está en que los mismos sistemas donde el mocking de clases finales resulta imprescindible son los que después no pueden cambiar de proveedor de persistencia, no pueden introducir una caché sin tocar veinte ficheros y no pueden compilar a otro objetivo. El test no creó ninguno de esos problemas; fue el primero en tropezar con ellos. Hay una segunda capa igual de importante. Un doble configurado en el test codifica una expectativa sobre la interacción, y esa expectativa es una copia de la implementación actual escrita en otro fichero: cuando refactorizas y el comportamiento observable no cambia, el test se rompe igualmente, y el equipo aprende que refactorizar es caro. Un fake escrito a mano codifica en cambio una implementación alternativa del mismo contrato, de modo que sobrevive a la refactorización y falla solo cuando el comportamiento cambia de verdad. El coste de escribirlo es real —unas decenas de líneas por rol, mantenidas a lo largo del tiempo— y hay que pagarlo con los ojos abiertos; lo que a cambio se compra es una definición ejecutable de qué significa cumplir ese contrato, algo que ningún doble configurado línea a línea llegará a ser nunca. La elección, en el fondo, no es entre dos tecnologías de test: es entre escribir tus contratos o simular que los tienes.
- Localiza en tu proyecto el test con más comprobaciones de interacción sobre una clase concreta y anota cuántas de ellas describen comportamiento observable y cuántas describen la implementación actual.
- Extrae el rol que realmente usa el código bajo prueba como interfaz, quedándote solo con los métodos que se invocan, y escribe un fake en memoria que la implemente de forma coherente.
- Reescribe el test contra el fake comprobando estado en lugar de interacciones. Después renombra un método interno de la clase de producción y comprueba cuál de las dos versiones del test sigue pasando.
- Configura el plugin
all-opencon una anotación propia, aplícala a una clase y verifica con un descompilador que la clase resultante ya no es final en el artefacto de producción. - Añade un objetivo de Kotlin/Native al módulo y mueve los tests a
commonTest. Anota qué dobles dejaron de compilar y cuántas líneas de fake hicieron falta para reemplazarlos.