Estrategia de testing: qué exigir y dónde aflojar
Con dos regímenes disponibles, la pregunta deja de ser cómo se testea y pasa a ser dónde se pone cada uno. Esta lección construye el criterio: dos ejes —previsibilidad del resultado y carácter publicado del contrato— que deciden la severidad, un reparto concreto por tipo de código, y una defensa de la cobertura como diagnóstico y no como objetivo. Cierra con el presupuesto de la suite: una prueba también es código que alguien mantiene, y una suite bien diseñada gasta su rigidez donde compra seguridad y la ahorra donde solo compra fricción.
Tener dos regímenes de severidad convierte el testing en un problema de asignación: ya no se trata de si testear, sino de repartir un recurso escaso —la rigidez que impones a tu propio código— entre los sitios donde compra seguridad y los sitios donde solo compra fricción. Casi todas las suites que envejecen mal cometen el mismo error de asignación, y no es el que la gente supone: no fallan por testear poco, fallan por testear todo con la misma severidad, gastando en la coreografía de un flujo de siete pantallas el mismo rigor que en la regla de negocio que decide si un pago se acepta. Esta lección propone un criterio explícito para repartir, un reparto concreto por tipo de código y una forma honesta de saber si la suite protege algo o solo lo parece.
- Decidir la severidad de un test con dos ejes: si puedes predecir el resultado y si lo que verificas es contrato publicado.
- Aplicar un reparto concreto por tipo de código, del reducer hoja al flujo entre features y a la telemetría.
- Diagnosticar la suite con la pregunta de mutación en vez de con el porcentaje de cobertura.
- Presupuestar el coste de mantenimiento de los tests y reconocer cuándo un test cuesta más de lo que protege.
Dos ejes deciden la severidad
El primer eje es la previsibilidad: si eres capaz de derivar el estado resultante desde el modelo de dominio antes de ejecutar el test, la aserción exhaustiva es conocimiento y cada línea es una predicción arriesgada. Si el único modo de saber qué queda es ejecutar y copiar, la aserción exhaustiva es una copia y no puede falsar nada. Este eje ordena por tamaño del sujeto, pero no exactamente: hay reducers pequeños e impredecibles —los que orquestan tres dependencias con reintentos— y árboles medianos perfectamente predecibles.
El segundo eje es el carácter publicado de lo que verificas. Un caso de delegate, la forma del State inicial, el nombre de una acción que otra feature envía: todo eso es interfaz, y congelar una interfaz es exactamente para lo que sirven los tests. Un caso internal que existe porque el autor prefirió partir el trabajo en dos pasos es encapsulación, y congelarla es cerrarse la puerta a mejorarla. La distinción no es de visibilidad en Swift sino de compromiso: publicado es lo que otro módulo, otra feature o un futuro tú tendrá derecho a asumir.
flowchart TD Q[Que pregunta responde este test] --> U[Como se comporta esta maquina de estados] Q --> J[Que ocurre cuando el usuario recorre este camino] U --> P[Puedes predecir el estado resultante sin ejecutar] P -->|si| E[Exhaustivo campo a campo] P -->|no| M[Simplifica el modelo antes de testear] J --> L[No exhaustivo con asercion final] L --> C[Afirma contratos publicados y puntos de contacto] E --> S[Rompe cuando cambia el comportamiento] C --> S style E fill:#a6e3a1,color:#11111b style C fill:#89b4fa,color:#11111b style M fill:#f9e2af,color:#11111b
El diagrama esconde una afirmación fuerte que conviene sacar a la luz: cuando un reducer no es predecible, la respuesta correcta no es bajar la severidad del test, es arreglar el reducer. Un estado del que no puedes anticipar la transición es un estado con demasiadas responsabilidades, con invariantes implícitas o con dependencias que hacen más de lo que su nombre promete. El test exhaustivo, aquí, funciona como un detector de complejidad accidental, y renunciar a él antes de escuchar lo que dice es silenciar el instrumento en vez de atender la lectura.
El reparto, por tipo de código
| Qué estás probando | Régimen | Razón |
|---|---|---|
| Reducer hoja con ramas y validación | Exhaustivo | Es una máquina de estados predecible | cada rama es una garantía |
| Manejo de fallo de una dependencia | Exhaustivo | El camino de error casi nunca se recorre a mano | ahí viven los bugs |
| Cancelación, rebote y relojes | Exhaustivo con TestClock |
El orden temporal es el comportamiento | no un detalle |
| Invariantes del estado derivado | Exhaustivo | Son afirmaciones sobre el dominio | no sobre la implementación |
Contrato de delegate entre padre e hijo |
Exhaustivo pero solo en el receive |
Es interfaz publicada | debe romper si se renombra |
| Flujo largo entre varias features | No exhaustivo con assert final |
La pregunta es el resultado del camino | no su coreografía |
| Navegación en pila y enlaces profundos | No exhaustivo | Muchos pasos de atrezo | pocos puntos de contacto reales |
| Telemetría, registro y métricas | Ninguno o un contrato mínimo | Cambia a menudo | romper por ella es ruido puro |
| Expansión de macros y reenvíos triviales | Ninguno | Testeas al compilador | no a tu dominio |
La fila del delegate merece una lectura despacio porque es la más útil y la que más gente pasa por alto. En un test de integración laxo, recibir explícitamente la acción de delegación no contradice la laxitud: es una excepción deliberada que fija justo el punto donde dos features se comprometen. Todo lo demás en ese test puede aflojarse; ese receive, no. Y la fila de la telemetría dice lo contrario por el mismo motivo: si tus tests de flujo se rompen cada vez que alguien añade un evento de analítica, no es que el test sea estricto, es que la telemetría está cableada donde no debe —probablemente en el padre en lugar de en un reducer propio compuesto aparte.
// Exhaustivo: una rama de negocio, predecible y con consecuencias
@Test
func rechazaPagoConSaldoInsuficiente() async {
let store = TestStore(initialState: Pago.State(saldo: 10, importe: 25)) {
Pago()
}
await store.send(\.view.confirmarPulsado) {
$0.error = .saldoInsuficiente
$0.procesando = false
}
}
// No exhaustivo: un camino de usuario, con la garantía al final
@Test
func altaCompletaDejaSesionIniciada() async {
let store = TestStore(initialState: Alta.State()) {
Alta()
} withDependencies: {
$0.cuentas.crear = { _ in .demo }
}
store.exhaustivity = .off
await store.send(\.datos.correoEscrito, "a@b.c")
await store.send(\.datos.continuarPulsado)
await store.send(\.verificacion.codigoEscrito, "123456")
await store.send(\.verificacion.continuarPulsado)
await store.receive(\.verificacion.delegate.verificado)
store.assert { $0.sesion = .demo }
}
Los dos tests miden cosas incomparables y por eso se escriben distintos. El primero afirma una regla: con saldo insuficiente no se cobra. El segundo afirma un desenlace: quien completa el alta acaba con sesión. Poner el primero en modo laxo sería tirar la mitad de su valor, porque la mitad de la regla está en el procesando que también hay que fijar; poner el segundo en modo exhaustivo lo haría cuatro veces más largo sin añadir una sola garantía nueva.
Cobertura, mutación y tests que nunca fallan
El porcentaje de cobertura mide qué líneas se ejecutaron, no qué comportamientos se verificaron, y con el modo no exhaustivo esa brecha se vuelve enorme: un test que recorre siete pantallas sin afirmar nada ilumina cientos de líneas y garantiza cero. Usar la cobertura como objetivo, en una suite que mezcla regímenes, no es solo poco informativo: premia justamente los tests que menos protegen.
// Antes: la guarda que impide cobrar dos veces
guard !state.procesando else { return .none }
state.procesando = true
// Mutación deliberada: si la suite sigue verde, esa guarda no está protegida
// guard !state.procesando else { return .none }
state.procesando = true
La pregunta que sí discrimina es la de mutación: si estropeo esta línea a propósito, ¿se pone algo rojo? Invierte una condición, borra una asignación, comenta el return de un efecto. Si la suite entera sigue verde, esa línea no está protegida, por mucho que la cobertura la marque en color. Aplicada a mano sobre las diez o quince líneas que de verdad importan de una feature, esta prueba cuesta veinte minutos y produce un diagnóstico infinitamente más honesto que cualquier informe automático.
Un test tautológico nunca falla: afirma lo que la ejecución produjo, así que cambie lo que cambie sigue coincidiendo. Un test frágil falla siempre por el motivo equivocado: fija coreografía interna, así que cualquier refactor lo rompe sin que nada se haya estropeado. Ambos acaban en el mismo sitio —el equipo deja de leer los fallos—, pero se curan con remedios contrarios: al tautológico hay que añadirle una aserción que arriesgue algo; al frágil hay que quitarle las que no arriesgan nada. Diagnosticar mal cuál de los dos tienes garantiza empeorarlo.
El presupuesto de la suite
Una prueba es código que alguien mantiene, y su coste no se paga al escribirla sino en cada refactor posterior. Ese coste es proporcional a cuántas afirmaciones hace sobre detalles que van a cambiar, y su beneficio es proporcional a la probabilidad de que detecte un error real multiplicada por el daño que ese error causaría. La consecuencia práctica es que hay tests con beneficio neto negativo, y que borrarlos es una mejora, no una rendición.
El nombre es el índice
rechazaPagoConSaldoInsuficiente dice qué se garantiza; testPago2 no dice nada. La lista de nombres de una suite bien nombrada es la especificación legible del módulo.
Una garantía por test
Si el nombre necesita una conjunción, el test contiene dos preguntas y su fallo será ambiguo. Partirlo cuesta un minuto y ahorra diagnósticos.
Camino de error primero
La rama feliz se recorre a mano cada día; la de error, casi nunca. El orden de escritura debería ser el inverso al de la intuición.
Borrar es mantener
Un test que no puede fallar por un motivo real ocupa tiempo de ejecución, de lectura y de refactor. Retirarlo mejora la suite de forma medible.
Tres criterios ayudan a decidir en la práctica. El primero es la volatilidad: sobre código que cambia cada semana, la rigidez se paga cada semana; ahí conviene afirmar solo el contrato y dejar libre el interior. El segundo es el daño: un error en el cálculo de un importe cuesta dinero y confianza, uno en el orden de dos animaciones cuesta una queja; el rigor debe seguir esa curva y no la del entusiasmo. El tercero es la detectabilidad por otras vías: si un error se manifiesta al abrir la aplicación en cualquier previsualización, invertir en un test caro para él es duplicar una defensa barata. Bajo estos tres criterios, la forma canónica de una suite TCA sana suele ser una base ancha de tests exhaustivos y cortos sobre reducers hoja, una capa fina de tests de integración laxos con una aserción cada uno, y un puñado casi anecdótico de comprobaciones sobre la vista, que es lo que verá la lección siguiente.
Hay una analogía que ordena todo lo dicho en esta lección y que además explica por qué en TCA las decisiones de testing se parecen tanto a las de modelado. Un sistema de tipos no gana valor prohibiendo más cosas indiscriminadamente: gana valor prohibiendo exactamente los estados que no quieres, y pierde valor —se vuelve ceremonioso, se llena de conversiones, empuja a la gente a esquivarlo— en cuanto prohíbe cosas legítimas. Una suite de tests hace lo mismo con una diferencia de mecanismo: en vez de rechazar programas al compilar, rechaza comportamientos al ejecutar. Con esa lente, cada aserción que escribes es una prohibición, y la pregunta correcta ante cada una es la misma que ante un tipo: ¿qué programas futuros estoy prohibiendo con esto, y quería prohibirlos todos? Afirmar que al pagar el estado queda con error de saldo insuficiente prohíbe cualquier futuro que cobre de más, y ese es un futuro que quieres prohibido para siempre. Afirmar que entre pulsar y confirmar se emitieron cuatro acciones en cierto orden prohíbe todos los futuros que hagan lo mismo con tres, y ninguno de esos futuros era indeseable; has cerrado la puerta a mejoras que no tenías motivo para cerrar. De aquí sale una regla que sustituye con ventaja a cualquier objetivo de cobertura: escribe la aserción más débil que siga siendo incompatible con el error que temes. Es la misma economía que gobierna el diseño de un buen State —hacer imposibles los estados inválidos sin hacer incómodos los válidos— y la misma que gobierna una buena dependencia —exponer lo que la feature necesita sin exponer el transporte—. El testing no es una disciplina aparte que se añade al final del proyecto: es la continuación del modelado por otros medios, y una suite bien repartida es, literalmente, la parte de tu sistema de tipos que Swift no podía comprobar por ti.
- Clasifica cada test de un módulo tuyo en las nueve filas de la tabla. Si alguno no encaja en ninguna, escribe en una frase qué pregunta responde; sospecha si no puedes.
- Aplica la prueba de mutación a las diez líneas de lógica más importantes del módulo. Anota cuántas quedan verdes al estropearlas y escribe una aserción para cada una.
- Busca los tres tests que más veces se han modificado en el historial sin que la funcionalidad cambiara. Ese es tu impuesto de rigidez concentrado: reduce sus aserciones al contrato y comprueba que siguen detectando la rotura real.
- Localiza un test que no pueda fallar nunca —sin aserciones o solo con las que copian la ejecución— y decide entre darle una garantía real o borrarlo. Ambas opciones son mejores que dejarlo.
- Escribe en el README del módulo el reparto que has elegido, con dos ejemplos: uno de lo que se afirma exhaustivamente y otro de lo que se deja libre a propósito. La estrategia que no está escrita se erosiona en tres sprints.