wandres.dev
TESTING NO EXHAUSTIVO · y de integración

Cuando el exhaustivo estorba

La exhaustividad del `TestStore` es una virtud con un dominio de validez: mientras el sujeto del test es un reducer pequeño, exigir que declares cada mutación convierte el test en la especificación completa de una máquina de estados. En cuanto el sujeto pasa a ser un flujo que atraviesa media aplicación, la misma exigencia se vuelve en tu contra: el tamaño del test deja de fijarlo la propiedad que quieres proteger y pasa a fijarlo todo el trabajo accidental que ocurre por el camino. Esta lección disecciona esa degradación —la señal ahogada, la fragilidad ante refactores, los efectos en vuelo del árbol entero— y sitúa el umbral exacto donde el instrumento correcto deja de ser el exhaustivo.

⏱ 18 min

La exhaustividad del TestStore es una virtud con un dominio de validez, y fuera de ese dominio se convierte en su propia caricatura. Mientras el sujeto del test es un reducer —un estado pequeño, un puñado de acciones, dos o tres efectos—, exigir que declares cada mutación es exactamente lo que quieres: el resultado es la especificación íntegra de una máquina de estados que cabe en la cabeza. Pero en cuanto el sujeto pasa a ser un flujo que atraviesa media aplicación, esa misma exigencia se vuelve contra ti. El test deja de decir qué comportamiento se garantiza y pasa a transcribir todo lo que el sistema hace por dentro; su tamaño ya no lo fija la propiedad que quieres proteger, sino la cantidad de trabajo accidental que ocurre por el camino. Esta lección disecciona esa degradación antes de que la siguiente te dé la herramienta para cruzar el umbral.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el coste de un test exhaustivo crece con el trabajo del sistema y no con la propiedad verificada.
  • Reconocer los tres síntomas del test degradado: señal ahogada, fragilidad ante refactores y efectos en vuelo del árbol entero.
  • Distinguir el test que verifica comportamiento del que ratifica implementación, y ver por qué el segundo consolida errores.
  • Situar el umbral entre unidad e integración y elegir el instrumento por la pregunta que haces, no por costumbre.

La aritmética del ruido

Un send exhaustivo obliga a describir todas las mutaciones del estado alcanzable desde el reducer bajo prueba. Sobre una feature aislada eso significa dos o tres líneas. Sobre un padre que compone un destino presentado, una colección y un reducer de telemetría, significa describir el árbol entero cada vez que el usuario toca un botón, porque el árbol entero es, literalmente, el estado de ese reducer.

await store.send(\.destination.presented.detalle.guardarPulsado) {
  $0.destination?.detalle?.guardando = true
  $0.destination?.detalle?.errorValidacion = nil
  $0.telemetria.eventosEnCola = 1
  $0.borradorSucio = false
}
await store.receive(\.telemetria.enviado) {
  $0.telemetria.eventosEnCola = 0
}
await store.receive(\.destination.presented.detalle.guardado) {
  $0.destination?.detalle?.guardando = false
}
await store.receive(\.destination.presented.detalle.delegate.guardado) {
  $0.destination = nil
  $0.items[id: idFicha]?.titulo = "Nuevo"
}

De las cuatro aserciones, solo la última contiene la pregunta que el test venía a responder: al guardar en el detalle, la hoja se cierra y la fila del padre queda actualizada. Las otras tres son contabilidad interna —un indicador de carga, una cola de analítica, un flag de borrador— que nadie discutiría en una revisión de producto. El coste del test no lo fija la propiedad verificada, que es constante, sino el trabajo total que el sistema realiza mientras la produce, que crece con cada feature que compones y con cada responsabilidad transversal que añades al padre.

