wandres.dev
TESTING DEL ESTADO · lógica sin UI

Testear reducers y transiciones

Una vez separado el núcleo de la vista, aparece el test más barato que existe en toda la disciplina: dar un estado y un evento a una función pura y comparar el estado que devuelve. Esta lección exprime esa figura hasta el fondo. Empieza estableciendo por qué la firma de un reducer es la superficie de contrato ideal —sin montaje, sin dobles, sin asincronía, con igualdad estructural como única aserción— y por qué eso convierte cada regla de negocio en una comparación de valores. Sigue con la técnica que convierte esos tests en documentación ejecutable: la tabla de casos, expresada con las utilidades de datos de los ejecutores modernos, que hace visible la matriz de estados por eventos y expone por omisión los huecos que ningún test cubre. Después trata el problema de qué comprobar realmente en una transición, distinguiendo entre afirmar el estado completo y afirmar solo el delta, y señalando el error clásico de reimplementar el reducer dentro de su propio test. Cierra con una lectura crítica de la cobertura: qué mide la cobertura de ramas, por qué la de líneas miente, y por qué ni siquiera el cien por cien de ramas garantiza que las transiciones prohibidas estén prohibidas.

⏱ 20 min

Si la lección anterior fue la condición de posibilidad, esta es la recompensa inmediata. Cuando la lógica de estado adopta la forma canónica —una función que recibe el estado actual y un evento y devuelve el estado siguiente— el test correspondiente se reduce a su expresión más pura: escribe un valor, llama a la función, compara con otro valor. No hay entorno que preparar, ni dobles que configurar, ni esperas que sincronizar, ni orden de ejecución que respetar. Es literalmente el test más barato que se puede escribir en programación, y esa baratura tiene consecuencias que van mucho más allá de la velocidad: cuando comprobar una regla cuesta cuatro líneas y ochenta microsegundos, dejas de racionar las comprobaciones y empiezas a escribirlas todas, incluidas las que nunca habrías escrito si cada una costase medio minuto de navegador. Esta lección va sobre cómo explotar esa economía de forma sistemática, no anecdótica: cómo pasar de tests sueltos a tablas de casos que exhiben la matriz completa de estados por eventos, cómo decidir qué afirmar en cada transición sin caer en el vicio de reescribir el reducer dentro del test, y cómo leer la cobertura sin dejarse engañar por un número verde que puede estar tapando exactamente las ramas que importan.

🎯 Al terminar esta lección sabrás
  • Reconocer por qué la firma de un reducer es la superficie de contrato más barata de testear y qué elimina exactamente de la ecuación.
  • Escribir tablas de casos con las utilidades de datos del ejecutor para exhibir la matriz de estados por eventos y sus huecos.
  • Decidir qué afirmar en una transición y evitar el antipatrón de reimplementar la lógica dentro del propio test.
  • Interpretar la cobertura de ramas con criterio, distinguiendo lo que mide de lo que no puede medir.

La firma que hace el test trivial

Un reducer tiene una firma que impone un contrato muy estrecho, y esa estrechez es toda su virtud a efectos de prueba. Recibe dos valores, devuelve uno y no puede hacer nada más: no observa el mundo, no lo modifica, no depende de cuántas veces se le haya llamado antes. De ahí se siguen tres propiedades que cualquier ingeniero de tests reconocería como el escenario ideal. Es determinista, así que los mismos argumentos producen siempre el mismo resultado y la intermitencia es imposible por construcción. Es aislado, así que el orden de los tests dentro del archivo es irrelevante y pueden ejecutarse en paralelo sin estado compartido. Y es total en su superficie observable: todo lo que hace se ve en el valor devuelto, de modo que una sola aserción de igualdad estructural agota la comprobación.

Esa última propiedad merece un momento de atención porque es la que ahorra más trabajo. En un test de código imperativo hay que decidir dónde mirar: qué campo consultar, qué llamada espiar, qué efecto verificar. En un reducer no hay dónde mirar, porque el resultado entero es el valor devuelto. La aserción no es una selección de aspectos sino una comparación total, y esa comparación la hace el ejecutor con igualdad profunda sin que tengas que enumerar nada.

