¿Cómo se testea de forma confiable código asíncrono/concurrente sin sleeps inestables?
Los sleeps fijos en los tests son una moneda al aire que desperdicia tiempo en máquinas rápidas y falla en las lentas — los relojes virtuales y el polling determinístico reemplazan la adivinanza por una garantía real.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo se testea de forma confiable código asíncrono/concurrente sin sleeps inestables?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por Qué un sleep() en un Test Es Tirar una Moneda
Un test que hace await sleep(500); expect(orderWasProcessed).toBe(true); codifica una adivinanza sobre cuánto tarda una operación asíncrona, y esa adivinanza está mal en las dos direcciones a la vez. Corrélo en una máquina rápida y ociosa, y 500ms es muchísimo más de lo necesario: hiciste la suite más lenta sin ninguna razón. Corrélo en un runner de CI cargado, una máquina bajo presión de memoria, o justo después de una pausa de garbage collection, y 500ms no alcanza: el test falla, alguien lo reintenta, pasa, y el equipo aprende a ignorar el CI en rojo en lugar de investigarlo. Ni el falso negativo ni el tiempo desperdiciado son un problema de testing en el sentido tradicional; es un problema de timing, y necesita una solución de timing, no un número más grande.
Hay dos alternativas reales y determinísticas según quién controla la demora: tiempo virtual cuando tu propio código es el que llama a delay/sleep/programa un timer, y un helper de polling determinístico cuando la demora ocurre en un lugar que no controlás — un thread pool, un round trip de red real, un broker de mensajes.
#Opción 1: Tiempo Virtual, Cuando Vos Controlás la Demora
Si el código bajo test llama a delay() por su cuenta — por ejemplo, implementando reintentos con backoff — un runtime de test que controla su propio reloj puede avanzar rápido a través de esa demora en lugar de esperar de verdad. La librería de testing de corutinas de Kotlin es un ejemplo limpio de esto porque lo hace automáticamente: cualquier llamada a delay() hecha desde una corutina que corre en el dispatcher de test avanza un reloj virtual en lugar de bloquear tiempo real.
suspend fun <T> retryWithBackoff(maxAttempts: Int,initialDelay: Duration = 100.milliseconds,block: suspend (attempt: Int) -> T,): T {var currentDelay = initialDelayrepeat(maxAttempts - 1) { attempt ->try {return block(attempt)} catch (e: IOException) {delay(currentDelay)currentDelay *= 2}}return block(maxAttempts - 1)}
import kotlinx.coroutines.test.runTestimport kotlinx.coroutines.test.currentTimeimport kotlin.time.Duration.Companion.millisecondsimport java.io.IOExceptionimport kotlin.test.assertEqualsimport org.junit.jupiter.api.Testclass RetryWithBackoffTest {@Testfun `reintenta con backoff exponencial y tiene exito en el tercer intento`() = runTest {var attempts = 0val result = retryWithBackoff(maxAttempts = 4, initialDelay = 100.milliseconds) {attempts++if (attempts < 3) throw IOException("connection reset")"ok"}assertEquals("ok", result)assertEquals(3, attempts)// Se dispararon dos delays -- 100ms y luego 200ms -- pero solo en tiempo *virtual*.// El test en sí se completa en unos pocos milisegundos reales.assertEquals(300L, currentTime)}}
Nada en este test compite contra un reloj real. runTest corre la corutina sobre un scheduler que avanza automáticamente cualquier delay() en el momento en que no queda nada más para ejecutar, así que dos demoras de backoff que en producción tomarían 300ms de tiempo real se completan al instante en el test, y currentTime reporta exactamente cuánto tiempo virtual transcurrió, lo que te permite afirmar sobre el propio esquema de backoff como un resultado de primera clase. Si un cambio futuro hace que el backoff tope en 5 segundos en lugar de crecer sin límite, este mismo estilo de test lo detecta con precisión, sin que tu suite tarde realmente 5 segundos.
#Opción 2: Polling Determinístico, Cuando No Controlás la Demora
El tiempo virtual solo funciona cuando tu runtime de test controla cada reloj que toca el código bajo test. No te sirve cuando la demora es un round trip de red real, un mensaje llegando a una cola real, o un worker en segundo plano en su propio thread pool: ahí, delay() no lo está llamando código que puedas mover a un scheduler virtual. La solución no es "sleep, pero más corto": es reemplazar una espera fija por un polling acotado, chequear la condición repetidamente, en un intervalo corto, hasta un timeout generoso, y fallar rápido en el momento en que la condición se cumple en lugar de esperar siempre la duración completa.
async function waitFor(predicate: () => boolean | Promise<boolean>,{ timeoutMs = 2000, intervalMs = 20 }: { timeoutMs?: number; intervalMs?: number } = {}): Promise<void> {const deadline = Date.now() + timeoutMs;while (Date.now() < deadline) {if (await predicate()) return;await new Promise((resolve) => setTimeout(resolve, intervalMs));}throw new Error(`waitFor: condition not met within ${timeoutMs}ms`);}test('el consumidor procesa el mensaje y actualiza el read model', async () => {const readModel = new InMemoryReadModel();const consumer = startConsumer(queue, readModel);await queue.publish({ type: 'OrderPlaced', orderId: 'o-1', total: 4999 });await waitFor(() => readModel.get('o-1')?.status === 'PLACED');expect(readModel.get('o-1')?.total).toBe(4999);await consumer.stop();});
Este test todavía usa tiempo real de reloj, pero ya no está apostando con él: en una máquina rápida, waitFor retorna apenas la condición es verdadera, muchas veces dentro de un solo intervalo de polling de 20ms, sin tiempo desperdiciado. En una máquina de CI lenta y cargada, sigue haciendo polling hasta dos segundos completos antes de rendirse, lo cual es muchísimo más margen que el que daría un sleep(200) fijo. Falla solo cuando la condición genuinamente nunca se cumple dentro de un presupuesto generoso, lo cual es un bug real, no una coincidencia de scheduling.
#Comparando los Dos Enfoques
| Enfoque | Usalo cuando | Duración del test | Determinismo |
|---|---|---|---|
sleep(ms) fijo | Nunca, como primera opción | Desperdicia tiempo o no alcanza -- ambas cosas, en máquinas distintas | Ninguno -- pura suerte |
| Reloj virtual / de test | Tu código controla el delay/timer/lógica de scheduling | Instantáneo, sin importar cuán larga sea la demora real | Total -- mismo resultado en cada corrida, en cada máquina |
Polling determinístico (waitFor) | La demora está fuera de tu control (red, cola externa, thread pool real) | Rápido cuando el sistema está sano, acotado cuando no lo está | Alto -- el timeout es una red de seguridad, no una adivinanza |