Conviene hacer el recuento con números, porque la intuición lo subestima. Si un flujo tiene siete pasos y cada paso mueve una media de cinco campos repartidos entre el padre y dos hijos, y además emite dos acciones de vuelta que hay que reclamar, el test exhaustivo son unas cincuenta líneas de aserción para proteger, típicamente, entre una y tres garantías. La proporción entre lo escrito y lo garantizado ronda el veinte a uno, y todo lo que sobra no es solo volumen: es superficie por la que el test puede romperse sin que nada esté roto.

Esa asimetría es lo característico. Duplicar la profundidad de la composición no duplica lo que quieres garantizar, pero sí duplica lo que estás obligado a escribir. Y como el trabajo accidental se reparte entre features de las que este test no es dueño, el precio se paga en un archivo que ni siquiera menciona a los responsables del ruido.

Ratificar el diff deja de ser verificar

El flujo de trabajo canónico del TestStore —escribir una closure aproximada, ejecutar, leer el diff y copiar la realidad dentro— es virtuoso porque descansa en una condición tácita: que puedas juzgar el diff. Cuando cabe en cuatro líneas, ratificarlo es un acto de verificación; lees que la cuenta subió a uno, compruebas contra tu modelo mental y confirmas. Cuando el diff mide cuarenta líneas repartidas por tres features, ratificarlo se convierte en transcripción mecánica, y una transcripción no verifica nada: afirma que el sistema hace lo que hace.

Aquí está el daño real, y no es la incomodidad. Un test que se actualiza copiando la salida del fallo consolida como especificación cualquier cosa que estuviera ocurriendo, incluido el error que introdujiste hace diez minutos. El mecanismo que en la unidad convierte el reducer en documentación —la máquina te dice qué hace y tú lo ratificas— en integración degenera en una máquina que sella con lacre el comportamiento actual, sea cual sea. El test pasa de ser un oráculo a ser un espejo.

Hay una prueba mental que zanja la discusión con quien defienda que ratificar siempre es aceptable: imagina que el diff que estás a punto de copiar contiene, entre sus cuarenta líneas, la que dice que el saldo quedó en cero cuando debía quedar en cien. ¿Lo verías? Si la respuesta honesta es que probablemente no, entonces ese test no puede detectar un error de saldo, y afirmar que lo cubre —porque la línea aparece en su closure— es exactamente la falsa seguridad contra la que existen los tests.

Hay un segundo daño, más silencioso. Al exigir el orden y la presencia exacta de cada acción intermedia, el test exhaustivo de integración fija la coreografía interna: cuántas acciones se emiten, en qué secuencia, con qué nombres. Nada de eso es comportamiento observable para el usuario. Un refactor honesto —fusionar dos acciones, mover la telemetría a otro sitio, cambiar el orden de dos reducers en el body sin alterar el resultado— rompe treinta tests que seguían siendo ciertos. Es una resistencia al cambio que la suite cobra sin dar seguridad a cambio, y que a medio plazo produce el peor de los desenlaces: equipos que dejan de refactorizar para no tocar los tests.

Tres síntomas de un test que dejó de proteger

Síntoma Lo que se observa Lo que revela
Señal ahogada Cuarenta líneas de aserción para una garantía de una frase La granularidad del instrumento no corresponde a la de la pregunta
Fragilidad ante refactores Un cambio interno sin efecto visible rompe muchos tests El test fija coreografía | no comportamiento
Efectos en vuelo Fallos por tareas vivas de relojes | observadores | suscripciones El test hereda el ciclo de vida de todo el árbol | no solo del sujeto
// Final de un test de integración exhaustivo: la ceremonia de apagado
await store.send(\.destination.dismiss)
await store.receive(\.observadorDeAlmacen.detenido)
await store.receive(\.telemetria.vaciada)
await store.send(\.view.desaparecio)
await clock.advance(by: .seconds(30))
await store.receive(\.banner.expirado) {
  $0.banner = nil
}

Esas seis líneas no verifican nada que le importe a nadie: son el precio de salir de la habitación. Y crecen con cada hijo que añadas al árbol, aunque el flujo bajo prueba no los toque siquiera.

