wandres.dev
TESTING DE REDUX · reducers y efectos

Testear selectores: memoización y datos derivados

Un selector es la frontera de lectura del store y, cuando está memoizado, tiene dos contratos distintos que un test debe separar: el valor que devuelve y la identidad referencial que conserva. Esta lección enseña a probar selectores triviales sin ceremonia, a construir fixtures de estado mínimos y alcanzables, a verificar derivaciones complejas de composición y normalización, y a auditar la memoización con recomputations, resetRecomputations y resultFunc. Explica por qué afirmar con toBe sobre la referencia es un test de comportamiento y no de implementación, cómo cazar el cache thrashing de un selector parametrizado, y por qué el selector es el único punto donde conviene testear la forma del estado, precisamente para que ningún otro test tenga que conocerla.

⏱ 17 min

Si el reducer es la frontera de escritura, el selector es la de lectura, y ese paralelismo se extiende también al testing: igual que el reducer, un selector es una función pura del estado a un valor, y por tanto se prueba con la misma economía radical —una llamada y una afirmación—. Pero hay una asimetría que cambia el diseño de la suite. Un reducer solo tiene un contrato: qué estado devuelve. Un selector memoizado tiene dos, y son independientes. El primero es semántico: el valor derivado es correcto. El segundo es referencial: ante entradas idénticas devuelve exactamente el mismo objeto, con su identidad intacta. Un selector puede cumplir el primero y violar el segundo, y en ese caso todos tus tests pasan mientras la interfaz se repinta sin motivo en cada despacho. Testear selectores bien consiste, sobre todo, en no olvidar que existe ese segundo contrato.

🎯 Al terminar esta lección sabrás
  • Separar los dos contratos de un selector memoizado: corrección del valor e identidad del resultado.
  • Construir fixtures de estado mínimos y alcanzables en lugar de literales gigantes escritos a mano.
  • Auditar la memoización con recomputations, resetRecomputations y resultFunc.
  • Detectar cache thrashing y entradas inestables antes de que lleguen a producción.

Los dos contratos y qué merece un test

Empecemos descartando lo que no hay que probar. Un selector trivial, del tipo que devuelve una rama del estado sin transformarla, no necesita test propio: no contiene lógica, y su test sería una tautología que se limita a reescribir la ruta de acceso. Lo que merece verificación es el selector que deriva —que filtra, ordena, agrupa, agrega o compone otros selectores— porque ahí sí hay una regla del dominio codificada, y esa regla puede estar mal.

Sobre esos selectores derivados pesan los dos contratos. El semántico se prueba como cualquier función pura. El referencial exige una forma de test que a mucha gente le resulta extraña la primera vez: llamar dos veces al selector con el mismo estado y afirmar que el resultado es el mismo objeto, no uno equivalente.

import { describe, it, expect } from 'vitest'
import { selectVisibles, selectTotalPorEstado } from './todosSelectors'
import type { RootState } from '../store'

const estado = {
  todos: {
    entidades: {
      a1: { id: 'a1', hecho: false, prioridad: 3 },
      b2: { id: 'b2', hecho: true, prioridad: 1 },
    },
    ids: ['a1', 'b2'],
    filtro: 'pendientes',
  },
} as RootState

it('deriva el valor correcto', () => {
  expect(selectVisibles(estado).map((t) => t.id)).toEqual(['a1'])
})

it('conserva la identidad del resultado ante el mismo estado', () => {
  expect(selectVisibles(estado)).toBe(selectVisibles(estado))
})

it('recomputa cuando cambia una entrada relevante', () => {
  const otro = { ...estado, todos: { ...estado.todos, filtro: 'hechos' } } as RootState
  expect(selectVisibles(otro)).not.toBe(selectVisibles(estado))
})
💡
La identidad referencial es comportamiento, no implementación

Cuesta aceptarlo porque suena a detalle interno, pero la estabilidad de la referencia es una promesa observable de la que depende el resto del sistema. useSelector, React.memo y la reconciliación entera comparan con igualdad estricta; un selector que devuelve arrays equivalentes pero distintos les está mintiendo, y el síntoma —una lista que se repinta entera cada vez que se toca cualquier rama del store— es un defecto de producto tan real como un total mal calculado. Afirmar con toBe no es acoplarse a reselect: es verificar el contrato que la capa de lectura ofrece a sus consumidores.

