wandres.dev
TESTSTORE · testing exhaustivo

Por qué esto encuentra bugs reales

La exhaustividad no es una virtud abstracta: encuentra una familia concreta de fallos que ninguna otra prueba detecta, la de los cambios de estado no intencionados. Esta lección clasifica esa familia —mutación colateral, efecto de más, acción huérfana, orden invertido, tarea que no muere, estado que no se limpia al cerrar— explica por qué cada uno es invisible para un test convencional y para la inspección manual en el simulador, y sostiene que el valor del `TestStore` no está en confirmar lo que el programador quiso hacer sino en delatar lo que hizo sin darse cuenta.

⏱ 18 min

Casi todos los bugs de estado que llegan a producción comparten una biografía: nadie los escribió a propósito. No son errores de lógica que alguien razonó mal, sino efectos laterales de un cambio hecho en otro sitio —un campo que alguien añadió a la mutación de una acción vecina, un efecto que se disparó dos veces porque una rama nueva no cancelaba la anterior, una hoja modal que se cerró dejando dentro los datos del intento anterior—. Y comparten también su forma de esconderse: son cambios que ocurren de verdad, en el momento adecuado, sin lanzar ninguna excepción, y que sólo se manifiestan cuando el usuario hace las cosas en un orden que nadie probó. Un test convencional no puede verlos porque sólo mira lo que su autor decidió mirar, y ningún autor decide mirar aquello que no sabe que existe. Esta lección sostiene que ahí, y no en la confirmación del camino feliz, está el valor entero del TestStore: no sirve para comprobar lo que quisiste hacer, sirve para delatar lo que hiciste sin darte cuenta.

🎯 Al terminar esta lección sabrás
  • Reconocer la familia de bugs que la exhaustividad detecta y por qué son invisibles para un test parcial o para la inspección manual.
  • Clasificar los seis patrones más frecuentes: mutación colateral, efecto de más, acción huérfana, orden invertido, tarea que no muere y estado que no se limpia.
  • Entender por qué el fallo llega en el momento de editar y no en el de usar, y qué vale ese adelanto.
  • Ponderar con honestidad el coste de la exhaustividad y decidir dónde compensa pagarlo.

El bug que ningún otro test puede ver

Considera un cambio inocente. Alguien añade una analítica a la pantalla y decide, de paso, contar cuántas veces el usuario pulsa el botón de guardar. Escribe una línea dentro de la rama que ya existía.

case .guardarPulsado:
  state.guardando = true
  state.pulsaciones += 1        // linea nueva, aparentemente inofensiva
  return .run { send in
    await send(.guardado(try await api.guardar(state.borrador)))
  }

Un test convencional de esa pantalla comprueba que al pulsar guardar el indicador se enciende y que al volver la respuesta se apaga. Sigue verde. La app se comporta igual en el simulador. Y sin embargo el estado ya no es el que la prueba describía: hay un campo que se mueve y nadie lo ha declarado en ninguna parte. Hoy es un contador inofensivo; dentro de tres meses ese mismo campo participa en una comparación de Equatable que decide si una vista se redibuja, o se persiste, o viaja a una feature padre, y entonces el incremento silencioso se convierte en un redibujado por pulsación, en un dato corrupto en disco o en una animación que se dispara sola. En el TestStore esa línea no llega a mañana: la prueba se pone roja en el mismo commit, con un diff que dice + $0.pulsaciones = 1, y quien la lee tiene que decidir si ese cambio es querido y documentarlo, o no lo es y borrarlo.

Y conviene notar de dónde venía el cambio: no de un descuido, sino de una petición razonable hecha por alguien que no conocía el resto de la feature. Los bugs de estado no intencionados casi nunca nacen de la ignorancia técnica, nacen de la asimetría de información entre quien edita una rama y quien conoce las consecuencias de ese campo tres módulos más allá. Ninguna revisión de código cubre esa asimetría de forma fiable, porque el revisor lee el diff y el diff parece correcto; lo que la cubre es una descripción del comportamiento que la máquina evalúa entera cada vez.

