wandres.dev
TESTING DE REDUX · reducers y efectos

Testear reducers: el test más barato y más valioso

Un reducer es una función pura de estado previo y acción a estado siguiente, y esa pureza convierte su test en el más barato que existe: sin DOM, sin red, sin mocks, sin ciclo de vida, sin asincronía. Esta lección justifica por qué la suite de reducers es el suelo de toda estrategia de testing en Redux, fija la forma canónica del test —construir el estado previo, aplicar la acción, afirmar sobre el estado siguiente—, explica por qué basta probar el paso de la reducción para razonar por inducción sobre la secuencia entera, y detalla qué afirmar de verdad: transiciones, invariantes del dominio, casos frontera e inmutabilidad estructural. Cierra con el estado inicial, la reutilización del propio reducer como constructor de fixtures y el salto a las pruebas basadas en propiedades.

⏱ 17 min

Todo el aparato de Redux —el store, el middleware, los selectores, los thunks— existe para proteger y orquestar una única función diminuta: el reducer. Y esa función es pura. No lee un reloj, no toca la red, no consulta el DOM, no depende de en qué orden la llames ni de cuántas veces. Dale el mismo estado y la misma acción y devolverá siempre el mismo resultado. Esa propiedad, que parecía una restricción incómoda cuando la aprendiste, es en realidad el regalo más grande que Redux le hace a tu suite de tests: el corazón de tu lógica de dominio se puede verificar sin arrancar nada. No hay navegador, no hay servidor, no hay ciclo de vida de componente, no hay temporizadores, no hay dobles de prueba. Hay una llamada a una función y una comparación. Por eso los tests de reducers son, en relación coste-valor, los mejores tests que escribirás en tu vida, y por eso son el suelo sobre el que se apoya todo lo demás en este nivel.

🎯 Al terminar esta lección sabrás
  • Justificar por qué la pureza del reducer produce el test de menor coste y mayor densidad de verificación.
  • Escribir la forma canónica del test: estado previo, acción, estado siguiente, sin dobles de prueba.
  • Razonar por inducción sobre secuencias de acciones probando únicamente el paso de la reducción.
  • Afirmar sobre lo que importa: transiciones, invariantes del dominio, casos frontera e inmutabilidad.

La pureza como propiedad de testabilidad

Un reducer tiene la firma más simple que existe en una arquitectura de aplicación: recibe un estado y una acción, devuelve un estado. Ni siquiera necesita saber que hay un store, ni que hay React, ni que hay una red. Es una función de transición, en el sentido estricto que un teórico de autómatas le daría a esa palabra: un morfismo del producto de estados por alfabeto de entrada al conjunto de estados.

Esa naturaleza tiene una consecuencia práctica brutal en el coste del test. Testear una unidad de lógica normalmente exige construir su contexto: instanciar dependencias, inyectar dobles, controlar el tiempo, esperar promesas, limpiar entre casos. Cada una de esas necesidades introduce acoplamiento, lentitud y falsos positivos. Un reducer no exige nada de eso. El contexto completo de un test de reducer son dos valores que tú construyes a mano y un tercero que comparas.

flowchart LR
A[estado previo] --> C[reducer]
B[accion] --> C
C --> D[estado siguiente]
D --> E[afirmacion de igualdad]
style C fill:#89b4fa,color:#11111b
style E fill:#a6e3a1,color:#11111b

Hay un segundo regalo, menos obvio y más profundo. Un store real no aplica un reducer una vez, sino que pliega una secuencia entera de acciones sobre un estado inicial: la historia de la aplicación es un fold sobre el flujo de acciones, y el reducer es la función de paso de ese pliegue. La teoría del pliegue dice que si la función de paso es correcta para cualquier par de estado y acción, entonces el pliegue es correcto para cualquier secuencia, por inducción sobre su longitud. Traducido a tu suite: probando exhaustivamente el paso, obtienes garantías sobre historias que nunca escribiste como test. Ningún otro tipo de test de tu aplicación te da ese apalancamiento.

💡
El test que no necesita nada es el test que nunca se rompe por accidente

La fragilidad de una suite es casi proporcional al número de cosas que hay que montar para ejecutarla. Cada mock, cada beforeEach que limpia un singleton, cada espera asíncrona es una superficie donde el test puede fallar por razones ajenas a la lógica que verifica. Un test de reducer tiene esa superficie reducida a cero: si falla, es porque la lógica de dominio cambió. Esa señal limpia —cero falsos positivos, cero intermitencia— es lo que convierte a estos tests en los únicos que la gente sigue confiando después de dos años de vida del proyecto.

La forma canónica y qué afirmar

