Dispatchers de prueba: el estándar, el no confinado y la inyección
Las dos implementaciones de dispatcher que ofrece kotlinx-coroutines-test no son variantes de comodidad sino dos modelos de ejecución con semánticas opuestas: el estándar encola y no ejecuta nada hasta que se lo pides, el no confinado ejecuta con avidez sobre el hilo que llama hasta la primera suspensión real. Esta lección compara ambos con precisión, explica por qué el estándar reproduce la semántica de producción y el no confinado la falsea de una forma a veces útil y a veces catastrófica, y demuestra que ninguno de los dos sirve de nada si el código bajo prueba tiene un dispatcher codificado a fuego en lugar de recibido como dependencia.
Un dispatcher decide dos cosas: en qué hilo corre una corrutina y cuándo. Los dispatchers de prueba renuncian por completo a la primera —todo ocurre en un único hilo— para tomar el control absoluto de la segunda, y ahí es donde empieza la parte interesante. El estándar convierte el momento de la ejecución en algo que tú decides explícitamente llamando a una función; el no confinado lo elimina como problema ejecutando todo lo que pueda tan pronto como pueda. Elegir entre ambos no es una cuestión de gusto sino de qué propiedad quieres que tu test verifique, y la elección equivocada produce tests que pasan mientras el código está roto.
- Contrastar el modelo de encolado del dispatcher estándar con la ejecución ávida del no confinado.
- Justificar por qué el estándar reproduce la semántica de producción y en qué casos el no confinado la falsea peligrosamente.
- Convertir un dispatcher codificado a fuego en una dependencia inyectada sin contaminar la firma pública.
- Controlar el orden de ejecución con el planificador y decidir cuál de sus tres verbos corresponde a cada aserción.
Dos modelos de ejecución, no dos sabores
El StandardTestDispatcher no ejecuta nada por su cuenta. Cuando alguien lanza una corrutina sobre él, la tarea se deposita en la cola del planificador y el control vuelve inmediatamente a quien llamó. La corrutina solo avanza cuando el hilo del test cede el control, sea porque el cuerpo del test se suspende o porque llamas explícitamente a uno de los verbos del planificador. Ese comportamiento reproduce exactamente lo que ocurre en producción con un dispatcher real: launch programa trabajo, no lo ejecuta.
El UnconfinedTestDispatcher hace lo contrario. Ejecuta la corrutina recién lanzada de inmediato, en el mismo hilo y dentro de la misma llamada a launch, y sigue ejecutándola hasta que se suspende de verdad; en ese punto devuelve el control. El efecto es una ejecución en profundidad que hace que el código parezca sincrónico.
@Test
fun elEstandarNoEjecutaHastaQueSeLoPides() = runTest {
var visto = false
launch { visto = true } // encolada, no ejecutada
assertFalse(visto)
runCurrent() // ahora sí
assertTrue(visto)
}
@Test
fun elNoConfinadoEjecutaAlVuelo() = runTest(UnconfinedTestDispatcher()) {
var visto = false
launch { visto = true } // ejecutada dentro del propio launch
assertTrue(visto)
}
La conclusión práctica es incómoda: el no confinado es cómodo precisamente porque oculta la asincronía, y ocultar la asincronía es la mejor forma de no detectar los errores que la asincronía provoca. Un componente que publica un estado antes de haber terminado de inicializarlo, o que asume que una corrutina lanzada ya ha corrido cuando la siguiente línea se ejecuta, pasará el test con el no confinado y fallará en producción con cualquier dispatcher real.
Si construyes un dispatcher de prueba a mano dentro de un runTest, pásale testScheduler. Un dispatcher con planificador propio tiene su propio reloj virtual, y entonces advanceUntilIdle en el test no mueve nada de lo que ocurre dentro del componente.
Inyectar el dispatcher en vez de codificarlo
Ningún dispatcher de prueba sirve de nada si el código bajo prueba escribe Dispatchers.IO en el cuerpo de una función. Ese literal es una dependencia con la infraestructura tan real como una conexión a base de datos, y como toda dependencia con la infraestructura debe entrar por la puerta, no aparecer por dentro. La forma canónica es un parámetro con valor por defecto: la producción no nota nada y el test puede sustituirlo.
// Antes: intestable, el salto es invisible desde fuera
class RepositorioMalo(private val api: Api) {
suspend fun cargar(id: String): Ficha =
withContext(Dispatchers.IO) { api.pedir(id) }
}
// Después: la dependencia temporal es explícita y sustituible
class Repositorio(
private val api: Api,
private val io: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun cargar(id: String): Ficha =
withContext(io) { api.pedir(id) }
}
@Test
fun cargaLaFicha() = runTest {
val repo = Repositorio(ApiFalsa(), StandardTestDispatcher(testScheduler))
assertEquals(Ficha("7"), repo.cargar("7"))
}
Cuando una clase necesita más de un dispatcher —uno para entrada y salida, otro para cálculo, otro para la interfaz— la respuesta no es multiplicar parámetros sino agrupar los tres en una única abstracción con un nombre propio, de modo que la sustitución en los tests sea una sola línea y no tres.
Hay un caso especial que no se resuelve con inyección porque el dispatcher no lo elige tu código: Dispatchers.Main, que en aplicaciones de interfaz lo instala la plataforma y no existe en un entorno de pruebas. Para eso la biblioteca ofrece dos funciones que sustituyen la instancia global, y su uso obliga a devolverla a su estado original al terminar.
class ReglaDelPrincipal(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
override fun finished(d: Description) = Dispatchers.resetMain()
}
Estándar por defecto
Úsalo salvo que tengas una razón concreta. Te obliga a decir cuándo avanza el trabajo y por tanto verifica que tu código no asume ejecución inmediata.
No confinado para colectores
Su hueco legítimo es arrancar un recolector de flujo que debe estar suscrito antes de que la primera emisión ocurra. Ahí la avidez es exactamente lo que necesitas.
Uno por rol
Inyecta un dispatcher por responsabilidad, no uno por clase. Si dos componentes comparten el mismo rol temporal, comparten el mismo parámetro.
Nunca en la firma pública
El parámetro va con valor por defecto y en la posición final. Quien usa la biblioteca no debería tener que saber que existe.
Los tres verbos del planificador
Con el dispatcher estándar el test decide cuándo avanza el trabajo, y para eso el planificador expone tres operaciones que no son intercambiables. runCurrent ejecuta todas las tareas programadas para el instante virtual presente y se detiene; no toca el reloj. advanceTimeBy adelanta el reloj una cantidad determinada ejecutando por el camino todo lo programado estrictamente antes del instante de destino, lo que deja pendiente de forma deliberada lo que caiga justo en la frontera. advanceUntilIdle vacía la cola entera, saltando el reloj tantas veces como haga falta hasta que no queda nada.
@Test
fun elDebounceDescartaLasPulsacionesRapidas() = runTest {
val buscador = Buscador(retardoMs = 300)
buscador.teclear("k")
advanceTimeBy(100) // aún no ha saltado la búsqueda
buscador.teclear("ko")
advanceTimeBy(100)
buscador.teclear("kot")
runCurrent()
assertEquals(0, buscador.consultasLanzadas)
advanceUntilIdle() // deja pasar el debounce completo
assertEquals(listOf("kot"), buscador.consultas)
}
La elección entre ellos comunica intención. Usar advanceUntilIdle en todas partes es cómodo y equivale a decir que ocurra todo: sirve para comprobar el resultado final, pero renuncia a afirmar nada sobre los estados intermedios, que es justamente donde viven los errores de orden. Usar runCurrent y advanceTimeBy con valores concretos es lo que convierte un test en una afirmación sobre la secuencia y no solo sobre el destino.
flowchart TD
A[Lanzar corrutina] --> B{Que dispatcher}
B -->|Estandar| C[Encolar en el planificador]
C --> D[El control vuelve al test]
D --> E{Que verbo llama el test}
E -->|runCurrent| F[Solo el instante actual]
E -->|advanceTimeBy| G[Hasta el instante indicado]
E -->|advanceUntilIdle| H[Vaciar la cola entera]
B -->|No confinado| I[Ejecutar ya en este hilo]
I --> J[Hasta la primera suspension real]Hay una simetría que casi nunca se explicita y que ordena todo este asunto. Nadie discute que una clase no debe construir por dentro su cliente HTTP, su conexión a base de datos ni su reloj del sistema, porque son dependencias con el mundo exterior y quien las fija le arrebata al llamante la capacidad de decidir. Un dispatcher es exactamente eso mismo aplicado al recurso más escaso y más compartido que tiene un proceso: los hilos. Cuando una función escribe withContext con un dispatcher literal, está afirmando que sabe mejor que todo el resto del sistema en qué pool debe correr ese trabajo, y esa afirmación es casi siempre falsa, porque la información necesaria para decidirlo —si el llamante ya está en un pool adecuado, si el trabajo se va a ejecutar mil veces en un bucle, si hay contención en ese pool ahora mismo— vive fuera de la función. El síntoma más visible de ese error es que el código se vuelve imposible de probar con tiempo virtual, y por eso la conversación suele plantearse como una cuestión de testabilidad; pero la testabilidad es aquí el canario, no la enfermedad. Un sistema con dispatchers repartidos por dentro de sus funciones acumula saltos de contexto redundantes que nadie puede ver en una traza de código, sufre inversiones de prioridad cuando un trabajo de cálculo largo aterriza en el pool de entrada y salida, y es incapaz de reaccionar a un cambio de política sin editar decenas de ficheros. Que además no se pueda testear con un reloj virtual es la manifestación más barata y más temprana de todo ese conjunto de problemas, y ahí está su valor: el test falla o se vuelve lento el mismo día que se introduce el acoplamiento, no dos años después en una incidencia de madrugada. Por eso la regla no debería enunciarse como inyecta el dispatcher para poder testear, sino al revés: si te cuesta testearlo con tiempo virtual es porque has fijado una política de ejecución en un sitio donde no se puede razonar sobre ella, y el test simplemente te lo está diciendo antes que nadie.
- Coge una clase tuya que use
withContextcon un dispatcher literal y conviértelo en un parámetro con valor por defecto. Comprueba que ningún llamante existente necesitó cambios. - Escribe el mismo test dos veces, una con el dispatcher estándar y otra con el no confinado, sobre un componente que lanza una corrutina y publica estado. Encuentra una aserción que pase con uno y falle con el otro.
- Implementa un debounce y verifica con
advanceTimeByque las pulsaciones separadas por menos del umbral no disparan consultas. Después sustituye todas las llamadas poradvanceUntilIdley anota qué aserciones dejaste de poder escribir. - Construye un dispatcher de prueba sin pasarle
testSchedulery observa qué ocurre cuando llamas aadvanceUntilIdledesde el test. Explica el resultado en términos de dos relojes independientes. - Instala una regla que sustituya el dispatcher principal y comprueba qué falla exactamente si olvidas restaurarlo al terminar. Ejecuta la suite completa dos veces para ver el efecto acumulado.