wandres.dev
TESTEAR EFECTOS · relojes y tiempo

El tiempo como dependencia: por qué un test no espera nunca

Un test que espera de verdad no es solo lento: ha delegado parte de su veredicto en el planificador del sistema operativo. Esta lección diagnostica las tres patologías de la espera real —latencia acumulada, inestabilidad y prueba tautológica—, reconstruye el protocolo `Clock` de Swift como la firma que convierte la espera en un valor sustituible, separa el tiempo de pared del tiempo lógico que simula un reloj de test, y fija el criterio para elegir entre `continuousClock`, `suspendingClock`, `TestClock` e `ImmediateClock`. La tesis es que la verificabilidad de la asincronía no se añade escribiendo mejores tests, sino declarando el paso del tiempo como lo que siempre fue: una entrada del sistema.

⏱ 18 min

Un test que tarda dos segundos no es simplemente un test lento: es un test que ha delegado parte de su veredicto en el planificador del sistema operativo. Cuando el reducer duerme de verdad, el resultado depende de la carga de la máquina, de la política de expropiación y del margen que su autor eligió por tanteo; y cuando ese margen se agota en la máquina de integración continua, la reacción habitual no es investigar sino subir la espera, con lo que la suite se degrada un poco más en cada iteración. La salida no consiste en escribir esperas más cuidadosas, sino en dejar de esperar: reconocer que cuánto tarda algo es una entrada del sistema tan externa como la red, declararla y sustituirla. Esta lección construye el argumento completo —la patología, la firma que la cura y el vocabulario de relojes que TCA hereda de la biblioteca estándar— para que las cuatro siguientes puedan dedicarse a explotarlo sin volver a justificarlo.

🎯 Al terminar esta lección sabrás
  • Diagnosticar las tres patologías de la espera real: latencia acumulada, inestabilidad y prueba tautológica.
  • Reconstruir el protocolo Clock de Swift y explicar qué convierte a la espera en un valor sustituible.
  • Distinguir tiempo de pared de tiempo lógico y describir qué simula exactamente un reloj de test.
  • Elegir entre continuousClock, suspendingClock, TestClock e ImmediateClock con un criterio explícito.

Tres patologías de una espera real

El antipatrón es tan común que casi no se ve. Un reducer con debounce de 300 ms; un test que dispara la acción, duerme un poco más y comprueba el resultado.

// El antipatron: el test paga la latencia del reducer
func testBusqueda() async throws {
  store.send(.consultaCambiada("swift"))
  try await Task.sleep(for: .milliseconds(350))
  XCTAssertEqual(store.state.resultados, [.demo])
}

La primera patología es aritmética. Trescientos cincuenta milisegundos por test parecen inofensivos hasta que se multiplican: doscientos tests con esperas medias de medio segundo son cien segundos de suite, y una suite de cien segundos deja de ejecutarse en cada guardado y pasa a ejecutarse antes de abrir la petición, que es justo cuando ya no sirve para dirigir el diseño. El coste real no es el tiempo perdido, es la retroalimentación perdida.

La segunda es estadística. El número 350 es una apuesta sobre el comportamiento del planificador: cincuenta milisegundos de margen sobre un umbral de trescientos. Ese margen aguanta en un portátil ocioso y se evapora en un contenedor de integración continua con cuatro trabajos compitiendo por dos núcleos. Cuando falla, el test no señala un defecto del código sino una fluctuación del entorno, y el equipo aprende exactamente la lección equivocada: que los tests rojos a veces no significan nada. Un solo test intermitente basta para envenenar la autoridad de toda la suite.

Y esa inestabilidad tiene una dinámica propia que conviene reconocer porque siempre es la misma. El test falla una vez, alguien sube la espera de trescientos cincuenta a quinientos, vuelve a pasar; meses después falla otra vez y alguien lo marca como intermitente y lo excluye de la ejecución. Cada paso de ese trinquete es individualmente razonable y el resultado agregado es una suite más lenta que además ha ido perdiendo justo los tests de la parte más delicada del sistema, porque los tests asíncronos son los primeros en caer y los que más falta hacían.