Aquí está la asimetría que define el capítulo. Un test parcial verifica una hipótesis: «si hago esto, aquello vale tal cosa». Sólo puede fallar si la hipótesis se rompe, y las hipótesis que alguien escribió cubren, en el mejor de los casos, lo que ese alguien había imaginado. Un test exhaustivo verifica una descripción cerrada: «tras esto, el estado es exactamente este y no ha vuelto nada más». Puede fallar por cosas que su autor jamás imaginó, y ese es literalmente el conjunto de los bugs no intencionados.

Seis patrones que la exhaustividad caza

Patrón Qué ocurre en producción Por qué el test normal no lo ve
Mutación colateral un campo se mueve al pulsar donde no debía nadie afirmó nada sobre ese campo
Efecto de más dos peticiones idénticas, la segunda pisa a la primera ambas devuelven lo mismo, el resultado parece bien
Acción huérfana un efecto emite algo que el reducer ignora no hay estado que mirar, así que no hay aserción
Orden invertido la respuesta lenta sobrescribe a la rápida depende del timing, no se reproduce a voluntad
Tarea que no muere la suscripción sigue viva tras cerrar la pantalla el consumo aparece en la batería, no en la lógica
Estado sin limpiar la hoja se abre con los datos del intento anterior hay que abrirla dos veces seguidas para verlo

La columna de la derecha es la que explica por qué esta familia sobrevive a todas las demás defensas. Ninguno de los seis produce una excepción, ninguno rompe una invariante que el compilador pueda comprobar y ninguno se manifiesta de forma fiable al usar la app a mano, porque todos dependen de una secuencia concreta —abrir dos veces, escribir rápido, perder la red a mitad— que nadie ejecuta por casualidad. Son, en sentido estricto, fallos de estado y no de código: el programa hace lo que dice hacer en cada línea, y aun así el mundo que va quedando detrás no es el que debería.

Merecen un comentario los dos que más daño hacen. El efecto de más es traicionero porque su síntoma no es un valor incorrecto sino un consumo doble: dos peticiones al servidor, dos escrituras en disco, dos eventos de analítica. El resultado final es idéntico, así que ninguna aserción sobre el estado lo delata; lo delata la cola del TestStore, que se queda con una acción sin reclamar y obliga a preguntarse de dónde salió esa segunda respuesta. El estado sin limpiar es el bug clásico de las hojas modales y los formularios: la feature hija se cierra y su estado se conserva, de modo que al reabrirla aparece el borrador anterior, o el mensaje de error de la vez pasada, o el resultado de una validación caducada. En el simulador hay que abrir, cerrar y reabrir para verlo; en el TestStore aparece a la primera, porque tras la acción de cierre tienes que declarar exactamente qué queda del hijo y ahí, al escribirlo, es cuando uno descubre que queda todo.

🧨

Cambios que nadie pidió

La exhaustividad no busca errores de razonamiento: busca movimientos de estado que ocurrieron sin intención declarada.

⏱️

Fallos de timing sin timing

El orden de los efectos se fija en la prueba, así que una carrera deja de depender de la suerte para reproducirse.

🚿

Higiene de ciclo de vida

Toda tarea abierta debe cerrarse antes de terminar. Las fugas dejan de ser invisibles y pasan a ser rojas.

🗓️

Detección en el commit

El fallo llega mientras editas, con el contexto fresco, no meses después en un informe de un usuario molesto.

Un caso real: la hoja que recuerda demasiado

Vale la pena seguir uno de esos patrones hasta el final, porque enseña algo que la lista no puede enseñar: que el fallo de la prueba suele apuntar a un defecto de modelado y no a una línea mal escrita.

@ObservableState
struct State: Equatable {
  var mostrandoHoja = false
  var borrador = Borrador()      // vive en el padre, fuera de la hoja
  var error: String?
}