El tercer síntoma merece un párrafo propio porque es el que más desconcierta. En modo exhaustivo, el TestStore falla si al terminar queda un efecto sin acabar ni cancelar. En una feature aislada eso es una salvaguarda excelente: te obliga a cancelar el temporizador que arrancaste. En un test de integración, en cambio, heredas los efectos de larga vida de todos los hijos vivos —el observador de la base de datos, la suscripción a notificaciones, el reloj del banner— y el final del test se convierte en una ceremonia de apagado que no tiene nada que ver con lo que querías comprobar. El test no falla porque el flujo esté mal; falla porque tu flujo, siendo correcto, deja la aplicación viva, que es justo lo que una aplicación hace.

⚠️
Un test frágil no es solo caro: es ruido en el canal

Una suite que se rompe con frecuencia por motivos irrelevantes entrena al equipo a leer los fallos como falsos positivos. A partir de cierta tasa, el rojo deja de significar peligro y pasa a significar tarea administrativa, y el día que un rojo sí importa recibe el mismo trato que los cien anteriores: se actualiza la aserción y se sigue. La fiabilidad de una suite no es la media de la calidad de sus tests, sino su capacidad de que un fallo signifique algo.

flowchart TD
P[Una accion del usuario en un flujo compuesto] --> M[Mutaciones en padre e hijos y en reducers transversales]
P --> E[Efectos de telemetria cache reloj y observadores]
M --> A[Lineas de asercion obligatorias]
E --> A
A --> D[Diff grande que ya no se juzga sino que se copia]
D --> R[El test ratifica la implementacion actual]
R --> F[Rompe con cada refactor sin proteger comportamiento]
style D fill:#f9e2af,color:#11111b
style F fill:#f38ba8,color:#11111b

El umbral: elegir el instrumento por la pregunta

📝
La historia de la biblioteca confirma el diagnóstico

Point-Free no añadió el modo laxo por comodidad ni al principio: llegó tarde, en 2022, y llegó primero como un tipo aparte —un store no exhaustivo hermano del exhaustivo— antes de fundirse en el TestStore como una simple propiedad. Ese recorrido es una confesión de diseño interesante: primero se pensó que eran dos instrumentos distintos para dos clases de test, y después se entendió que eran dos ajustes del mismo instrumento aplicables incluso dentro de una misma prueba. La conclusión que la biblioteca sacó por el camino largo es la que esta lección te ahorra: la severidad no es una propiedad del store, es una propiedad de la pregunta.

El criterio operativo es sorprendentemente nítido y no depende del tamaño del archivo, sino de la naturaleza de la pregunta. Si el test responde cómo se comporta esta máquina de estados —qué hace el reducer ante cada acción, en cada rama, con cada fallo de la dependencia—, el exhaustivo es el instrumento correcto y cualquier relajación es una pérdida neta. Si responde qué ocurre cuando el usuario recorre este camino —abre, edita, guarda, vuelve y ve el cambio reflejado—, el exhaustivo es un instrumento equivocado, porque para responder esa pregunta te obliga a afirmar cientos de cosas que no forman parte de ella.

Hay una prueba de diagnóstico que funciona muy bien en revisión de código: lee el mensaje de fallo del test cuando rompe y pregúntate si te dice qué se estropeó. Si dice que el campo guardando valía true y esperabas false, y con eso sabes dónde mirar, el test está bien granulado. Si dice que la línea diecisiete de un diff de cuarenta difiere y necesitas reconstruir mentalmente el flujo para interpretarla, el test está midiendo a una escala que no es la suya.

🧾

Contabilidad interna

Indicadores de carga, banderas de borrador, contadores de reintentos. Existen para que la feature funcione, no para que nadie los observe. Afirmarlos en integración es transcribir el libro mayor.

📡

Responsabilidades transversales