La tercera es epistemológica y es la más grave. Ese test no comprueba que el umbral sea de trescientos milisegundos: comprueba que, en trescientos cincuenta milisegundos de tiempo de pared, la búsqueda suele haber terminado. Pasaría igual con un umbral de doscientos, de cien o sin debounce alguno. Y hay una clase entera de afirmaciones que ni siquiera puede formular: probar que algo no ocurre exige esperar indefinidamente, así que la garantía más valiosa del debounce —que las cuatro pulsaciones intermedias no dispararon ninguna petición— queda fuera del alcance del método. Una prueba que no puede fallar cuando la regla se rompe no es una prueba débil; es una prueba vacía.

El mismo caso, con el tiempo bajo control, ocupa las mismas líneas y afirma incomparablemente más.

// El mismo caso con el reloj en la mano
await store.send(.consultaCambiada("swift")) { $0.consulta = "swift" }
await clock.advance(by: .milliseconds(299))   // aqui no debe ocurrir nada
await clock.advance(by: .milliseconds(1))     // aqui si
await store.receive(\.respuesta) { $0.resultados = [.demo] }

La diferencia no está en la velocidad, aunque el test pase de tardar un tercio de segundo a tardar microsegundos. Está en que el número trescientos ha dejado de ser un margen elegido a ojo para convertirse en la frontera exacta que el test acota por ambos lados; y en que la segunda línea, la que no va seguida de ninguna recepción, afirma precisamente el negativo que antes era inexpresable.

La firma que vuelve sustituible la espera

Desde Swift 5.7 el paso del tiempo tiene una abstracción de primera clase, y su economía es notable: tres requisitos bastan para describir todo lo que un reducer necesita saber del tiempo.

public protocol Clock<Duration>: Sendable {
  associatedtype Instant: InstantProtocol
  var now: Instant { get }
  var minimumResolution: Instant.Duration { get }
  func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws
}

Consultar el ahora, dormir hasta un instante y declarar la granularidad mínima que el reloj puede resolver. Nada más. Un reducer que solo habla con esos tres miembros se vuelve polimórfico en el tiempo: no sabe —ni puede saber— si el segundo que pide dura un segundo, dura cero o dura lo que alguien decida empujarle desde fuera. Esa ignorancia deliberada es exactamente la propiedad que buscamos, y es la misma que ya explotamos con la red: el reducer no sabe si el cliente habla con un servidor o con un diccionario en memoria.

@Reducer
struct Sesion {
  @Dependency(\.continuousClock) var clock

  // El reducer pide el paso del tiempo; no lo produce
}

Con esos tres miembros se expresa todo el vocabulario temporal que una feature necesita, incluida la medición, que es la operación que más a menudo se escapa del análisis.

let inicio = clock.now
try await clock.sleep(for: .milliseconds(300))
let transcurrido = clock.now - inicio

Merece subrayarse que Duration es un tipo de valor con aritmética exacta y resolución de attosegundos, no un número de coma flotante de segundos: sumar, multiplicar y comparar duraciones no acumula error, y eso importa en cuanto aparece una escalera de reintentos que dobla la espera en cada vuelta. La biblioteca de relojes de Point-Free añade sobre este protocolo una secuencia asíncrona de latidos periódicos, de modo que un temporizador también se expresa sin salir del vocabulario del reloj y hereda automáticamente su sustituibilidad.

Dos matices sostienen la construcción. El primero es que Clock tiene un parámetro primario, de modo que el existencial any Clock<Duration> es escribible sin fricción y el reloj puede almacenarse, pasarse y sustituirse como cualquier otro valor; la biblioteca de Point-Free añade además AnyClock para borrar el tipo cuando hace falta homogeneizar. El segundo es que la entrada por @Dependency no es decorativa: un reloj pasado por el inicializador funciona en la feature donde lo pusiste y se pierde en la primera composición, mientras que uno declarado como dependencia viaja a todos los reducers hijos y responde al bloque de sustitución del TestStore. La diferencia entre inyectar y parecer que se inyecta se cobra siempre en el mismo sitio: en la feature compuesta, que es donde vive la lógica interesante.