case .cerrarHojaPulsado:
  state.mostrandoHoja = false    // y nada mas
  return .none

Al escribir la prueba del flujo completo llega el momento de declarar el estado tras reabrir la hoja, y ahí se ve lo que en el simulador nadie mira:

await store.send(.abrirHojaPulsado) { $0.mostrandoHoja = true }
await store.send(.tituloCambio("a medias")) { $0.borrador.titulo = "a medias" }
await store.send(.cerrarHojaPulsado) { $0.mostrandoHoja = false }

await store.send(.abrirHojaPulsado) {
  $0.mostrandoHoja = true
  // y el borrador sigue diciendo "a medias": hay que escribirlo o no compila el enunciado
}

Nadie miente en ese test y sin embargo la tercera línea documenta un bug: el usuario cerró la hoja y su texto sobrevivió. La corrección no consiste en añadir un state.borrador = Borrador() en la rama de cierre —eso es tapar el síntoma y olvidarlo en la siguiente rama que cierre la hoja— sino en mover el borrador dentro del estado del hijo y modelar la presentación con @Presents, de modo que poner el opcional a nil destruya el borrador por construcción y no por acordarse. La prueba no ha encontrado una línea que falta: ha encontrado un estado mal colocado, y ha señalado el sitio exacto donde vivía la confusión.

El coste, dicho con honestidad

Sería deshonesto vender la exhaustividad como gratis. Cuesta escribir más: cada acción exige su declaración de estado, cada efecto su recepción, cada tarea su cierre. Cuesta mantener: un cambio deliberado de comportamiento rompe varias pruebas a la vez y hay que actualizarlas todas. Y produce un tipo particular de fatiga, la de ver rojo por cosas que sabes que están bien —el contador de analítica que sí querías, el campo nuevo que sí debía moverse—, con la tentación consiguiente de bajar la exigencia por sistema.

// El fallo util y el fallo molesto se ven igual; solo tu sabes cual es cual
await store.send(.guardarPulsado) {
  $0.guardando = true
  $0.pulsaciones = 1      // ¿lo añades porque lo querias o porque el test se queja?
}
💡
Distingue el rojo que enseña del rojo que sólo molesta

Ante un fallo, hazte siempre la misma pregunta antes de tocar nada: ¿la línea que el diff señala describe algo que yo quería que ocurriera? Si la respuesta es sí, la corrección es añadir la aserción y quedarte con una especificación más completa que hace un minuto. Si es no, acabas de cobrar el precio de la ceremonia de golpe. Lo que nunca hay que hacer es responder sin mirar, porque los dos casos se presentan exactamente igual en la consola y sólo el juicio los separa.

Hay además un coste que casi nunca se menciona y que es el más real de todos: la exhaustividad hace visible cuánto acoplamiento tiene tu feature. Una prueba que se rompe entera cada vez que tocas un campo no está siendo severa, está diciéndote que ese campo participa en demasiadas transiciones; un flujo que exige veinte líneas de aserción por paso está confesando que su estado mezcla cosas que deberían vivir separadas. Leído así, el dolor de mantener la prueba no es un impuesto sobre el testing sino una señal de diseño llegando por el único canal que no se puede ignorar, y la reacción productiva no es relajar la aserción sino partir el estado.

La respuesta a esa tentación no es doctrinal sino económica, y conviene formularla como una comparación de costes: escribir una línea de aserción de más cuesta segundos; encontrar en producción un bug de estado no intencionado cuesta, en el mejor de los casos, una tarde de reproducción a ciegas y, en el peor, datos corruptos en los dispositivos de los usuarios. La exhaustividad traslada trabajo desde el momento en que es carísimo —el diagnóstico a posteriori, sin contexto y con presión— al momento en que es baratísimo: ahora, con el código delante y la intención todavía fresca en la cabeza. Ese traslado es todo el negocio.