type Estado = { items: string[]; abierto: boolean }
type Evento =
  | { tipo: 'ANADIR'; item: string }
  | { tipo: 'VACIAR' }
  | { tipo: 'ALTERNAR' }

function reducer(estado: Estado, evento: Evento): Estado {
  switch (evento.tipo) {
    case 'ANADIR':
      return { ...estado, items: [...estado.items, evento.item] }
    case 'VACIAR':
      return { ...estado, items: [] }
    case 'ALTERNAR':
      return { ...estado, abierto: !estado.abierto }
  }
}

test('ANADIR agrega al final sin tocar el resto del estado', () => {
  const antes: Estado = { items: ['a'], abierto: true }
  expect(reducer(antes, { tipo: 'ANADIR', item: 'b' })).toEqual({
    items: ['a', 'b'],
    abierto: true,
  })
})

Repara en un detalle del test anterior que suele pasar inadvertido y que es la mitad de su valor: al comparar el estado completo, la aserción no solo confirma que se añadió el elemento, sino que confirma que nada más cambió. Ese segundo mensaje —el campo abierto sigue valiendo lo que valía— es una comprobación de no interferencia que en un sistema imperativo habría requerido su propia batería de tests, y que aquí sale gratis por el hecho de afirmar el valor entero.

Esa gratuidad tiene una contrapartida que conviene gestionar antes de que muerda. A medida que el estado crece, escribir el valor esperado completo en cada caso se vuelve tedioso y, peor, ruidoso: un cambio legítimo en un campo ajeno obliga a editar cincuenta literales. La tentación entonces es afirmar solo el campo que interesa, y ahí se pierde precisamente la comprobación de no interferencia que hacía valioso el test. La salida sensata no es elegir entre ambos extremos sino declarar la intención: afirmar el estado completo cuando la transición es pequeña y el estado también, y cuando no lo sea, afirmar explícitamente el delta contra el estado de partida en lugar de contra un literal aislado.

// En vez de afirmar solo el campo que interesa, afirma el estado
// de partida con el delta declarado. Sigue detectando interferencias.
const despues = reducer(antes, { tipo: 'ALTERNAR' })
expect(despues).toEqual({ ...antes, abierto: !antes.abierto })

La diferencia entre esa línea y una que solo mirase el campo abierto es enorme: la versión con propagación del estado anterior sigue fallando si la transición toca cualquier otro campo por accidente, que es el error que de verdad cuesta encontrar en producción.

Tablas de casos: la matriz de estados por eventos

Escribir un test por transición funciona hasta que las transiciones son treinta, y a partir de ahí el archivo se convierte en una repetición ritual que nadie lee. La técnica que escala es la tabla de casos: enumerar en una estructura de datos las tripletas de estado inicial, evento y estado esperado, y dejar que el ejecutor genere un test por fila. Los ejecutores modernos ofrecen esta utilidad de serie, y su efecto sobre la legibilidad es desproporcionado respecto a su sencillez.

type Caso = { nombre: string; antes: Estado; evento: Evento; despues: Estado }

const casos: Caso[] = [
  {
    nombre: 'VACIAR sobre lista con elementos deja lista vacia',
    antes: { items: ['a', 'b'], abierto: false },
    evento: { tipo: 'VACIAR' },
    despues: { items: [], abierto: false },
  },
  {
    nombre: 'VACIAR sobre lista vacia es idempotente',
    antes: { items: [], abierto: false },
    evento: { tipo: 'VACIAR' },
    despues: { items: [], abierto: false },
  },
  {
    nombre: 'ALTERNAR invierte el flag y conserva los items',
    antes: { items: ['a'], abierto: false },
    evento: { tipo: 'ALTERNAR' },
    despues: { items: ['a'], abierto: true },
  },
]

test.each(casos)('$nombre', ({ antes, evento, despues }) => {
  expect(reducer(antes, evento)).toEqual(despues)
})

