Controlar el tiempo y los dispatchers en los tests
Un test que espera de verdad es un test que miente sobre su coste y falla de forma intermitente. Esta lección explica el reloj virtual de runTest y por qué una espera de tres segundos se resuelve en microsegundos, la diferencia operativa entre el dispatcher estándar y el desconfinado a la hora de decidir cuándo corre cada corrutina, cómo se adelanta el reloj a mano para afirmar comportamientos temporales como el rebote o el tiempo de espera agotado, y por qué el dispatcher debe inyectarse en el container en lugar de referenciarse desde dentro.
Hay una clase de test que todo equipo acaba desactivando: el que pasa nueve de cada diez veces. Su origen casi siempre es el mismo, una espera real puesta ahí para dar tiempo a que algo termine, calibrada en la máquina de quien la escribió y desbordada en la máquina de integración continua cuando esta va cargada. La solución no consiste en afinar el número sino en abandonar la idea de esperar: en un test de corrutinas bien montado, el tiempo no transcurre, se ordena. El reloj es un contador que tú adelantas cuando quieres, las corrutinas se ejecutan en un planificador que tú controlas, y una operación con un retardo de treinta segundos se verifica en menos de un milisegundo sin haber esperado ni uno solo. Entender ese mecanismo es lo que separa una batería de tests fiable de una lotería.
- Explicar qué hace
runTestcon el tiempo y por qué un retardo largo no ralentiza el test. - Distinguir el dispatcher de test estándar del desconfinado y elegir según lo que se quiera observar.
- Adelantar el reloj virtual a mano para afirmar rebotes, reintentos y tiempos de espera agotados.
- Inyectar los dispatchers en el container para que el test decida dónde corre cada cosa.
El reloj virtual
Al abrir un bloque runTest no estás simplemente creando un scope donde poder llamar a funciones suspendidas: estás instalando un planificador con su propio reloj, desconectado del reloj del sistema. Cuando una corrutina de ese planificador solicita un retardo, no se duerme; se registra en una agenda ordenada por instante virtual y cede el control. Si el planificador se queda sin trabajo inmediato, salta el reloj hasta el siguiente instante agendado y reanuda lo que tocaba.
@Test
fun `el reintento espera tres segundos de tiempo virtual`() = runTest {
val vm = SincroViewModel(repo = RepoFalso())
vm.test(this) {
expectInitialState()
containerHost.sincronizar()
expectState { copy(estado = Sincronizando) }
// el retardo interno de tres segundos ya se consumio: el test no espero
expectState { copy(estado = Sincronizado) }
}
}
De ahí se sigue una consecuencia contraintuitiva que conviene interiorizar: la duración real de un test de corrutinas es independiente de las duraciones que aparecen en el código bajo prueba. Un tiempo de espera de sesenta segundos y uno de sesenta milisegundos cuestan lo mismo, porque ninguno de los dos se espera. La única forma de que un test así tarde de verdad es que alguna parte del código escape del planificador virtual, lo cual es precisamente la señal de diagnóstico más útil que existe: si tu test tarda, es que algo corre donde no debe.
Un test de container que consume segundos reales delata casi siempre una de tres cosas: una llamada bloqueante que no suspende, un Thread.sleep disfrazado, o una corrutina lanzada en un dispatcher real porque alguien lo escribió a mano dentro del container. Antes de subir el tiempo máximo del test, busca cuál de las tres es. Subir el tiempo máximo solo aplaza el diagnóstico y garantiza que el fallo reaparezca en el peor momento.
Estándar frente a desconfinado
El planificador virtual admite dos políticas de arranque para las corrutinas que se lanzan sobre él, y la elección entre ambas decide qué estados llegas a observar.
Dispatcher estándar
La corrutina lanzada se agenda pero no arranca hasta que cedes el control o adelantas el reloj. Modela con fidelidad el comportamiento real y te deja observar el estado antes de que el trabajo empiece.
Dispatcher desconfinado
La corrutina arranca de inmediato en el hilo que la lanzó y avanza hasta su primera suspensión real. Cómodo para tests que solo miran el resultado, y capaz de ocultar estados intermedios.
La distinción se entiende mejor pensando en qué modela cada uno. El estándar modela el caso real: en producción, lanzar una corrutina no la ejecuta de inmediato, sino que la encola para que el planificador la atienda cuando pueda. El desconfinado modela una simplificación conveniente que en producción no existe, y por eso los tests escritos con él pueden pasar describiendo una ejecución que la máquina real nunca produce.
La regla práctica es asimétrica y merece enunciarse sin matices: para un container de Orbit, prefiere el estándar. La razón es que en MVI los estados intermedios son el objeto de la prueba, y el desconfinado los atropella al ejecutar todo lo posible antes de devolverte el control. Con el desconfinado es perfectamente posible que un indicador de carga se encienda y se apague dentro de la misma invocación, sin que tu aserción llegue a verlo, y que un fallo real —el indicador que nunca se apaga— pase inadvertido.
flowchart TD
A[runTest instala reloj virtual] --> B{Dispatcher elegido}
B -->|estandar| C[La corrutina se agenda y espera]
B -->|desconfinado| D[La corrutina corre hasta suspender]
C --> E[Se observan los estados intermedios]
D --> F[Riesgo de saltarse estados]
style E fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111bAdelantar el reloj a propósito
Cuando el comportamiento bajo prueba es temporal —un rebote sobre lo que el usuario escribe, un reintento con espera creciente, un tiempo de espera agotado— el reloj virtual deja de ser una comodidad y se convierte en el instrumento principal. Adelantarlo por tramos permite afirmar no solo el desenlace sino la frontera: qué ocurre justo antes del umbral y qué ocurre justo después.
@Test
fun `el rebote descarta las pulsaciones intermedias`() = runTest {
val vm = BuscadorViewModel(repo = RepoFalso())
vm.test(this) {
expectInitialState()
containerHost.escribir("k")
containerHost.escribir("ko")
containerHost.escribir("kotlin")
expectState { copy(consulta = "k") }
expectState { copy(consulta = "ko") }
expectState { copy(consulta = "kotlin") }
advanceTimeBy(299)
expectNoItems() // aun no ha saltado el rebote
advanceTimeBy(2)
expectState { copy(buscando = true) }
}
}
Ese test afirma algo que ninguna aserción de resultado podría: que la búsqueda no se lanzó a los doscientos noventa y nueve milisegundos y sí a los trescientos uno. Comprobar ambos lados del umbral es lo que distingue un test de rebote de un test que simplemente tolera el rebote, y es la única forma de que un cambio accidental de la constante rompa algo.
Obsérvese el papel de la comprobación de cola vacía en mitad de la secuencia: no es decorativa, es la mitad negativa de la afirmación. Un test que solo comprobara que a los trescientos un milisegundos la búsqueda arrancó sería compatible con un container sin rebote alguno, porque también habría arrancado antes. La propiedad interesante —que no arrancó antes— exige afirmar la ausencia en un instante concreto, y eso solo es posible cuando el instante lo decides tú.
Cuando el interés no está en el umbral sino en el final —por ejemplo, tres reintentos con esperas crecientes— la herramienta adecuada no es adelantar una cantidad concreta sino agotar la agenda: dejar que el planificador ejecute todo lo pendiente hasta quedarse sin trabajo. Eso libera al test de conocer las duraciones internas, que son detalle de implementación, y lo deja afirmando lo que importa, que es el número de intentos y el estado final.
Un container que escribe dentro el dispatcher de entrada y salida es un container que el test no puede controlar: esa corrutina correrá en un pool real, fuera del reloj virtual, y volverás a las esperas arbitrarias. Pasa los dispatchers como dependencia —una pequeña interfaz con las tres o cuatro que uses— y en el test entrega la del planificador virtual. La misma lógica aplica al dispatcher principal de Android, que debe sustituirse antes de cada test y restaurarse al terminar, normalmente con una regla compartida.
Un único planificador para todo el árbol
La inyección de dispatchers solo rinde si es completa. Basta con que una capa intermedia —un repositorio, un mapeador, una utilidad de serialización— cambie de contexto por su cuenta para que una parte del trabajo escape del planificador virtual, y a partir de ahí el test vuelve a depender del azar aunque su superficie parezca controlada.
interface Dispatchers {
val principal: CoroutineDispatcher
val io: CoroutineDispatcher
val calculo: CoroutineDispatcher
}
// En el test, todos apuntan al mismo planificador virtual
class DispatchersDeTest(scheduler: TestCoroutineScheduler) : Dispatchers {
private val d = StandardTestDispatcher(scheduler)
override val principal = d
override val io = d
override val calculo = d
}
La clave está en que las tres propiedades compartan el mismo planificador. Si cada una construyera el suyo, tendrías tres agendas independientes, tres relojes que avanzan por separado y ninguna garantía de orden entre ellas: exactamente el no determinismo que querías eliminar, ahora escondido dentro del código de pruebas, que es el peor sitio posible para esconderlo.
El mismo cuidado merece el dispatcher principal de Android. El container no lo usa directamente, pero sí lo usan los componentes de ciclo de vida y algunas librerías, y si no se sustituye antes del test la primera llamada explota con un error sobre la falta de un bucle de mensajes. La solución convencional —una regla compartida que instala el dispatcher de prueba antes de cada test y lo restaura después— tiene además la virtud de fallar ruidosamente cuando alguien olvida aplicarla, en lugar de degradar silenciosamente la fiabilidad de la batería.
Merece un párrafo el caso de las fuentes de tiempo que no son retardos. Un container que consulta el reloj del sistema para decidir si un dato está caduco, o que genera un identificador aleatorio para una petición, sigue siendo no determinista aunque todas sus corrutinas corran en el planificador virtual, porque el no determinismo entró por otra puerta. El tratamiento es idéntico al de los dispatchers: convertir la fuente en dependencia inyectable, entregar una versión controlada en el test, y recuperar así la propiedad de que dos ejecuciones del mismo test observen exactamente lo mismo.
Queda una decisión de higiene que conviene tomar una sola vez y para todo el proyecto: prohibir por convención cualquier referencia literal a un dispatcher fuera de la capa de composición de dependencias. Es una regla que una comprobación estática puede vigilar, y su cumplimiento es la diferencia entre una batería determinista y una que lo es solo mientras nadie añada código nuevo.
La razón profunda de que los tests de código concurrente sean célebremente difíciles no es la concurrencia en sí, sino la pérdida de determinismo: un programa concurrente no tiene una ejecución, tiene un conjunto de ejecuciones posibles, y un test solo puede observar la que le tocó esa vez. Verificar bajo esas condiciones es como intentar demostrar un teorema muestreando casos: pasar mil veces no dice nada sobre la mil una. Lo que hace el planificador virtual es más radical de lo que su nombre sugiere: no acelera el tiempo, lo totaliza. Sustituye un orden parcial de eventos que compiten en hilos reales por un orden total decidido por una agenda que tú controlas, y con ello devuelve al programa la propiedad que la concurrencia le había quitado, que es tener una única ejecución observable y reproducible. Ese movimiento —cambiar una fuente de no determinismo por un parámetro explícito— es exactamente el mismo que se aplica al reloj de pared, al generador de números aleatorios y a la red, y aquí se aplica al planificador, que es la fuente de no determinismo más difícil de domesticar de las cuatro. La consecuencia es que un test de corrutinas bien montado no es un test más rápido: es un test de una categoría distinta, uno que demuestra en lugar de muestrear. Y de ahí sale la única política defendible sobre las esperas reales en una batería de pruebas: no se afinan, se eliminan. Cada espera que sobrevive es una confesión de que en ese punto el programa todavía decide por su cuenta cuándo ocurren las cosas, y todo lo que decide el programa por su cuenta es, tarde o temprano, lo que te despierta de madrugada.
- Mide cuánto tarda tu test de container más lento y localiza qué parte del código escapa del planificador virtual.
- Extrae los dispatchers a una dependencia inyectable y sustituye todas las referencias directas dentro del container.
- Reescribe un test que use una espera real para que adelante el reloj virtual, y comprueba que el tiempo total baja a milisegundos.
- Escribe un test de rebote que afirme los dos lados del umbral y verifica que falla al cambiar la constante en una unidad.
- Ejecuta un test de container con dispatcher desconfinado y con estándar, y explica qué emisión desaparece en uno de los dos casos.