wandres.dev
TESTING DE PRESENTACIÓN · la pirámide

Testear el reducer: la base sobre la que descansa toda la confianza

Probar una función pura es tan sencillo que el riesgo deja de ser técnico y pasa a ser metodológico: el peligro no es no saber escribir el test, sino escribirlo mal de las tres maneras clásicas. Esta lección construye la forma canónica del test de reducer, sustituye la colección de funciones sueltas por tablas parametrizadas que actúan como especificación ejecutable, explica por qué afirmar sobre el estado completo evita el test que reimplementa el reducer, y llega hasta las invariantes y la comprobación basada en propiedades, donde el ordenador genera los casos que a ti no se te habrían ocurrido.

⏱ 17 min

Hay una ironía en el centro de esta lección: el test más importante de una aplicación MVI es también el más aburrido de escribir. No requiere librerías, no requiere dobles, no requiere entender corrutinas, y cabe entero en cinco líneas. Precisamente por eso se descuida. Un equipo dedica semanas a domar la instrumentación de Compose y despacha el reducer con tres tests de ejemplo, cuando es ahí —en la función que decide todo— donde vive la lógica que de verdad puede estar mal. La tesis de esta lección es que la trivialidad técnica del test de reducer no lo hace menos valioso sino más exigente: cuando el andamiaje desaparece, lo único que queda visible es la calidad de tu pensamiento sobre el dominio, y no hay ninguna dificultad de infraestructura detrás de la cual esconderlo.

🎯 Al terminar esta lección sabrás
  • Escribir el test canónico de un reducer con las tres partes explícitas y sin ceremonia.
  • Convertir familias de casos en tablas parametrizadas que funcionen como especificación ejecutable.
  • Evitar los tres antipatrones: reimplementar el reducer, afirmar de menos y depender del orden.
  • Formular invariantes de estado y comprobarlas con secuencias generadas en vez de ejemplos escogidos.

La forma canónica

Un test de reducer tiene exactamente tres partes y ninguna más: un estado inicial construido a mano, un intent, y una comparación con el estado esperado. No hay preparación, no hay limpieza, no hay tiempo.

@Test
fun `cantidad cambiada recalcula el total y no toca el cupon`() {
    val inicial = CarritoState(
        lineas = listOf(Linea(id = "a", precio = 10, unidades = 1)),
        cupon = "VERANO",
    )

    val siguiente = reducir(inicial, CarritoIntent.CantidadCambiada("a", unidades = 3))

    assertEquals(30, siguiente.total)
    assertEquals("VERANO", siguiente.cupon)
}

El nombre del test es la primera aserción y la más leída. Describe la regla de negocio, no la mecánica: cantidad cambiada recalcula el total dice algo sobre el dominio; test reducir dos dice algo sobre la impaciencia de quien lo escribió. Cuando ese test falle dentro de nueve meses, el nombre será lo único que la persona de guardia tenga para orientarse.

Fíjate también en la segunda aserción, que a primera vista parece redundante. Comprobar que el cupón no cambió es exactamente el tipo de afirmación que atrapa las regresiones reales: los reducers rara vez fallan calculando lo que se les pide, fallan tocando lo que no se les pedía. Un test que solo mira el campo protagonista deja el resto del estado sin vigilancia.

⚠️
El estado inicial completo es una trampa silenciosa

Construir el estado con todos los campos explícitos parece riguroso y en realidad es lo que hace que añadir un campo rompa doscientos tests. Usa valores por defecto en la data class y una función auxiliar de fábrica en el paquete de test que solo reciba lo relevante para cada caso. El test debe nombrar únicamente aquello de lo que depende su afirmación; todo lo demás es ruido que envejece mal.

Tablas en vez de funciones sueltas

Cuando una regla tiene fronteras —cero unidades, stock agotado, cupón caducado, importe negativo— escribir una función por caso multiplica el texto sin multiplicar la información. La forma correcta es la tabla: una lista de tripletas recorrida por un único test.

private data class Caso(
    val nombre: String,
    val inicial: CarritoState,
    val intent: CarritoIntent,
    val esperado: CarritoState,
)