La tabla aporta tres cosas que el test suelto no aporta. La primera es densidad: veinte transiciones caben en una pantalla y se leen como una especificación, no como código. La segunda es que hace visible la ausencia, que es la información más difícil de obtener en testing; cuando las filas están ordenadas por estado y por evento, los huecos de la matriz saltan a la vista y cada hueco es una pregunta legítima que alguien tiene que responder. La tercera es que separa los datos de la mecánica, de modo que añadir un caso cuesta cuatro líneas de datos y cero líneas de lógica, lo que baja la barrera para que un compañero documente el escenario raro que acaba de descubrir en producción.

flowchart TD
M[Matriz estados por eventos] --> F1[Fila: estado inicial]
M --> F2[Columna: evento posible]
F1 --> C[Celda: estado esperado]
F2 --> C
C --> A[Caso cubierto por una fila de la tabla]
C --> H[Hueco sin caso: transicion sin especificar]
H --> P[Decision explicita: ignorar, prohibir o definir]
style M fill:#cba6f7,color:#11111b
style A fill:#a6e3a1,color:#11111b
style H fill:#f38ba8,color:#11111b

Sobre los huecos conviene ser explícito, porque ahí vive la clase de error más cara. Un evento que llega en un estado donde no tiene sentido no es un caso inexistente: es un caso que ocurrirá tarde o temprano por una condición de carrera, un doble clic o una respuesta tardía. La tabla obliga a decidir de antemano qué debe pasar entonces —devolver el estado sin cambios, registrar el suceso, fallar de forma ruidosa— y a escribir esa decisión como una fila más, en lugar de dejar que el comportamiento lo determine por accidente la rama por defecto del switch.

⚠️
No reimplementes el reducer dentro del test

Existe un antipatrón que anula el valor del test entero y que es tentador precisamente cuando el estado esperado es largo de escribir: calcular el resultado esperado en el propio test con la misma fórmula que usa la implementación. Si el test dice que el total esperado es la suma de los precios por las cantidades, y el reducer calcula el total como la suma de los precios por las cantidades, el test no comprueba nada: comprueba que dos copias del mismo error coinciden. La aserción tiene que ser un valor literal escrito a mano por una persona que ha razonado el resultado, no una expresión derivada. Si escribir el literal duele porque el estado es enorme, la señal no es que haya que calcularlo, sino que el estado tiene demasiadas cosas juntas o que hace falta una comparación parcial declarada de forma consciente.

Leer la cobertura sin dejarse engañar

La cobertura es la métrica más malinterpretada del oficio, y con los reducers conviene precisar qué mide cada variante. La cobertura de líneas cuenta qué sentencias se ejecutaron durante la suite y es prácticamente inútil como señal de calidad: un switch con diez casos puede alcanzar cifras altas ejercitando solo los caminos felices, porque cada caso es una línea y basta con tocarla una vez. La cobertura de ramas cuenta qué resultados posibles de cada decisión se recorrieron, de modo que un condicional exige que se visiten sus dos salidas y un switch exige que se visiten todos sus casos, incluida la rama por defecto. Esa es la métrica pertinente aquí, porque en un reducer cada rama es literalmente una transición y una rama no visitada es una transición sin especificar.

Dicho esto, la cobertura de ramas al cien por cien no significa que la lógica sea correcta, y la confusión entre ambas cosas produce falsa tranquilidad. Mide qué código se ejecutó, no qué se afirmó sobre él; un test que llama al reducer y no comprueba el resultado sube la cobertura exactamente igual que uno que lo comprueba con rigor. Y sobre todo, no dice nada de las transiciones que no existen en el código pero deberían existir: si olvidaste manejar un evento, no hay ninguna rama que quede sin cubrir, porque la rama no está escrita. La cobertura solo puede hablar de lo que hay, y los peores fallos de una máquina de estados son omisiones.

Contra ese doble límite hay dos remedios de naturaleza distinta y conviene conocer ambos. El primero es de tipos, no de tests: si los eventos se modelan como una unión discriminada cerrada y el switch se escribe de modo que el compilador exija exhaustividad, la omisión deja de ser un fallo silencioso en tiempo de ejecución y pasa a ser un error de compilación. Eso convierte una clase entera de errores en algo que la máquina detecta gratis, sin ningún test.