El test se escribe siempre igual, y esa monotonía es una virtud: construyes el estado previo, aplicas la acción, comparas con el estado esperado. Con createSlice de Redux Toolkit exportas el reducer y lo llamas directamente, sin store; las acciones se construyen con los propios action creators generados, de modo que el test se rompe si cambia el tipo de la acción.

import { describe, it, expect } from 'vitest'
import reducer, { anadido, alternado, eliminado } from './todosSlice'
import type { TodosState } from './todosSlice'

const base: TodosState = {
  entidades: { a1: { id: 'a1', texto: 'leer', hecho: false } },
  ids: ['a1'],
  filtro: 'todos',
}

describe('todosSlice', () => {
  it('devuelve el estado inicial cuando no hay estado previo', () => {
    expect(reducer(undefined, { type: 'accion/desconocida' })).toEqual({
      entidades: {}, ids: [], filtro: 'todos',
    })
  })

  it('anade un todo al final y no toca los existentes', () => {
    const siguiente = reducer(base, anadido({ id: 'b2', texto: 'dormir' }))
    expect(siguiente.ids).toEqual(['a1', 'b2'])
    expect(siguiente.entidades.a1).toBe(base.entidades.a1) // reparto estructural
  })

  it('ignora acciones que no le pertenecen', () => {
    expect(reducer(base, { type: 'otroSlice/algo' })).toBe(base)
  })
})

Fíjate en el tercer caso: comparamos con toBe, no con toEqual. Afirmar identidad referencial no es un capricho de purista; es verificar el contrato de reparto estructural del que depende toda la reactividad de la capa de lectura. Un reducer que clona ramas que no tocó produce estados iguales en valor pero distintos en identidad, y esa diferencia invisible en un toEqual es exactamente la que dispara renders inútiles en producción. Los tests de reducer son el único sitio barato donde puedes cazar esa regresión.

🔀

Transiciones

La tabla de casos: para cada acción relevante, qué le hace a cada rama del estado. Es el grueso de la suite y el que documenta el dominio.

🛡️

Invariantes

Propiedades que deben valer después de cualquier acción: sin identificadores huérfanos, contadores no negativos, ids sin duplicados.

🧊

Inmutabilidad

Que el estado previo salga intacto y que las ramas no tocadas conserven su referencia. Immer lo garantiza, tu test lo certifica.

🧪

Frontera

Lista vacía, identificador inexistente, doble aplicación de la misma acción, acción ajena. Ahí viven casi todos los defectos reales.

Sobre los casos frontera conviene ser explícito, porque son los que la gente omite y los que producen los incidentes. Eliminar una entidad que no existe, alternar un identificador ya borrado, recibir dos veces la misma acción por un doble clic o un reintento: en producción todos ocurren. Un reducer robusto los absorbe devolviendo el estado sin cambios, y ese comportamiento merece un test tanto como el camino feliz, porque es una decisión de diseño y no un accidente.

Una advertencia sobre la granularidad de las afirmaciones. Es tentador comparar el estado siguiente entero con un literal completo, y en slices pequeños funciona bien; en slices grandes convierte cada test en un espejo de la estructura y lo rompe cualquier campo nuevo. La alternativa razonable es afirmar sobre las ramas que la acción debía tocar y añadir una única afirmación de que las demás quedaron idénticas por referencia. Así el test describe la transición —lo que cambia y lo que no— en lugar de fotografiar el estado, que es justo la distinción que la última lección del nivel convertirá en criterio general.

⚠️
No dupliques la implementación en el test

El antipatrón más frecuente es escribir el estado esperado calculándolo con la misma lógica que el reducer, o afirmar sobre un toMatchSnapshot del estado entero. En el primer caso el test es una tautología que pasa incluso con el reducer roto; en el segundo, cualquier campo nuevo lo rompe sin que nada esté mal, y la gente aprende a actualizar los snapshots sin mirarlos. Escribe el estado esperado a mano, literal, como una afirmación independiente sobre el dominio. Si escribirlo a mano resulta insoportable, el aviso no es sobre tu test: tu estado es demasiado grande y probablemente esté sin normalizar.

Estado inicial, fixtures y propiedades

Dos detalles operativos elevan mucho la calidad de la suite. El primero: prueba el estado inicial pasando undefined como estado previo con una acción desconocida, tal y como hace el store al arrancar. Ese caso protege el contrato de arranque y detecta el olvido clásico de añadir un campo nuevo al initialState. Con Redux Toolkit tienes además slice.getInitialState(), que te evita duplicar el literal en el test.