Tiempo de pared y tiempo lógico

Lo que ocurre al sustituir el reloj no es un truco de aceleración, sino un cambio de modelo. El tiempo de pared es continuo, monótono y ajeno; el tiempo lógico de un reloj de test es discreto, y su avance está bajo el control del experimentador.

flowchart LR
T1[Test con espera real] --> W[Dormir 300 ms de verdad]
W --> R1[El veredicto depende del planificador]
T2[Test con reloj inyectado] --> V[Avanzar 300 ms de mentira]
V --> R2[El veredicto depende solo del reducer]
style R1 fill:#f38ba8,color:#11111b
style R2 fill:#a6e3a1,color:#11111b

Un reloj de test mantiene un conjunto ordenado de suspensiones pendientes, cada una con su instante de vencimiento. Cuando pides un avance, el reloj no espera: mueve su noción de ahora y despierta, en orden de vencimiento, a todas las suspensiones que el salto ha dejado atrás. Entre dos vencimientos consecutivos no ocurre nada porque, en este modelo, nada puede ocurrir: el tiempo no es un flujo sino una lista de eventos. Es la misma técnica que emplean los simuladores de eventos discretos en redes o en logística, y trae consigo la misma propiedad: el coste computacional depende del número de eventos, no de la distancia temporal entre ellos. Verificar la caducidad de una sesión de treinta días cuesta lo mismo que verificar un rebote de treinta milisegundos.

La implementación es tan sencilla como el modelo sugiere: un actor que guarda el instante actual y una colección de suspensiones pendientes, cada una con su vencimiento y su continuación; avanzar consiste en recorrer esa colección en orden y reanudar a quien corresponda. No hay hilos, no hay temporizadores del sistema y no hay carreras, porque el único hilo de decisión es el del test. Esa ausencia de concurrencia real es lo que hace que el resultado sea reproducible ejecución tras ejecución, incluso bajo el detector de carreras de datos y en máquinas con distinto número de núcleos.

De ahí se sigue el rasgo que más se aprecia en el uso diario: el determinismo total. No hay tolerancia, no hay margen, no hay constantes ajustadas a ojo. Si el reducer libera un latido al llegar al segundo, un avance de un segundo libera exactamente un latido —ni cero por ir la máquina lenta ni dos por haberse acumulado—. Y como el avance es explícito, se vuelve posible afirmar el silencio: avanzar doscientos noventa y nueve milisegundos y comprobar que no llegó nada es una aserción legítima y barata, cuando en tiempo de pared era una imposibilidad práctica.

Cuatro relojes y las preguntas que contestan

El vocabulario es pequeño y la elección casi siempre está determinada por una sola pregunta: qué papel juega el tiempo en lo que estás construyendo o probando.

Reloj Qué hace Pregunta que contesta
continuousClock Cuenta también con el dispositivo suspendido Producción | cuentas atrás | caducidades
suspendingClock Se detiene con el dispositivo Producción | animaciones | rebotes de entrada
TestClock Solo avanza cuando lo ordenas El cuándo es lo que estoy probando
ImmediateClock Toda espera dura cero El cuándo es un estorbo del que quiero librarme
🧪

Producción: continuo o suspendido

La pregunta es si el usuario debe seguir consumiendo la espera con la pantalla bloqueada. Una sesión que caduca sí; una animación que se reanudaría de golpe al despertar, no. Elegir mal produce bugs que solo aparecen en dispositivos reales.

⏱️

Test: control o colapso

Si la aserción menciona un instante, un intervalo o un umbral, el reloj es TestClock. Si el sueño solo estorba en el camino hacia otra afirmación, es ImmediateClock. Confundirlos produce, respectivamente, tests inútiles y tests verdes que no verifican nada.

🚫

La cuarta opción: fallar si se usa

Existe además un reloj no implementado que hace fallar el test en cuanto alguien lo consulta. Es la elección correcta cuando tu hipótesis es que ese código no debe tocar el tiempo en absoluto.

