ImmediateClock: saltarse la espera cuando el tiempo no importa
No todo test que atraviesa una espera está probando esa espera. Cuando el sueño es solo un peaje en el camino hacia la afirmación que de verdad interesa, `ImmediateClock` lo colapsa a cero y deja el test limpio y rápido. Esta lección precisa qué hace exactamente ese reloj —incluida la sutileza de que su instante actual sí salta al vencimiento mientras la suspensión desaparece—, delimita los casos donde su uso es legítimo, expone los tres costes ocultos que casi nadie anticipa —el orden relativo que se pierde, la prueba que se vacía y el temporizador que se convierte en un bucle desbocado— y presenta la tercera opción, el reloj no implementado, para cuando tu hipótesis es que ahí no debería haber tiempo en absoluto.
Un test de un flujo de alta largo atraviesa media docena de esperas: el retardo de cortesía antes de mostrar el indicador de carga, el rebote del campo de validación, la pausa entre reintentos. Ninguna de ellas es lo que estás probando —lo que estás probando es que al final del flujo la cuenta queda creada—, y sin embargo cada una exige su avance de reloj, su cálculo mental y su línea de ruido. ImmediateClock existe para ese caso: colapsa toda espera a cero y deja que el efecto llegue de un tirón hasta el punto que te interesa. La herramienta es cómoda y la tentación de generalizarla es fuerte, así que conviene enunciar desde ya la frontera que esta lección se dedica a trazar: el reloj inmediato no es un TestClock más rápido, es un reloj distinto que borra una dimensión, y borrar una dimensión es aceptable exactamente cuando ninguna de tus afirmaciones se apoya en ella.
- Precisar qué colapsa
ImmediateClocky qué sigue funcionando, incluida la aritmética de su instante actual. - Identificar los casos legítimos: previews, flujos de integración y esperas incidentales al margen de la aserción.
- Reconocer los tres costes ocultos: orden relativo perdido, prueba vacía y temporizador desbocado.
- Elegir entre reloj inmediato, reloj de test y reloj no implementado según la hipótesis bajo prueba.
Qué colapsa y qué no
El reloj inmediato implementa el mismo protocolo que los demás, así que el reducer no nota ninguna diferencia: pide dormir y la llamada le devuelve el control enseguida. Lo interesante es lo que ocurre por debajo.
let store = TestStore(initialState: Alta.State()) {
Alta()
} withDependencies: {
$0.continuousClock = ImmediateClock()
}
await store.send(.continuar)
await store.receive(\.validado) { $0.paso = .credenciales }
// El sueno de 800 ms del reducer no ha costado nada
Tres precisiones definen su comportamiento y las tres importan. La primera es que la suspensión desaparece pero el instante actual del reloj sí avanza hasta el vencimiento solicitado: un reducer que reste dos lecturas de now para medir cuánto tardó algo seguirá obteniendo la cifra correcta, porque la aritmética del ahora se conserva aunque la espera se haya evaporado. La segunda es que la cancelación se sigue respetando: dormir con la tarea ya cancelada lanza el error como haría cualquier reloj, así que la lógica de cancelación no se falsea. La tercera es que este reloj es el valor de preview por defecto de las rutas de reloj de la biblioteca, lo que explica por qué una vista previa no se queda tres segundos en blanco antes de dibujar sus datos falsos.
De esas tres, la que suele sorprender es la primera, y es la que salva muchos tests: colapsar la espera no equivale a congelar el tiempo. El reloj inmediato no detiene el reloj, lo teletransporta.
let clock = ImmediateClock()
let inicio = clock.now
try await clock.sleep(for: .seconds(3)) // devuelve el control enseguida
let transcurrido = clock.now - inicio // y sin embargo vale tres segundos
La distinción tiene consecuencias prácticas inmediatas. Un reducer que mide una duración para decidir algo —si la sesión lleva demasiado tiempo abierta, si conviene mostrar un aviso de lentitud— sigue funcionando bajo el reloj inmediato y sus cálculos siguen siendo correctos, así que ese test no se rompe ni miente. Lo que se pierde es únicamente la sucesión: la información sobre qué ocurrió antes y qué después cuando la respuesta dependía de las duraciones. Si en cambio necesitas un reloj que ni espere ni avance, lo que buscas es fijar el instante con una dependencia de fecha constante, que es otra herramienta y responde a otra pregunta.
El caso legítimo: la espera incidental
Hay un criterio limpio para decidir, y no depende de si el reducer duerme sino de si tu aserción menciona el tiempo. Si ninguna línea del test habla de un intervalo, un instante o un umbral, entonces el tiempo es un peaje y no un objeto de estudio.
Flujos de integración
Un test que recorre cinco pantallas para comprobar que el estado final es correcto no gana nada avanzando seis relojes por el camino. El reloj inmediato quita ruido y deja visible la única afirmación que importa.
Previews y datos de ejemplo
Una vista previa que espera de verdad es una vista previa inútil. Aquí el colapso no es una concesión: es el comportamiento correcto, porque nadie está verificando temporización en un lienzo de diseño.
Aserciones sobre el resultado
Cuando lo que compruebas es qué devuelve un efecto y no cuándo, el reloj inmediato mantiene el test enfocado. El sueño de cortesía de ochocientos milisegundos no es parte del contrato que estás fijando.
El flujo de integración es el caso que mejor ilustra el criterio, porque la alternativa se ve de inmediato: escribir el mismo test con reloj de test obligaría a intercalar seis avances cuyos números no aparecen en ninguna aserción y que habría que actualizar cada vez que alguien retocase un retardo de cortesía.
@Test
func elAltaCompletaTerminaConLaCuentaCreada() async {
let store = TestStore(initialState: Alta.State()) {
Alta()
} withDependencies: {
// El tiempo es un peaje del camino, no el objeto de estudio
$0.continuousClock = ImmediateClock()
$0.clienteAPI.crearCuenta = { _ in .demo }
}
await store.send(.correoIntroducido("ada@ejemplo.dev")) { $0.correo = "ada@ejemplo.dev" }
await store.receive(\.validado) { $0.paso = .credenciales }
await store.send(.continuarPulsado)
await store.receive(\.cuentaCreada) { $0.cuenta = .demo }
}
Conviene hacer explícita esa decisión en el propio test, con una línea de comentario que diga por qué el tiempo se colapsó. No es burocracia: el lector futuro debe poder distinguir de un vistazo entre aquí el tiempo no importa y aquí alguien tenía prisa, y esa diferencia no se lee en el código.
Tres costes que casi nadie anticipa
El primero es la pérdida del orden relativo. Dos efectos concurrentes que duermen uno y tres segundos tienen, bajo un reloj de verdad o bajo un TestClock, un orden de llegada determinado por sus duraciones. Bajo el reloj inmediato ambos vencen a la vez, y el orden en que sus acciones alcanzan el store pasa a depender de cómo el planificador de tareas resuelva el empate. Es decir: el reloj inmediato reintroduce justo el no determinismo que veníamos a eliminar, solo que ahora sin ninguna espera visible que lo delate.
// Bajo TestClock el orden esta determinado por las duraciones.
// Bajo ImmediateClock ambas vencen a la vez y el orden lo decide el planificador.
return .merge(
.run { send in try await clock.sleep(for: .seconds(1)); await send(.rapido) },
.run { send in try await clock.sleep(for: .seconds(3)); await send(.lento) }
)
El segundo coste es la prueba que se vacía. Un test de debounce con reloj inmediato pasa exactamente igual si borras el debounce entero, porque nunca llegó a observar la ventana. El síntoma es cruel por invisible: la suite sigue verde, la métrica de cobertura no se mueve y sin embargo una regla del dominio ha dejado de estar protegida. La comprobación que zanja la duda cabe en un experimento de treinta segundos: elimina la característica y ejecuta el test. Si sigue verde, el test no la estaba probando.
El tercero es operativo y muerde de verdad. Un efecto de larga vida que itera sobre un temporizador —un bucle que espera un intervalo y emite un latido— se convierte, bajo el reloj inmediato, en un bucle sin freno que produce acciones tan deprisa como la máquina permita.
// Bajo TestClock late una vez por segundo.
// Bajo ImmediateClock gira sin freno y el test se ahoga en acciones.
return .run { send in
for await _ in clock.timer(interval: .seconds(1)) {
await send(.tick)
}
}
.cancellable(id: CancelID.temporizador)
``` El test no se hace lento: se cuelga, o inunda el store con miles de acciones no reclamadas. Para cualquier efecto que repita una espera indefinidamente, el reloj inmediato es directamente la herramienta equivocada.
<Callout type="warn" title="Un temporizador bajo reloj inmediato es un bucle desbocado">
La combinación de un bucle sobre una secuencia de latidos y un reloj que nunca espera no produce un test rápido, produce un test que gira a máxima velocidad emitiendo acciones. Si tu efecto tiene la forma de esperar y repetir sin condición de salida, usa `TestClock` y cancela explícitamente, o acota el bucle con una condición del estado. Es el único caso donde el reloj inmediato no degrada la calidad de la prueba sino que impide que la prueba termine.
</Callout>
## La tercera opción y la matriz de decisión
Entre controlar el tiempo y colapsarlo hay una postura más: prohibirlo.
```swift
} withDependencies: {
// Hipotesis: esta feature resuelve sin tocar el reloj
$0.continuousClock = UnimplementedClock()
}
El reloj no implementado hace fallar el test en el instante en que alguien lo consulta, lo que lo convierte en la elección correcta cuando tu hipótesis es que ese código no debería tocar el tiempo en absoluto. Su valor es el mismo que el de los valores de test no implementados del sistema de dependencias: transforma una suposición tácita del autor en una verificación automática, y avisa cuando un cambio futuro introduce una espera donde no la había.
| Hipótesis bajo prueba | Reloj | Qué demuestra el verde |
|---|---|---|
| El umbral es exactamente N | el reparto de esperas es correcto | TestClock |
La temporización cumple el contrato |
| El resultado del flujo es correcto | la espera es incidental | ImmediateClock |
La lógica llega al estado esperado |
| Este código no debe consultar el reloj | Reloj no implementado | Nadie introdujo una espera nueva |
| Lo que se mide es la fecha, no la duración | Fecha constante | El cálculo sobre instantes es correcto |
La tabla se lee de izquierda a derecha y en ese orden: primero decides qué afirmas, después eliges la herramienta. Invertir el sentido —elegir el reloj cómodo y descubrir luego qué se puede afirmar con él— es el origen de la mayoría de los tests verdes que no protegen nada.
Vale la pena resistir la lectura fácil de esta lección, que sería usa el reloj de test siempre que puedas y el inmediato cuando tengas prisa. La lectura correcta es que cada reloj codifica una afirmación distinta sobre el sistema, y que el valor de un test verde no se mide por lo que confirma sino por el conjunto de comportamientos que excluye. Un test con reloj de test que fija un umbral por los dos lados excluye todas las implementaciones cuyo umbral difiera, aunque sea en un milisegundo: su conjunto de exclusión es enorme y por eso el verde vale mucho. El mismo test con reloj inmediato excluye únicamente las implementaciones que jamás producen el resultado: su conjunto de exclusión es diminuto y el verde vale casi nada, aunque en la pantalla los dos verdes se vean idénticos. Ese es el problema de fondo, y por eso conviene desconfiar de la métrica de cobertura y de la sensación de progreso que da una suite en verde: ambas cuentan tests, no exclusiones. Colapsar el tiempo es una decisión metodológica legítima —el experimentador que fija una variable para estudiar otra hace exactamente eso, y hace bien—, pero deja de serlo en el momento en que se olvida que fue una decisión. Un test que colapsó el tiempo sin advertirlo miente por omisión al lector futuro, que interpretará su verde como una garantía más amplia de la que es. De ahí la disciplina práctica que cierra la lección: declara en el test qué variable has fijado y por qué, y somete periódicamente tus tests al experimento de borrar la característica que dicen proteger. Un test que sobrevive a la desaparición de aquello que verifica no es un test defectuoso; es, con precisión técnica, una afirmación sobre otra cosa.
- Localiza en tu suite todos los tests que instalan un reloj inmediato. Para cada uno, responde por escrito si alguna de sus aserciones menciona tiempo. Los que respondan que sí están usando el reloj equivocado.
- Toma un test de debounce con reloj inmediato, elimina el debounce del reducer y ejecútalo. Documenta el resultado y reescríbelo con
TestClocky avance por los dos lados del umbral. - Escribe un reducer que combine dos efectos con esperas de uno y tres segundos. Testéalo primero con
TestClocky después con reloj inmediato, y describe cómo cambia la fiabilidad del orden de recepción. - Prueba a testear un temporizador con reloj inmediato en un entorno aislado y observa el resultado. Explica por qué la degradación es cualitativa y no de rendimiento.
- Instala el reloj no implementado en un test de una feature que no debe esperar. Añade después un sueño al reducer y comprueba que el test lo delata de inmediato.
- Añade a cada test con reloj inmediato una línea que declare qué variable se ha fijado, y revisa si en algún caso escribir esa frase te hace cambiar de reloj.