El segundo: usa el propio reducer para construir fixtures complejos en lugar de escribir a mano estados profundos. Encadenar acciones desde el estado inicial produce un fixture que por construcción es alcanzable, mientras que un literal escrito a mano puede describir un estado que tu aplicación nunca podría alcanzar, y probar sobre estados imposibles es gastar esfuerzo en verificar ficción.

import { describe, it, expect } from 'vitest'
import * as fc from 'fast-check'
import reducer, { anadido, eliminado } from './todosSlice'
import slice from './todosSlice'

const plegar = (acciones: ReturnType<typeof anadido>[]) =>
  acciones.reduce(reducer, slice.getInitialState())

it('los ids nunca contienen duplicados', () => {
  fc.assert(
    fc.property(fc.array(fc.uuid()), (ids) => {
      const estado = plegar(ids.map((id) => anadido({ id, texto: 't' })))
      return new Set(estado.ids).size === estado.ids.length
    }),
  )
})
ℹ️
Los reducers de extraReducers también son tuyos

Un olvido habitual: los casos que un slice maneja mediante extraReducers —las respuestas pending, fulfilled y rejected de un createAsyncThunk, o acciones emitidas por otro slice— son reducers como los demás y se prueban igual, llamando al reducer con la acción correspondiente construida desde el propio thunk. No hace falta ejecutar nada asíncrono para verificar que pending enciende la bandera de carga o que rejected guarda el mensaje de error: esa parte es pura y pertenece a este nivel, mientras que la orquestación de la petición pertenece a la lección de thunks. Separar ambas cosas evita que un test barato se convierta en uno caro sin necesidad.

Ese salto —de casos ejemplares a propiedades cuantificadas sobre todas las secuencias posibles— es el que la pureza hace posible y trivial. Con fast-check generas historias arbitrarias de acciones y afirmas invariantes que deben valer al final de cualquiera de ellas: que ids y entidades estén sincronizados, que un contador nunca sea negativo, que aplicar dos veces una acción idempotente dé el mismo resultado que aplicarla una. Es verificación cercana a la formal a coste de test unitario, y solo es viable porque no hay efectos que simular.

El reducer es la especificación ejecutable de tu dominio, y su test es la especificación de la especificación

La forma pobre de entender estos tests es como una verificación de que un switch reparte bien los casos, una tarea mecánica que se delega a quien acaba de llegar. La forma correcta es reconocer que el reducer es el único artefacto de tu aplicación donde las reglas del negocio están escritas sin contaminación: sin latencia, sin fallos de red, sin ciclo de vida de la interfaz, sin framework. Cuando escribes que eliminar una entidad inexistente deja el estado intacto, o que un carrito no admite cantidades negativas, no estás cubriendo líneas: estás enunciando una ley del dominio en un lenguaje que la máquina puede comprobar en un milisegundo y que un humano puede leer como documentación. Toda la ceremonia que Redux te cobró —separar la intención en una acción, prohibir la mutación, exigir que el cálculo sea determinista— tenía este pago diferido: la porción de tu sistema que más caro cuesta equivocarse es también la más barata de verificar, y lo es precisamente porque aceptaste la disciplina. Esa es la asimetría que hay que interiorizar. Los tests de integración cuestan minutos de máquina y horas de mantenimiento, y te dan confianza sobre un puñado de caminos concretos. Los tests de reducer cuestan microsegundos, no se rompen jamás por causas ajenas y, gracias a la inducción sobre el pliegue, te dan garantías sobre historias infinitas que nunca escribiste. Si tu presupuesto de testing fuera de una sola línea, esa línea iría aquí; y si tu suite de reducers es pobre, ninguna cantidad de tests de interfaz por encima podrá compensar que la especificación de tu dominio no está escrita en ninguna parte donde alguien pueda comprobarla.

⚔️ Convierte tu slice en una especificación comprobable
  1. Elige el slice más complejo de tu aplicación y enumera, sin escribir código, todas las transiciones que sus acciones producen. Esa lista es el índice de tu suite.
  2. Escribe el test del estado inicial con undefined y una acción desconocida, y otro que verifique que una acción de otro slice devuelve el mismo objeto con toBe.
  3. Cubre cada transición con el estado esperado escrito a mano. Prohíbete calcularlo con la lógica del reducer y prohíbete los snapshots.
  4. Añade una afirmación de identidad referencial sobre una rama que la acción no debía tocar, y comprueba que falla si clonas esa rama a propósito.
  5. Enumera tres casos frontera reales —identificador inexistente, colección vacía, acción repetida— y decide explícitamente qué debe pasar en cada uno antes de escribir el test.
  6. Formula un invariante del dominio y verifícalo con fast-check sobre secuencias arbitrarias de acciones. Rompe el reducer a propósito y confirma que la propiedad lo detecta.