El TimeProvider de .NET 8 y un pequeño ajuste en el SynchronizationContext pueden ahorrar segundos en las pruebas unitarias de lógica de reintento que, de otro modo, se quedarían inactivas esperando tiempos de espera reales. Una prueba que antes tardaba siete segundos ahora termina en unas pocas decenas de milisegundos, y el cambio no supone ningún coste adicional al ejecutarse en producción.
Por qué es importante el retraso
Muchos ayudantes de reintento utilizan un retroceso exponencial (exponential backoff): esperar 1 segundo, luego 2 segundos, luego 4 segundos, y así sucesivamente. En producción, esto protege a los servicios de saturar un endpoint que está fallando. En una suite de pruebas, el mismo código llama a Task.Delay utilizando el reloj real, por lo que el hilo de la prueba realmente se queda dormido. Multiplique eso por docenas de pruebas y el pipeline de integración continua (CI) se infla durante minutos sin obtener ningún beneficio funcional. El "impuesto por espera" (sleep tax) es puro tiempo perdido.
Cómo funciona TimeProvider
.NET 8 introdujo TimeProvider, una abstracción sobre el reloj del sistema. Una clase que necesite la hora actual o deba realizar un retraso puede aceptar una instancia de TimeProvider en lugar de llamar directamente a DateTime.UtcNow o Task.Delay. En producción, se pasa TimeProvider.System, que redirige al reloj real. En una prueba, se proporciona un FakeTimeProvider. El objeto simulado (fake) permite avanzar el tiempo manualmente con Advance(TimeSpan). Internamente, Task.Delay lee el proveedor, por lo que adelantar el reloj simulado satisface instantáneamente cualquier retraso pendiente.
La idea es sencilla: sustituir el reloj real por uno controlable y luego saltar hacia adelante al punto donde el código bajo prueba habría reanudado su ejecución.
La trampa del SynchronizationContext en xUnit
El concepto funciona en una aplicación de consola, pero en xUnit causó un bloqueo (deadlock). La prueba llamó a Advance() mientras el ayudante de reintento estaba esperando un retraso. xUnit instala su propio SynchronizationContext que captura las continuaciones y las ejecuta en el hilo de la prueba. Cuando Advance() adelantó el reloj, la continuación se encoló en el thread pool en lugar de en el hilo de la prueba. El hilo de la prueba siguió adelantando el tiempo, empujando el reloj simulado más allá del momento en que debería ejecutarse el siguiente reintento. El nuevo temporizador se programó para un punto futuro que nunca se alcanzaría porque no quedaba ningún hilo para volver a mover el reloj. La prueba se quedó colgada indefinidamente.
La solución de una sola línea
Añadir una sola línea al principio de la prueba restaura el comportamiento esperado:
SynchronizationContext.SetSynchronizationContext(null);
Limpiar el contexto personalizado obliga a que las continuaciones de await se ejecuten en el thread pool, donde los callbacks del temporizador simulado pueden ejecutarse. Con ese cambio, la misma prueba pasa de siete segundos a aproximadamente 36 milisegundos.
Beneficios más amplios
Más allá de eliminar las esperas inactivas, un reloj simulado facilita las pruebas de lógica sensible a las zonas horarias. Puede verificar que una cuota diaria se reinicie a la medianoche local correcta sin esperar a que el reloj real marque las doce. El mismo enfoque funciona para cualquier código que tome decisiones basadas en la hora actual: inyecte el proveedor, evite llamadas ocultas a Thread.Sleep o Task.Delay y obtendrá pruebas rápidas y deterministas.
A qué prestar atención
- Cada retraso debe dirigirse a través del
TimeProviderinyectado. UnThread.Sleepperdido o unTask.Delaydirecto seguirán invocando al reloj real y reintroduciendo latencia. - Si una biblioteca de la que dependes llama internamente a
Task.Delaysin exponer un proveedor, no podrás controlar su temporización sin un shim más invasivo. En tales casos, el beneficio puede ser limitado. - Utilice
grepen la carpeta de pruebas para buscarTask.DelayyThread.Sleepy así detectar esperas ocultas. Refactorizar esas llamadas para que utilicen el proveedor es la única forma de mantener la eficacia del reloj simulado.
Conclusión
Sustituir el reloj del sistema por TimeProvider y desactivar el SynchronizationContext de xUnit convierte las lentas pruebas de reintento en comprobaciones casi instantáneas, liberando recursos de CI y facilitando la verificación del código dependiente del tiempo. El esfuerzo es de unas pocas líneas de código; la recompensa se mide en segundos ahorrados por cada ejecución de prueba.
