Qué no testear: comportamiento en lugar de forma
Una suite puede fallar por defecto y por exceso, y el exceso es más difícil de diagnosticar porque se disfraza de rigor. Esta lección define el criterio operativo que separa comportamiento de detalle de implementación —qué se rompe si refactorizo sin cambiar lo que el usuario percibe— y lo aplica a un catálogo de antipatrones concretos: testear action creators triviales, congelar la forma del estado con snapshots, espiar dispatch, verificar internals de Redux Toolkit o de reselect, y perseguir cobertura como objetivo. Formula la API pública de un módulo de Redux como acciones que entran y selectores que salen, explica el daño de diseño inducido por los tests, y propone la resistencia a la refactorización y el testing de mutación como medidas honestas de calidad.
El nivel ha construido hasta aquí una escala de tests que van de lo barato y microscópico a lo caro y completo. Falta el criterio negativo, que es el que de verdad distingue a una suite madura: saber qué dejar sin probar. Una suite excesiva no es simplemente una suite grande; es una suite que vota en contra de mejorar el código, porque cada refactorización interna la rompe y esa fricción acaba desalentando el cambio. El síntoma es inconfundible y todo el mundo lo ha vivido: cambias el nombre de una acción, mueves un campo de slice o sustituyes un valor guardado por uno derivado, no rompes ninguna funcionalidad, y aun así tienes cuarenta tests en rojo. Ninguno de ellos encontró un defecto; todos describían tu implementación de ayer. El coste real no es la hora de arreglarlos: es que la próxima vez decidirás no hacer la mejora.
- Aplicar el criterio que separa comportamiento observable de detalle de implementación.
- Reconocer y eliminar los antipatrones concretos que producen suites frágiles en Redux.
- Definir la API pública de un módulo de estado: acciones que entran, selectores que salen.
- Sustituir la cobertura como métrica por la resistencia a la refactorización y el testing de mutación.
El criterio: qué se rompe si refactorizo
La definición útil de detalle de implementación no es filosófica sino operativa. Un test verifica comportamiento si solo puede fallar cuando cambia algo que un consumidor externo percibe: el usuario en la pantalla, otro módulo que consume tu API, el backend al otro lado del protocolo. Un test verifica implementación si puede fallar por un cambio interno que nadie fuera del módulo puede observar. Aplícalo como experimento mental antes de escribir cada afirmación: si renombro esto, si lo muevo, si cambio la estructura de datos manteniendo el resultado, este test se pone rojo. Si la respuesta es sí y nadie ha perdido nada, el test es pasivo, no activo.
En un módulo de Redux esa frontera se puede dibujar con precisión inusual, y esa es una de las ventajas menos comentadas de la arquitectura. Hacia dentro entran acciones; hacia fuera salen valores por selectores. Todo lo que ocurre entre esas dos puertas —la forma del estado, el número de acciones intermedias, si usas un thunk o un listener, si el total está guardado o se calcula— es interior privado.
Merece la pena insistir en que una suite puede fallar de dos maneras opuestas y que no cuestan lo mismo. Falla por defecto cuando deja lógica sin verificar, y el precio es un defecto que quizá llegue a producción; es un riesgo probabilístico, y a menudo tolerable en código de bajo impacto. Falla por exceso cuando codifica decisiones internas, y el precio se paga con certeza en cada refactorización futura, esté o no esté el defecto. El exceso es además mucho más difícil de diagnosticar, porque se disfraza de rigor: nadie escribe en una revisión de código que hay demasiados tests, mientras que todo el mundo se siente autorizado a pedir uno más.
flowchart LR A[acciones que entran] --> B[interior privado] B --> C[selectores que salen] B -.- D[forma del estado] B -.- E[acciones intermedias] B -.- F[thunk o listener] style A fill:#a6e3a1,color:#11111b style C fill:#a6e3a1,color:#11111b style B fill:#f38ba8,color:#11111b
Conviene recordar quién es el destinatario de un test, porque el criterio se vuelve obvio en cuanto se nombra. No lo escribes para el revisor de hoy ni para una métrica del panel de integración continua: lo escribes para quien toque este módulo dentro de un año, que probablemente serás tú sin acordarte de nada. A esa persona le sirve un test que le diga qué promesa no puede romper; le estorba uno que le diga cómo estaba escrito el código antes de que ella llegara.
De ese dibujo se deduce una regla práctica que resuelve la mayoría de las dudas: los tests deben entrar por la puerta de las acciones y salir por la de los selectores. Un test de reducer que afirma sobre el estado crudo es una excepción consciente y localizada —la lección primera argumentó por qué merece la pena— pero incluso ahí conviene preferir el selector cuando existe, porque el mismo caso queda entonces inmune a una futura normalización.
Hay una forma de auditar una suite en una tarde. Elige una refactorización que no cambie ningún comportamiento: renombra una acción, normaliza una colección, divide un slice en dos. Ejecútala y cuenta los tests en rojo. Cada uno es una hipótesis que tu suite tenía sobre tu implementación, y ninguno encontró un defecto. Ese número es la medida más honesta de la fragilidad de tu suite, mucho más informativa que el porcentaje de cobertura, y tiene la virtud de que se puede reducir deliberadamente reescribiendo esos tests para que entren por acciones y salgan por selectores.
El catálogo de lo que sobra
Conviene nombrar los antipatrones concretos, porque en abstracto todo el mundo está de acuerdo y en el código nadie lo aplica.
Action creators triviales
Afirmar que un creador generado por createSlice devuelve un objeto con su tipo y su carga es testear Redux Toolkit, no tu dominio.
Snapshots del estado
Congelan la forma completa del estado. Cualquier campo nuevo los rompe, y la gente aprende a actualizarlos sin leerlos.
Espiar dispatch
Afirmar que se llamó a dispatch con cierto objeto verifica el vocabulario interno, no que el sistema haga nada.
Cobertura como objetivo
Una métrica útil como diagnóstico y destructiva como meta: premia tests que ejecutan líneas sin afirmar comportamiento.
El caso del action creator merece un matiz, porque hay una excepción legítima. Si el creador tiene un prepare con lógica —genera un identificador, marca una fecha, normaliza la entrada— entonces contiene una decisión de dominio y merece test. Lo que no merece test es el creador generado sin lógica, cuyo test se limita a reescribir la definición y falla el día que renombras la acción sin que nada se haya roto.
// Sobra: reescribe la definicion y no puede detectar ningun defecto tuyo.
it('mal', () => {
expect(anadido({ id: 'p1' })).toEqual({ type: 'carrito/anadido', payload: { id: 'p1' } })
})
// Merece test: el prepare contiene una decision de dominio.
it('bien', () => {
const accion = anadidoConFecha({ id: 'p1' })
expect(accion.payload.creadoEn).toBeTypeOf('number')
expect(accion.payload.id).toBe('p1')
})
Lo mismo ocurre con los internals de las librerías. No pruebes que Immer no muta el borrador, que createSelector memoiza o que configureStore monta el middleware de serializabilidad. Ese código ya tiene su propia suite, mantenida por gente que conoce sus casos límite mejor que tú, y duplicarla añade mantenimiento sin añadir señal. La excepción vuelve a ser tuya, no de ellos: probar que tu selector concreto conserva la referencia es probar tu contrato, no la memoización de reselect.
Hay un caso donde acoplarse a la implementación es correcto, y conviene conocerlo para no confundirlo con una licencia general. Cuando vas a migrar un módulo heredado que nadie entiende, escribir tests que congelen su comportamiento actual —incluida su forma interna, si hace falta— es una red legítima: no describen lo que el sistema debe hacer, sino lo que hoy hace, para poder detectar cualquier desviación durante la reescritura. La clave es que son andamios con fecha de caducidad. Se borran cuando la migración termina y se sustituyen por tests de comportamiento; dejarlos indefinidamente es cómo nacen la mayoría de las suites frágiles que este capítulo intenta evitar.
Con RTK Query la regla se vuelve especialmente cortante, porque la superficie que la gente intenta testear es casi toda ajena. No pruebes que la caché deduplica peticiones, que los tags invalidan lo que deben ni que los hooks generados exponen las banderas de carga: eso es la librería. Lo tuyo son los transformResponse, los providesTags y invalidatesTags que tú escribiste, y sobre todo el comportamiento resultante en la interfaz, que ya cubre el test de integración de la lección anterior.
Al catálogo del exceso hay que sumar una categoría propia: los tests que fallan a veces. Una espera fija por milisegundos, un store compartido entre casos, un orden de ejecución implícito o una petición no interceptada producen rojos que no significan nada. Su daño no es el tiempo perdido en reejecutar, sino que erosionan la única propiedad que hace útil a una suite, que es que un rojo obligue a parar. En cuanto el equipo aprende que algunos rojos se ignoran, todos los rojos se vuelven negociables. Repáralos de raíz o bórralos, pero no los marques como omitidos y los dejes ahí, porque un test omitido es documentación falsa de una cobertura que no existe.
Hay una patología más grave que la fragilidad, y es cuando el código de producción se deforma para complacer a la suite: exportar internals para poder inspeccionarlos, inyectar el store como parámetro de una función que no lo necesitaba, partir un módulo cohesionado en tres para poder simular el del medio, añadir atributos de test cuando el rol accesible bastaba. Cuando un test es difícil de escribir, la respuesta correcta es preguntarse si el diseño está mal —un módulo con demasiadas responsabilidades, un efecto en el sitio equivocado— y no abrir un agujero en la encapsulación. Los tests son consumidores del código, no propietarios; si un consumidor exige acceso privilegiado que ningún otro necesita, el problema es la exigencia.
Comportamiento en lugar de forma, y cómo medirlo
La reformulación positiva del criterio es sencilla: describe lo que el sistema hace, no cómo lo tiene guardado. La diferencia se ve mejor en código que en prosa.
// Frágil: afirma la forma interna del estado y el vocabulario de acciones.
it('mal', () => {
const s = reducer(inicial, { type: 'carrito/anadir', payload: { id: 'p1' } })
expect(s.lineas.porId.p1.cantidad).toBe(1)
expect(s.lineas.ids).toEqual(['p1'])
})
// Robusto: entra por la accion publica, sale por el selector.
it('bien', () => {
const s = reducer(inicial, anadido({ id: 'p1' }))
expect(selectCantidad(s, 'p1')).toBe(1)
expect(selectTotalLineas(s)).toBe(1)
})
Los dos tests fallan ante el mismo defecto de dominio; solo el primero falla además cuando normalizas, renombras la rama o cambias el tipo de la acción. Esa diferencia —misma capacidad de detección, distinta sensibilidad al cambio interno— es toda la lección condensada. Cuando dudes entre dos afirmaciones que detectan lo mismo, elige siempre la que dependa de menos decisiones internas.
De ahí se sigue una práctica que casi nadie normaliza: borrar tests debe ser una operación tan rutinaria como escribirlos. Un test que ya no puede fallar por un motivo interesante es deuda pura —cuesta tiempo de ejecución, ocupa atención en las revisiones y añade una hipótesis congelada más— y eliminarlo no reduce la calidad de la suite, la aumenta. Si borrar te da vértigo, el testing de mutación te da el argumento objetivo: si al quitar ese test ningún mutante nuevo sobrevive, no estaba detectando nada que otro no detectase ya.
Hay un corolario que ordena las cuatro lecciones anteriores en una sola idea. Cada nivel de la escala compra su confianza pagando con una hipótesis distinta sobre lo que no cambiará: el test de reducer supone estable la firma del reducer y el vocabulario de acciones, el de selector supone estable la frontera de lectura, el de thunk supone estable el contrato HTTP, y el de integración no supone casi nada porque habla en el idioma del usuario. Esa escala de suposiciones, y no la pirámide de velocidades que se dibuja en todas partes, es el criterio con el que decidir dónde poner cada caso: cuanto más probable sea que refactorices una capa, menos tests deberían depender de su forma.
La misma regla se aplica al vocabulario con que el test nombra las cosas. Un test que localiza un elemento por un identificador técnico depende de una decisión de marcado; uno que lo localiza por su rol y su nombre accesible depende de algo que el usuario percibe y que además es un requisito de accesibilidad. Un test que afirma sobre la clase de un elemento se rompe al cambiar de sistema de estilos; uno que afirma sobre el texto visible no. En cada capa hay una versión de la misma elección, y en todas conviene resolverla igual: apoyarse en lo que es promesa y no en lo que es medio.
Queda la cuestión de cómo medir si una suite es buena, porque la cobertura no sirve para eso: mide qué líneas se ejecutaron, no qué defectos se detectarían. Un test sin una sola afirmación puede dar cobertura completa. Las dos medidas honestas son la que ya vimos —cuántos tests se rompen ante una refactorización silenciosa, y cuanto menos, mejor— y el testing de mutación, que introduce defectos artificiales en el código y comprueba cuántos sobreviven a la suite. Un mutante que sobrevive es una prueba constructiva de que hay lógica sin verificar. Es más caro de ejecutar que la cobertura y muchísimo más informativo, y en la capa de reducers y selectores, que corre en microsegundos, su coste es perfectamente asumible.
La imagen de una suite como red de seguridad es correcta pero incompleta, y lo que omite explica casi todos los errores de este nivel. Cada afirmación que escribes es también una declaración sobre el futuro: estás diciendo que este aspecto concreto del sistema debe seguir siendo verdad, y estás comprometiendo a todos los que vengan después a mantenerlo o a justificar por qué lo cambian. Cuando esa declaración recae sobre comportamiento observable —el usuario ve el total actualizado, el backend recibe esta petición, este módulo devuelve este valor— el compromiso es exactamente el que quieres: has congelado la promesa y liberado los medios, y cualquiera puede reescribir el interior entero con la certeza de que si la suite pasa, no rompió nada de lo prometido. Pero cuando la declaración recae sobre la forma interna —el estado tiene esta estructura, se despachan estas tres acciones en este orden, la implementación usa un thunk— has congelado los medios sin proteger ninguna promesa, y has convertido tu suite en un voto permanente a favor del diseño que tenías el día que la escribiste. Ahí está la asimetría que hace del exceso un problema peor que el defecto: un hueco en la cobertura te expone a un defecto que quizá nunca ocurra, mientras que un test acoplado a la implementación te cobra un peaje cada vez que intentas mejorar el código, y lo hace precisamente en los momentos en que más falta hace mejorarlo. Por eso la madurez en testing no se mide en cuántos tests escribes sino en cuántos te atreves a no escribir, y por eso la pregunta correcta antes de cada afirmación no es si esto está cubierto sino si esto seguirá siendo verdad cuando cambie de opinión sobre cómo implementarlo. Testear comportamiento y no forma es, en última instancia, la manera de que tu suite trabaje para tu yo futuro en vez de contra él.
- Ejecuta el experimento de la refactorización silenciosa: renombra una acción y normaliza una colección sin cambiar nada observable, y cuenta los tests en rojo.
- Clasifica cada uno de esos rojos en dos montones: los que describían una promesa al usuario y los que describían tu implementación. Borra o reescribe el segundo montón.
- Elimina todos los tests de action creators sin
preparey todos los snapshots de estado. Comprueba que la capacidad de detección de la suite no baja. - Reescribe tres tests de reducer para que entren por el action creator público y salgan por un selector, y confirma que sobreviven a una normalización del estado.
- Busca en tu código de producción cualquier cosa que exista solo para los tests —un export de internals, un parámetro inyectado, una bandera de entorno— y decide si el diseño real está mal.
- Ejecuta una herramienta de testing de mutación sobre tu capa de reducers y selectores. Cada mutante superviviente es una regla de dominio que creías cubierta y no lo estaba.