flowchart TD
C[Cambio en el reducer] --> Q{Fue intencionado}
Q -->|si| D[Actualizas la asercion y queda documentado]
Q -->|no| B[Bug detectado en el commit]
D --> V[Verde con la especificacion al dia]
B --> F[Lo borras antes de que llegue a produccion]
style V fill:#a6e3a1,color:#11111b
style B fill:#f9e2af,color:#11111b
⚠️
El peligro no es el test estricto, es el test relajado por costumbre

Cada vez que resuelves un fallo con exhaustivity = .off, con skipReceivedActions o con una aserción calculada desde la propia lógica, la prueba deja de vigilar exactamente aquello que iba a delatarte. Ninguna de esas herramientas es mala; lo malo es usarlas como analgésico. La señal de alarma es fácil de reconocer: si tu razón para relajar la prueba es que se queja mucho y no que su objeto sea otro, lo que estás comprando no es velocidad, es ignorancia con retraso.

Un test sólo protege de aquello sobre lo que puede fallar

Hay una proposición sencilla, casi tautológica, que ordena todo este nivel: una prueba únicamente protege contra los comportamientos capaces de ponerla en rojo. Todo lo demás —todo aquello que puede cambiar sin alterar el veredicto— queda estructuralmente fuera de su alcance, y la extensión de esa zona muda es la verdadera medida de la calidad de una suite, mucho más que el porcentaje de líneas ejecutadas. La cobertura, de hecho, mide casi lo contrario de lo que la gente cree: dice qué código corrió durante las pruebas, no qué comportamientos quedaron fijados; se puede recorrer el cien por cien de un reducer sin afirmar una sola cosa sobre él. La exhaustividad ataca justamente esa zona muda y la reduce, por defecto y sin esfuerzo del autor, a lo que uno renuncia a vigilar de forma explícita. Y de ahí se sigue el rasgo que la hace insustituible: es la única técnica que puede detectar fallos que nadie anticipó, porque no pregunta si se cumple la hipótesis que alguien escribió, sino si el mundo entero quedó como se dijo que quedaría. Todo lo que un programador sabe que puede romperse acabará, con suficiente disciplina, teniendo su prueba; el problema del software nunca ha sido ese, sino lo que el programador no sabe. Los bugs caros no viven en el terreno de lo previsto mal, viven en el de lo no pensado en absoluto: la interacción entre dos cambios hechos por dos personas distintas en dos semanas distintas, cada uno correcto por separado. Contra eso no hay más defensa que una descripción cerrada que se evalúe entera en cada compilación, porque sólo un enunciado que afirma «y nada más ocurre» puede romperse cuando ocurre algo que a nadie se le pasó por la cabeza. El precio de esa cláusula es la ceremonia que llevas pagando desde el primer nivel; lo que compra es la única forma de seguridad que no depende de la imaginación de quien escribe la prueba.

⚔️ Provoca los seis bugs y compruébalos
  1. Añade a una rama existente de tu reducer una mutación colateral inocente —un contador, una marca de tiempo— y verifica que tu prueba exhaustiva la delata mientras tus pruebas antiguas siguen verdes.
  2. Duplica el efecto de una acción para que se dispare dos veces. Comprueba que el estado final es idéntico y que aun así el TestStore falla por la acción sobrante en la cola.
  3. Haz que dos efectos concurrentes escriban en el mismo campo y recíbelos en el orden equivocado. Anota qué te enseña el fallo sobre la carrera que tenías escondida.
  4. Abre una hoja modal, ciérrala y vuelve a abrirla dentro de la misma prueba, afirmando el estado del hijo tras el cierre. Si sobrevive algo del primer intento, acabas de encontrar el bug más común de la navegación.
  5. Arranca una suscripción de larga vida y termina el test sin cerrarla; luego busca en tu app real una pantalla con ese mismo patrón y comprueba si alguien se acordó de cancelarla al desaparecer.