flowchart TD
A[fixture de estado] --> B[primera llamada al selector]
A --> C[segunda llamada al selector]
B --> D[valor correcto?]
C --> E[misma referencia?]
D --> F[contrato semantico]
E --> G[contrato referencial]
style F fill:#a6e3a1,color:#11111b
style G fill:#89b4fa,color:#11111b

Fixtures alcanzables y derivaciones complejas

El punto débil de casi toda suite de selectores es el fixture. Escribir a mano un RootState completo produce literales enormes que se rompen cada vez que alguien añade un campo, y que además pueden describir estados que la aplicación nunca alcanzaría —un identificador presente en ids pero ausente de entidades, por ejemplo—. Hay dos remedios y conviene combinarlos.

El primero es construir el fixture plegando acciones sobre los reducers reales, igual que en la lección anterior: el estado resultante es alcanzable por construcción. El segundo es tipar el selector con la rama mínima que necesita en lugar de con el RootState entero, de modo que el fixture también sea mínimo. Con createEntityAdapter el trabajo ya está medio hecho, porque el adaptador expone constructores de estado y selectores base que puedes componer.

import { describe, it, expect } from 'vitest'
import { setupStore } from '../store'
import { anadido, alternado } from './todosSlice'
import { selectPendientesPorPrioridad } from './todosSelectors'

const construirEstado = () => {
  const store = setupStore()
  store.dispatch(anadido({ id: 'a1', prioridad: 3 }))
  store.dispatch(anadido({ id: 'b2', prioridad: 9 }))
  store.dispatch(alternado('b2'))
  return store.getState()
}

it('ordena por prioridad descendente y excluye los hechos', () => {
  const ids = selectPendientesPorPrioridad(construirEstado()).map((t) => t.id)
  expect(ids).toEqual(['a1'])
})

En selectores compuestos —los que consumen otros selectores memoizados— aparece una decisión de granularidad. Puedes probar cada eslabón por separado o solo el selector de más arriba. La respuesta razonable es probar a fondo los eslabones que contienen reglas de dominio y probar el compuesto con un caso de extremo a extremo que verifique que la composición está bien cableada. Multiplicar tests intermedios sobre selectores que solo reenvían datos infla la suite sin añadir capacidad de detección.

🎯

Valor derivado

Filtrado, orden, agregación, unión de entidades normalizadas. Aquí vive la regla de dominio y aquí se concentran los casos.

🧬

Identidad

Dos llamadas con el mismo estado devuelven el mismo objeto. El test más corto y el que más regresiones de rendimiento caza.

📉

Recomputaciones

Cuántas veces corrió la función de resultado. Convierte la memoización en una afirmación numérica en vez de una fe.

🧷

Frontera

Colección vacía, entidad ausente, filtro sin coincidencias. El selector debe devolver algo estable, no reventar ni fabricar arrays nuevos.

Auditar la memoización con recomputations y resultFunc

Los selectores de reselect exponen una instrumentación que convierte la memoización en algo comprobable. recomputations() devuelve cuántas veces se ejecutó la función de resultado, resetRecomputations() pone el contador a cero, y resultFunc da acceso directo a la función de resultado para probar la lógica de derivación sin pasar por el estado ni por la caché.

import { describe, it, expect, beforeEach } from 'vitest'
import { selectVisibles } from './todosSelectors'

beforeEach(() => selectVisibles.resetRecomputations())

it('no recomputa cuando cambia una rama ajena', () => {
  const s1 = { todos: rama, ui: { modal: false } } as RootState
  const s2 = { todos: rama, ui: { modal: true } } as RootState
  selectVisibles(s1)
  selectVisibles(s2)
  expect(selectVisibles.recomputations()).toBe(1)
})

it('la funcion de resultado se puede probar aislada', () => {
  const salida = selectVisibles.resultFunc(
    { a1: { id: 'a1', hecho: false } },
    'pendientes',
  )
  expect(salida.map((t) => t.id)).toEqual(['a1'])
})