private val casos = listOf(
    Caso("cero unidades elimina la linea", conUnaLinea(1), cambiar("a", 0), vacio()),
    Caso("supera el stock y se topa", conUnaLinea(1), cambiar("a", 99), conUnaLinea(10)),
    Caso("negativo se ignora", conUnaLinea(2), cambiar("a", -1), conUnaLinea(2)),
)

@Test
fun `tabla de transiciones del carrito`() {
    casos.forEach { caso ->
        assertEquals(caso.esperado, reducir(caso.inicial, caso.intent), caso.nombre)
    }
}

Esa tabla es la especificación de la pantalla escrita en un formato que la máquina verifica. Tiene una virtud que ninguna prosa posee: no puede quedarse desactualizada sin que alguien se entere. Y tiene una virtud práctica adicional, que es su legibilidad para quien no programa; una analista de negocio puede leer las tres columnas y decir que falta un caso, cosa que jamás hará leyendo tres funciones con anotaciones.

La única precaución seria es el mensaje de fallo. Si recorres la lista con un bucle sencillo, un fallo te dirá que el estado esperado no coincide, pero no cuál de los quince casos falló; pasar el nombre como tercer argumento de la aserción, o usar el soporte de casos parametrizados del framework, es lo que convierte una tabla en algo depurable.

Afirmar sobre el estado entero

Aquí aparece el antipatrón más dañino y el más difícil de detectar en revisión: el test que reimplementa el reducer. Ocurre cuando la aserción calcula el valor esperado en lugar de escribirlo.

// Antipatron: el test repite la formula y por tanto no la comprueba
assertEquals(inicial.lineas.sumOf { it.precio * it.unidades }, siguiente.total)

// Correcto: el valor esperado es un literal escrito por una persona
assertEquals(30, siguiente.total)

La versión defectuosa pasa aunque la fórmula esté mal, porque el error está por duplicado a ambos lados de la igualdad. Un test solo tiene valor cuando el resultado esperado procede de una fuente independiente del código bajo prueba: el enunciado del negocio, una hoja de cálculo, una cabeza humana. En cuanto el test deriva su expectativa del mismo razonamiento que la implementación, ha dejado de ser una comprobación y se ha convertido en una tautología cara.

🪞

El test espejo

Calcula la expectativa con la misma fórmula que la implementación. Pasa siempre, incluso con la fórmula equivocada.

🔍

La aserción miope

Solo mira el campo protagonista. No detecta que el reducer tocó tres campos que no debía tocar.

🔗

El test encadenado

Depende del estado que dejó el test anterior. Pasa en suite completa y falla al ejecutarlo suelto.

🏷️

El nombre mudo

Se llama reducir tres. Cuando falla dentro de un año, nadie sabe qué regla se acaba de romper.

La contrapartida natural de este principio es comparar estados enteros y no campos sueltos, porque una data class con equals estructural convierte una sola aserción en una comprobación completa. El coste es que el mensaje de fallo se vuelve más largo; la ganancia es que ningún campo queda fuera de vigilancia y que añadir un campo obliga a revisar qué debería valer, que es justo la conversación que quieres tener.

flowchart LR
N[Regla de negocio] --> T[Valor esperado escrito a mano]
N --> C[Codigo del reducer]
T --> A[Asercion]
C --> A
A --> V[Comprobacion real]
style T fill:#a6e3a1,color:#11111b
style A fill:#cba6f7,color:#11111b

El diagrama resume la condición de validez: las dos ramas que llegan a la aserción deben partir del enunciado y no una de la otra. En cuanto el valor esperado se calcula a partir del código, la figura se cierra sobre sí misma y la comprobación desaparece.

Invariantes y casos generados

Los ejemplos escogidos a mano tienen un límite estructural: solo cubren las situaciones que se te ocurrieron, y los bugs viven justamente en las que no. El siguiente escalón consiste en dejar de enumerar casos y empezar a enunciar propiedades que deben cumplirse siempre.

@Test
fun `ninguna secuencia de intents deja cargando y error a la vez`() {
    repeat(1_000) {
        val traza = generarIntentsAleatorios(longitud = 20)
        val estados = traza.runningFold(CarritoState()) { acc, i -> reducir(acc, i) }
        estados.forEach { estado ->
            assertFalse(estado.cargando && estado.error != null, "traza: $traza")
        }
    }
}