function assertNunca(x: never): never {
  throw new Error(`Evento no manejado: ${JSON.stringify(x)}`)
}

function reducerExhaustivo(estado: Estado, evento: Evento): Estado {
  switch (evento.tipo) {
    case 'ANADIR': return { ...estado, items: [...estado.items, evento.item] }
    case 'VACIAR': return { ...estado, items: [] }
    case 'ALTERNAR': return { ...estado, abierto: !estado.abierto }
    // Si manana anades un evento y olvidas su caso, esta linea deja de compilar
    default: return assertNunca(evento)
  }
}

El segundo remedio ataca el otro flanco, el de las aserciones perezosas, y se llama testing de mutación. La idea es dejar que una herramienta introduzca cambios pequeños y deliberados en tu código —invertir una comparación, cambiar un más por un menos, sustituir un valor por su vecino— y ejecutar la suite tras cada mutación. Si la suite sigue verde con el código mutado, hay una mutación superviviente y eso demuestra, sin ambigüedad, que ninguna aserción observaba esa línea de verdad. Es una métrica mucho más honesta que la cobertura, porque no pregunta si el código se ejecutó sino si alguien se habría dado cuenta de que estaba mal.

El reducer es barato de testear porque ya pagaste el precio al escribirlo

Merece la pena detenerse en por qué exactamente este test resulta tan sospechosamente fácil, porque la respuesta reordena la manera de mirar toda la arquitectura. Un reducer no es fácil de testear por una virtud del testing, sino porque su forma canónica ya pagó por adelantado, en tiempo de diseño, todas las facturas que normalmente se pagan en tiempo de prueba. La determinación de qué constituye un cambio válido se pagó al enumerar los eventos como un tipo cerrado. La eliminación de la asincronía se pagó al expulsar los efectos fuera de la función. La eliminación del estado oculto se pagó al recibir el estado como argumento en lugar de leerlo de una variable ambiental. La eliminación de la ambigüedad sobre qué observar se pagó al devolver el estado entero en lugar de mutar partes sueltas. Cada una de esas decisiones se tomó porque hacía el código más claro, y cada una tuvo como efecto colateral eliminar una categoría entera de dificultad de test. Esa correspondencia no es una coincidencia afortunada: es una regularidad profunda del oficio, y funciona en los dos sentidos. Un código difícil de testear te está informando de que alguna de esas facturas sigue impagada —hay un efecto donde debería haber un valor, una dependencia implícita donde debería haber un argumento, un conjunto abierto donde debería haber uno cerrado— y la reacción correcta nunca es buscar una herramienta que permita testearlo igualmente, sino preguntarse cuál es la factura pendiente. Por eso la práctica de escribir tests de transición temprano no es solo una red de seguridad: es un detector de diseño. Cuando un caso de la tabla resulta imposible de escribir sin trampas, acabas de descubrir, gratis y antes de tiempo, que tu modelo de estado tiene una costura mal cosida.

⚔️ Convierte tus transiciones en una especificación ejecutable
  1. Toma un reducer real de tu proyecto y dibuja en papel la matriz completa de sus estados posibles por sus eventos posibles, sin mirar el código.
  2. Traduce esa matriz a una tabla de casos con test.each y ejecútala. Anota cuántas filas fallan y cuántas ni siquiera sabías que existían.
  3. Rellena a conciencia los huecos de la matriz decidiendo, para cada evento imposible en un estado dado, si debe ignorarse, registrarse o fallar de forma ruidosa.
  4. Busca en tus tests existentes al menos una aserción que calcule el resultado esperado con la misma fórmula de la implementación y sustitúyela por un literal razonado a mano.
  5. Activa la cobertura de ramas, no la de líneas, y localiza la rama del switch que ningún caso visita. Escribe la fila que falta.
  6. Elimina a propósito un caso del switch y comprueba si tu suite lo detecta. Si la cobertura sigue en verde, explica por escrito por qué la métrica no podía verlo.