Conviene notar que la elección de producción y la de test son ortogonales entre sí y se toman por motivos distintos. La primera es una decisión de producto —qué debe pasar mientras el usuario tiene el dispositivo bloqueado— y sobrevive a cualquier reescritura de la suite. La segunda es una decisión metodológica que se toma test a test, y nada impide que dos tests de la misma feature instalen relojes diferentes: uno de test para fijar el umbral del rebote y otro inmediato para recorrer el flujo largo del que ese rebote solo es una etapa. Tratarlas como una sola decisión es el origen de discusiones estériles sobre cuál es el reloj correcto de una feature.

La regla operativa cabe en una línea: elige el reloj según la hipótesis que quieres poner a prueba, no según la comodidad de escribir el test. Las tres lecciones siguientes desarrollan cada rama —el control fino, el colapso deliberado y la sustitución del resto de dependencias que acompañan al tiempo— y la última cierra con el caso más exigente, el de los efectos que no terminan solos.

La verificabilidad no se anade al final: se decide en la firma

Hay una creencia extendida y falsa según la cual la calidad de una suite depende del esfuerzo que se invierta en escribirla. Depende mucho más de una decisión anterior y aparentemente menor: qué entradas del sistema quedaron declaradas y cuáles se colaron implícitas. Una dependencia implícita no es difícil de testear, es inaccesible, porque el test no tiene ningún nombre por el que agarrarla; y el tiempo es el caso extremo de esa clase, porque no falla nunca, no lanza excepciones y no aparece en ninguna firma, así que nadie lo sospecha hasta que la suite lleva meses siendo lenta e intermitente. El diagnóstico correcto de un test que duerme no es este test está mal escrito, sino este código no admite ser interrogado sobre su temporización, y por eso ninguna cantidad de esfuerzo en la capa de test lo arregla: el defecto está una capa más abajo. Conviene además notar qué clase de afirmaciones se recuperan al declarar el reloj, porque no son las obvias. Verificar que algo ocurre siempre se pudo aproximar esperando lo bastante; lo que la espera real no puede formular es el negativo —que en esos trescientos milisegundos no salió ninguna petición, que tras cancelar no llegó ningún latido más—, y resulta que las garantías más caras de un sistema asíncrono son casi todas negativas. Cancelar, rebotar, limitar, deduplicar: todas se definen por lo que impiden. Al convertir el tiempo en un eje que el test recorre a voluntad, esas afirmaciones dejan de ser conjeturas apoyadas en un margen generoso y pasan a ser aserciones exactas, tan falsables como una suma. Ahí está el verdadero dividendo, y explica por qué esta decisión de diseño —tres miembros en un protocolo, una ruta de clave en un contenedor de dependencias— compra mucho más de lo que su tamaño sugiere.

⚔️ Audita el tiempo implicito de tu proyecto
  1. Busca en tu base de código todas las apariciones de Task.sleep, DispatchQueue.asyncAfter, Timer y Date(). Clasifícalas en dos listas: las que están dentro de un reducer o de un efecto, y las que no. La primera lista es tu deuda de verificabilidad.
  2. Toma el caso más antiguo de la primera lista y sustitúyelo por @Dependency(\.continuousClock). Justifica por escrito si correspondía el reloj continuo o el suspendido, apelando al comportamiento esperado con la pantalla bloqueada.
  3. Mide la suite antes y después de convertir tres tests con esperas reales en tests con reloj sustituido. Anota los dos números; te harán falta para justificar el resto de la migración.
  4. Escribe un test que afirme un negativo: que durante los primeros doscientos noventa y nueve milisegundos no se emitió ninguna acción. Comprueba que con esperas reales esa afirmación era inexpresable.
  5. Sube el umbral del debounce en el reducer sin tocar el test y confirma que este falla. Si no falla, el test no estaba fijando el umbral: estaba describiendo una coincidencia.
  6. Redacta en tres líneas la política del equipo sobre el tiempo: dónde puede consultarse el reloj, por qué vía y qué revisión debe rechazar automáticamente una petición que reintroduzca una espera real.