Ese patrón tiene una variante todavía más barata que casi nadie usa y que atrapa la clase de bug más difícil de razonar a mano: la comprobación de idempotencia. Muchos intents deben poder aplicarse dos veces seguidas sin cambiar nada la segunda vez, y esa propiedad se expresa en una línea.

@Test
fun `aplicar el mismo cupon dos veces no cambia el estado`() {
    val unaVez = reducir(CarritoState(), CarritoIntent.AplicarCuponPulsado("VERANO"))
    val dosVeces = reducir(unaVez, CarritoIntent.AplicarCuponPulsado("VERANO"))
    assertEquals(unaVez, dosVeces)
}

Las invariantes útiles son afirmaciones sobre lo que el estado nunca debe permitir: que el total no sea negativo, que no haya líneas duplicadas, que cargando y error se excluyan, que un identificador seleccionado exista siempre en la lista. Escribirlas obliga a un ejercicio que ningún test de ejemplo provoca: articular en voz alta las reglas que hasta ese momento vivían implícitas en la cabeza de alguien.

Un detalle operativo importante es imprimir la traza en el mensaje de fallo. Cuando una comprobación generada falla, lo único que la hace accionable es poder reproducirla; con la lista de intents en el informe de error, el caso se convierte de inmediato en un test de ejemplo permanente que se añade a la tabla.

El test del reducer no comprueba el código: comprueba tu modelo del dominio

Merece la pena detenerse en lo que realmente ocurre cuando escribes un test de reducer, porque no es lo que parece. Técnicamente estás verificando que una función devuelve un valor, pero epistemológicamente estás haciendo algo mucho más raro en programación: estás escribiendo dos veces la misma regla por dos caminos independientes y comprobando que coinciden. Uno de los caminos es general —el when que cubre todos los casos— y el otro es particular —el ejemplo concreto con números escritos a mano—. La confianza no nace de ejecutar código, nace de que dos razonamientos distintos sobre el mismo dominio hayan llegado al mismo sitio. De ahí se siguen dos consecuencias que cambian la práctica. La primera es que un test cuyo valor esperado se calcula con la misma fórmula que la implementación no aporta absolutamente nada, por muchas líneas de cobertura que encienda: los dos caminos se han fusionado en uno y la coincidencia está garantizada por construcción. La segunda es que el momento más productivo de escribir tests de reducer no es cuando pasan sino cuando te cuesta decidir qué debería valer el estado esperado; esa duda no es una laguna de conocimiento técnico, es un hueco en la especificación del producto que acaba de hacerse visible, y llevarlo a quien define el negocio suele descubrir que tampoco allí estaba decidido. Por eso la base de la pirámide no es la parte fácil que se despacha rápido para llegar a lo interesante. Es el único estrato donde la ambigüedad del dominio se manifiesta como una pregunta concreta y respondible, antes de que se convierta en un comportamiento accidental que nadie eligió, que la instrumentación confirmará obedientemente y que dentro de un año alguien defenderá como si hubiera sido una decisión.

Un apunte final sobre el mantenimiento: los tests de reducer no se refactorizan igual que el código. Cuando cambia una regla de negocio, el test debe romperse, y arreglarlo es el trabajo. Cuando cambia solo la estructura interna sin cambiar la regla, el test no debería inmutarse; si lo hace, estaba acoplado a la implementación y no a la conducta, y esa es la señal para reescribirlo.

⚔️ Convierte tu reducer en especificación
  1. Toma la pantalla más compleja de tu aplicación y escribe la tabla de transiciones con al menos doce casos, incluyendo todas las fronteras.
  2. Revisa cada aserción y elimina las que calculen el valor esperado en lugar de escribirlo como literal.
  3. Sustituye las aserciones campo a campo por comparaciones de estado completo y anota cuántos campos estaban sin vigilar.
  4. Enuncia por escrito tres invariantes que tu estado deba cumplir siempre y compruébalas sobre mil secuencias generadas.
  5. Documenta el primer caso que la generación aleatoria encuentre y añádelo a la tabla como caso permanente con nombre propio.