El primer test es el que descubre el defecto más caro y más silencioso: una entrada inestable. Si un selector de entrada construye un objeto nuevo en cada llamada, la caché nunca acierta, la función de resultado corre siempre y el contador lo delata sin ambigüedad. Es la versión ejecutable del inputStabilityCheck que Redux Toolkit ejecuta en desarrollo, con la ventaja de que falla en integración continua en lugar de imprimir un aviso que nadie lee.

El segundo caso, resultFunc, es una herramienta de doble filo que conviene usar con criterio. Aísla la derivación pura y permite probarla con entradas diminutas, lo cual es cómodo en agregaciones complejas. Pero salta la composición: un test que solo usa resultFunc no detecta que cableaste mal los selectores de entrada. Úsalo para cubrir combinatoria de casos y deja al menos un test que atraviese el selector completo desde el estado.

⚠️
El cache thrashing de un selector parametrizado

Un selector que recibe argumentos además del estado —selectPorId(estado, id)— es el candidato clásico a destruir su propia caché cuando varios consumidores lo llaman con argumentos distintos en el mismo ciclo. Con el memoizador weakMapMemoize que Redux Toolkit trae por defecto desde reselect 5 el problema está en gran medida resuelto, pero sigue vivo en código que use lruMemoize con tamaño uno o en selectores fabricados a mano. El test que lo detecta es directo: alterna dos argumentos en un bucle y afirma que las recomputaciones no crecen linealmente con las llamadas. Si crecen, la memoización es decorativa.

El selector es el único sitio donde conviene testear la forma del estado, para que ningún otro test tenga que conocerla

Hay una razón arquitectónica por la que estos tests importan mucho más de lo que su tamaño sugiere, y no tiene que ver con el rendimiento. El selector es un contrato de indirección: existe para que ningún componente, ningún thunk y ningún otro test necesiten saber si los todos viven en un array o en un diccionario normalizado, si el total está guardado o se calcula, si esa rama se llama entidades o porId. Cuando testeas selectores con seriedad estás haciendo dos cosas a la vez que parecen contradictorias y no lo son: estás fijando la forma interna del estado en el único lugar donde es legítimo conocerla, y con ello estás liberando a todo lo demás de conocerla. Esa es la operación que hace refactorizable un store grande. El día que normalices una colección, que muevas un campo de un slice a otro o que sustituyas un valor almacenado por uno derivado, los tests que deben romperse son los de los selectores afectados —porque describen precisamente ese acoplamiento— y ninguno más; si además se rompen veinte tests de componentes, es que la indirección nunca existió de verdad y tu suite estaba anclada a la forma del estado en cien sitios distintos. Por eso la suite de selectores es a la vez la más humilde y la más estratégica del nivel: es el punto donde concentras deliberadamente todo el acoplamiento a la estructura interna, para poder cambiar esa estructura mañana sin que se caiga el edificio. Y por eso el segundo contrato, el referencial, pertenece a esa misma lógica: la promesa del selector no es solo darte el dato correcto, sino dártelo de forma que el resto del sistema pueda confiar en la identidad y ahorrarse trabajo. Un selector que rompe esa promesa no está fallando en una optimización, está incumpliendo la mitad de su contrato.

⚔️ Audita la frontera de lectura de tu store
  1. Inventaría tus selectores y clasifícalos en triviales y derivados. Elimina de tu plan de tests todos los triviales sin remordimiento.
  2. Escribe para el derivado más complejo un test de valor y otro de identidad con toBe. Comprueba que el segundo falla si quitas la memoización.
  3. Sustituye tu fixture escrito a mano por uno construido plegando acciones sobre los reducers reales y observa cuántas líneas desaparecen.
  4. Añade un test de recomputaciones que cambie una rama ajena del estado y afirme que la función de resultado no volvió a correr.
  5. Introduce a propósito un selector de entrada que devuelva un objeto nuevo en cada llamada y confirma que el contador lo detecta.
  6. Cubre con resultFunc la combinatoria de un selector de agregación y deja un único test que lo atraviese entero desde el estado para verificar el cableado.