Telemetría, registro, caché, prefetch. No son de la feature bajo prueba y aun así aparecen en su diff. Su ruido crece cada vez que otro equipo añade un evento.

🔁

Ciclo de vida del árbol

Relojes, observadores y suscripciones de todos los hijos vivos. El test hereda su apagado aunque el flujo verificado no los haya arrancado.

🎭

Coreografía interna

Cuántas acciones se emiten y en qué orden. Nada de eso es visible para el usuario, y fijarlo convierte cualquier refactor honesto en un rojo.

Las cuatro categorías tienen algo en común que conviene explicitar antes de pasar a la herramienta: ninguna es un defecto del sistema. Una aplicación madura debe llevar contabilidad interna, debe emitir telemetría y debe mantener observadores vivos. Lo que está mal calibrado no es el sistema sino el instrumento que le hemos aplicado, y por eso la solución de la lección siguiente no consiste en cambiar el código de producción sino en declarar con precisión sobre qué se pronuncia cada test.

La exhaustividad es una propiedad del sujeto del test, no una virtud moral del equipo

Es tentador leer la exhaustividad como una escala de rigor donde más siempre es mejor y relajarla es una concesión a la pereza; esa lectura es un error de categoría y conviene desmontarlo con precisión, porque de él salen la mitad de las suites insufribles que existen. Un test exhaustivo afirma que el estado resultante es exactamente el que declaraste, y esa afirmación solo tiene valor epistémico si tú, la persona que la escribe, eres capaz de derivar ese estado desde el modelo de dominio antes de ejecutarlo. Cuando puedes hacerlo —una feature con diez campos y quince acciones—, la aserción total es conocimiento: cada línea es una predicción que arriesgaste y la máquina confirmó. Cuando no puedes —un árbol con cuarenta campos repartidos en seis features, la mitad de las cuales mantiene otro equipo—, la aserción total deja de ser una predicción y pasa a ser una copia; y una copia no puede falsar nada, porque siempre coincide con el original por construcción. Ese es el punto exacto de inflexión: la exhaustividad conserva su valor mientras el autor del test tenga un modelo del sujeto lo bastante completo como para predecirlo, y lo pierde en cuanto la única fuente del valor esperado es la ejecución misma. Visto así, el modo no exhaustivo de la lección siguiente no es una rebaja de estándares sino una corrección de escala, hermana de lo que ya hiciste al modelar: igual que el estado de una fila debe pesar lo que la fila muestra, la aserción de un test debe cubrir lo que su pregunta abarca. Y hay una consecuencia de diseño que casi nadie extrae: si tus tests de integración son insoportables, buena parte de la culpa no es del TestStore sino de la forma de tu árbol —telemetría cableada en el padre, efectos de larga vida repartidos por todas las hojas, features que mutan estado ajeno—. La incomodidad del test exhaustivo, antes de ser un problema a esquivar, es un instrumento de medida de cuánto acoplamiento accidental has acumulado, y merece una lectura antes de que la silencies.

⚔️ Mide la degradación en tu propia suite
  1. Toma el test de integración más largo que tengas y cuenta dos cifras: líneas totales de aserción y líneas que un responsable de producto reconocería como garantía. Anota el cociente.
  2. Marca en ese test cada receive cuya única razón de existir sea que el TestStore lo exige. Agrupa los que provengan de responsabilidades transversales como analítica, caché o registro.
  3. Haz un refactor sin efecto observable —fusiona dos acciones internas o cambia el orden de dos reducers equivalentes— y cuenta cuántos tests rompen. Ese número es tu impuesto de rigidez.
  4. Introduce un error real en el flujo, actualiza el test copiando el diff sin leerlo y comprueba si el error sobrevive en verde. Esa es la demostración de que la ratificación mecánica no verifica.
  5. Escribe en una frase la pregunta que ese test debería responder. Si no cabe en una frase, el test contiene varias preguntas y probablemente deba dividirse antes de relajarse.