Inyección en los tests: sustituir sin tocar producción
La prueba de fuego de un grafo de dependencias bien diseñado es poder cambiar una implementación real por una falsa sin modificar una sola línea del código que se envía a los usuarios. Esta lección recorre las tres capas donde eso ocurre —construcción directa en tests unitarios, sustitución de módulos con `@TestInstallIn` y `@BindValue` en Hilt, y sobrescritura de definiciones en Koin— y termina defendiendo por qué un doble escrito a mano suele producir mejores tests que un simulacro generado, y qué revela sobre el diseño el hecho de necesitar demasiados.
Cualquier discurso sobre inyección de dependencias es palabrería hasta que se comprueba en el único sitio donde se cobra: el momento de escribir un test que necesita que la red falle, que el reloj marque una fecha concreta o que la base de datos devuelva exactamente tres elementos. Si conseguirlo exige tocar el código de producción —una bandera de depuración, un condicional que mire si estamos en un test, un campo que alguien pueda reasignar—, entonces el grafo no estaba invertido, sino disfrazado. Un diseño correcto permite sustituir cualquier nodo del grafo desde fuera, sin que el código enviado a los usuarios contenga la más mínima traza de que existen tests. Esta lección muestra cómo se hace eso en las tres capas donde se hace, y qué significa cuando cuesta más de lo que debería.
- Situar cada tipo de test en la capa correcta y decidir cuándo hace falta el grafo y cuándo estorba.
- Sustituir módulos completos en Hilt con
@TestInstallIny valores concretos con@BindValue. - Sobrescribir definiciones en Koin y aislar cada test para que no herede estado del anterior.
- Elegir entre un doble escrito a mano y un simulacro generado según lo que el test necesita afirmar.
Cada test en su capa
El error más frecuente al hablar de inyección en tests es suponer que todos los tests necesitan el contenedor. La mayoría no solo no lo necesita, sino que empeora si lo usa.
flowchart TD A[Test de reductor: funcion pura, sin grafo] --> B[Test de contenedor: constructor a mano con dobles] B --> C[Test de integracion: grafo real con modulos sustituidos] C --> D[Test de interfaz: aplicacion completa y dobles en la frontera] style A fill:#a6e3a1,color:#11111b style B fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b style D fill:#f38ba8,color:#11111b
En las dos capas de abajo no interviene ningún framework de inyección: el reductor es una función pura sobre el estado y el contenedor se construye a mano pasándole dobles, exactamente como se haría con cualquier clase de Kotlin. Esa es, de hecho, la mejor prueba de que la inyección por constructor está bien hecha: si el test unitario necesita arrancar Hilt o Koin, algo se ha buscado en vez de recibirse.
class RepositorioPedidosFalso(
private val pedidos: MutableList<Pedido> = mutableListOf(),
private val fallar: Boolean = false,
) : RepositorioPedidos {
override suspend fun crear(lineas: List<Linea>, envio: Dinero, fecha: Instant): Pedido {
if (fallar) error("sin conexion")
return Pedido(id = "p-${pedidos.size}", lineas = lineas).also { pedidos += it }
}
}
@Test
fun `un fallo de red deja el estado con error y no confirma`() = runTest {
val vm = PedidoViewModel(
repositorio = RepositorioPedidosFalso(fallar = true),
calcularEnvio = CalcularEnvioFijo(Dinero(500)),
reloj = RelojFijo(Instant.parse("2026-01-15T10:00:00Z")),
)
vm.test(this) {
expectInitialState()
containerHost.confirmar()
expectState { copy(enviando = true) }
expectState { copy(enviando = false, error = "sin conexion") }
}
}
El framework de inyección solo aparece en las dos capas superiores, donde se prueba que las piezas encajan de verdad y donde por tanto tiene sentido levantar el grafo real y sustituir únicamente sus fronteras.
Sustituir módulos en Hilt
Hilt resuelve la sustitución con una idea limpia: un módulo de test declara que reemplaza a un módulo de producción, y el procesador construye para los tests un grafo distinto donde el original nunca se instala.
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositorioModule::class],
)
abstract class RepositorioTestModule {
@Binds
abstract fun vincular(impl: RepositorioPedidosFalso): RepositorioPedidos
}
@HiltAndroidTest
class ConfirmarPedidoTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
// Sustitucion puntual para un solo caso, sin crear modulo aparte
@BindValue
@JvmField
val reloj: Reloj = RelojFijo(Instant.parse("2026-01-15T10:00:00Z"))
@Test
fun confirmarNavegaAlResumen() {
hiltRule.inject()
// ...
}
}
Hay tres mecanismos y conviene no confundirlos. @TestInstallIn sustituye un módulo para toda la fuente de tests y es el adecuado para las fronteras estables, como la red o la base de datos. @BindValue sustituye un único enlace en una clase de test y sirve para lo que varía caso a caso, como el reloj o una configuración. @UninstallModules desinstala módulos en una clase concreta y obliga a proveer sustitutos ahí mismo; es el más ruidoso y el que menos se usa desde que existe @TestInstallIn.
Lo notable no es que se pueda sustituir, sino que la verificación no se pierde al hacerlo. El grafo de test también se comprueba al compilar: si el módulo sustituto olvida proveer algo que el original proveía, el error aparece al construir los tests y no al ejecutarlos. Es decir, la propiedad que Hilt garantiza en producción sigue garantizada en la configuración de pruebas, que es donde muchos proyectos la pierden sin darse cuenta.
Sobrescribir definiciones en Koin
Koin no genera nada, así que su sustitución es más directa y también más peligrosa: se declara un módulo con las mismas definiciones y se carga después, permitiendo el reemplazo.
val testModule = module {
single<RepositorioPedidos> { RepositorioPedidosFalso() }
single<Reloj> { RelojFijo(Instant.parse("2026-01-15T10:00:00Z")) }
}
class ConfirmarPedidoTest : KoinTest {
@get:Rule
val koinRule = KoinTestRule.create {
modules(datosModule, presentacionModule, testModule)
}
@After
fun limpiar() = stopKoin()
}
Dos advertencias importan aquí más que en Hilt. La primera es el aislamiento: el registro de Koin es global al proceso, de modo que si un test no lo detiene al terminar, el siguiente hereda definiciones que no declaró. Usar la regla de test, o parar el contenedor explícitamente, no es una buena práctica opcional sino la condición para que la suite sea determinista. La segunda es que la sobrescritura por tipo es silenciosa: si dos módulos definen el mismo tipo, gana el último cargado, y un cambio de orden puede alterar qué implementación recibe el test sin que nada avise.
La solución que hay que rechazar sin negociar es la condicional dentro del código enviado: comprobar una bandera de depuración, mirar el nombre del paquete o consultar si estamos en un test para devolver una implementación u otra. Eso hace que el binario de producción contenga caminos que nadie prueba y, peor, que el camino probado no sea el que corre en los dispositivos de los usuarios. La sustitución debe ocurrir siempre en el ensamblaje —módulos de test, fuentes de prueba, variantes de compilación—, nunca en la lógica.
Falsos escritos a mano frente a simulacros generados
Un doble puede escribirse como una clase real que implementa la interfaz con una versión simplificada —un falso— o generarse con una librería que registra llamadas y devuelve lo que se le programa —un simulacro—. La elección no es de gusto y tiene consecuencias claras sobre la solidez de la suite.
Falso: comportamiento reutilizable
Un repositorio en memoria se escribe una vez y sirve en decenas de tests. Se comporta como el real —lo que guardas lo puedes leer— así que verifica estado y no interacciones.
Simulacro: verificar interacción
Útil cuando lo que importa es que se llamó, no el resultado: un registro de analítica, un envío de notificación. Ahí el efecto observable es la llamada misma.
Acoplamiento a la implementación
Un test que afirma la secuencia exacta de llamadas se rompe con cualquier refactorización interna aunque el comportamiento sea idéntico. Es la causa principal de suites frágiles.
Señal de diseño
Si un test necesita programar cinco simulacros para funcionar, la clase bajo prueba tiene demasiados colaboradores. El dolor del test está midiendo el acoplamiento, no la herramienta.
La regla que resiste el paso del tiempo es sencilla: verifica estado siempre que puedas, verifica interacciones solo cuando la interacción es el efecto. Un test que afirma que tras confirmar el estado contiene el identificador del pedido sobrevive a cualquier reescritura interna; uno que afirma que se llamó a crear exactamente una vez con esos tres argumentos se rompe en cuanto alguien añade un parámetro, sin que nada haya dejado de funcionar.
Hay una simetría que atraviesa este nivel entero y que conviene enunciar al final, cuando ya hay material para sostenerla: el esfuerzo que cuesta preparar un test es una medida directa del acoplamiento del código que prueba, y es la única medida que se obtiene gratis, sin herramientas, sin métricas y sin discusiones de estilo. Cuando un test se escribe en cuatro líneas porque basta construir la clase con tres dobles, eso no significa que el test sea bueno: significa que la clase tiene fronteras claras, pocas responsabilidades y ninguna dependencia oculta. Cuando exige levantar un contexto de Android, arrancar un grafo entero, programar siete simulacros y limpiar un registro global, tampoco significa que falte una librería mejor: significa que la clase no se puede aislar porque no está aislada, y ninguna herramienta arregla eso, solo lo hace más soportable. Ahí está el valor epistemológico real de la disciplina de tests, muy por encima de la detección de regresiones que todo el mundo cita: es un instrumento de medición que devuelve una lectura honesta sobre la estructura, y lo hace antes de que el coste del acoplamiento se pague en forma de miedo a tocar código. Por eso el consejo de escuchar a los tests no es una metáfora amable sino una instrucción precisa: cuando duelen, no están pidiendo un simulacro más potente, están informando de que hay una frontera mal trazada. Y por eso la respuesta correcta a un test difícil casi nunca está en el test.
- Escribe un falso en memoria de tu repositorio principal y úsalo en tres tests de contenedor construidos a mano, sin arrancar ningún framework.
- Crea un módulo de test que sustituya el módulo real de red con
@TestInstallIno con un módulo de Koin cargado al final, según tu proyecto. - Sustituye el reloj con
@BindValueo una definición de test y escribe un caso que dependa de una fecha concreta. - Busca en tu código cualquier condicional que dependa de estar en un test y elimínalo trasladando la decisión al ensamblaje.
- Toma el test que más preparación exija de tu suite, cuenta los dobles que necesita y propón qué frontera del diseño habría que mover para reducirlos a dos.