Cómo probar corrutinas de Kotlin en Android

El código de prueba de unidades que usa corrutinas requiere atención adicional, ya que su ejecución puede ser asíncrona y ocurrir en varios subprocesos. En esta guía, se explica cómo se pueden probar las funciones de suspensión, las construcciones de prueba que necesitas conocer y cómo hacer que tu código que usa corrutinas se pueda probar.

Las APIs que se usan en esta guía forman parte de la biblioteca kotlinx.coroutines.test. Asegúrate de agregar el artefacto como una dependencia de prueba a tu proyecto para tener acceso a estas APIs.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

Cómo invocar funciones de suspensión en las pruebas

Para llamar a funciones de suspensión en las pruebas, debes estar en una corrutina. Como las funciones de prueba JUnit no son funciones de suspensión, debes llamar a un compilador de corrutinas dentro de tus pruebas para iniciar una corrutina nueva.

runTest es un compilador de corrutinas diseñado para pruebas. Úsalo para unir cualquier prueba que incluya corrutinas. Ten en cuenta que las corrutinas se pueden iniciar no solo en el cuerpo de la prueba, sino también mediante los objetos que se usan en la prueba.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

En general, deberías tener una invocación de runTest por prueba, y se recomienda usar un cuerpo de expresión.

Unir el código de tu prueba en runTest funcionará para probar funciones de suspensión básicas y omitirá automáticamente cualquier retraso en corrutinas, lo que hará que la prueba anterior se complete mucho más rápido que en un segundo.

Sin embargo, debes tener en cuenta otras consideraciones, según lo que suceda en el código que estés probando:

  • Cuando tu código cree corrutinas distintas de la corrutina de prueba de nivel superior que crea runTest, deberás elegir el TestDispatcher apropiado para controlar cómo se programarán esas corrutinas.
  • Si tu código mueve la ejecución de corrutinas a otros despachadores (por ejemplo, mediante withContext), runTest funcionará en general, pero ya no se omitirán los retrasos y las pruebas serán menos predecibles, ya que el código se ejecuta en varios subprocesos. Por estos motivos, durante las pruebas debes insertar despachadores de prueba para reemplazar los despachadores reales.

TestDispatchers

Los TestDispatchers son implementaciones de CoroutineDispatcher para realizar pruebas. Deberás usar TestDispatchers si se crean corrutinas nuevas durante la prueba para que la ejecución de las corrutinas nuevas sea predecible.

Hay dos implementaciones disponibles de TestDispatcher: StandardTestDispatcher y UnconfinedTestDispatcher, que realizan una programación diferente de corrutinas recién iniciadas. Ambas usan un TestCoroutineScheduler para controlar el tiempo virtual y administrar las corrutinas en ejecución en una prueba.

Solo debe haber una instancia de programador en una prueba, compartida entre todos los TestDispatchers. Para obtener más información sobre el uso compartido de los programadores, consulta Cómo insertar despachadores de prueba.

Para iniciar la corrutina de prueba de nivel superior, runTest crea un TestScope, que es una implementación de CoroutineScope que siempre usará un TestDispatcher. Si no se especifica, un TestScope creará un StandardTestDispatcher de forma predeterminada y lo usará para ejecutar la corrutina de prueba de nivel superior.

runTest realiza un seguimiento de las corrutinas que se ponen en cola en el programador que usa el despachador de su TestScope y no devolverá resultados mientras haya trabajo pendiente en ese programador.

StandardTestDispatcher

Cuando inicias corrutinas nuevas en un StandardTestDispatcher, estas se ponen en cola en el programador subyacente y se ejecutan cada vez que el subproceso de prueba es de uso gratuito. Para permitir que se ejecuten estas corrutinas nuevas, debes generar el subproceso de prueba (liberándolo de otras corrutinas). Este comportamiento de cola te brinda un control preciso sobre cómo se ejecutan las corrutinas nuevas durante la prueba y se asemeja a la programación de corrutinas en el código de producción.

Si no se produce el subproceso de prueba durante la ejecución de la corrutina de prueba de nivel superior, las corrutinas nuevas solo se ejecutarán después de que se complete la corrutina de prueba (pero antes de que runTest devuelva resultados):

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Hay varias formas de generar la corrutina de prueba para permitir que se ejecuten las corrutinas en cola. Todas estas llamadas permiten que se ejecuten otras corrutinas en el subproceso de prueba antes de devolver resultados:

  • advanceUntilIdle: Ejecuta todas las demás corrutinas en el programador hasta que no quede nada en la cola. Esta es una buena opción predeterminada para permitir que se ejecuten todas las corrutinas pendientes, y funcionará en la mayoría de los casos de prueba.
  • advanceTimeBy: Avanza el tiempo virtual en función de la cantidad determinada y ejecuta las corrutinas programadas para ejecutarse antes de ese punto en tiempo virtual.
  • runCurrent: Ejecuta corrutinas que se programan para la hora virtual actual.

Para corregir la prueba anterior, se puede usar advanceUntilIdle y así permitir que las dos corrutinas pendientes cumplan su función antes de continuar con la aserción:

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

UnconfinedTestDispatcher

Cuando se inician corrutinas nuevas en un UnconfinedTestDispatcher, se hace con anticipación en el subproceso actual. Esto significa que comenzarán a ejecutarse de inmediato, sin esperar a que su compilador de corrutinas devuelva resultados. En muchos casos, este comportamiento de despacho genera un código de prueba más simple, ya que no necesitas procesar manualmente el subproceso de prueba para permitir que se ejecuten corrutinas nuevas.

Sin embargo, este comportamiento es diferente de lo que verás en producción con despachadores que no hacen pruebas. Si la prueba se centra en la simultaneidad, usa StandardTestDispatcher en su lugar.

Si quieres usar este despachador para la corrutina de prueba de nivel superior en runTest en lugar de la predeterminada, crea una instancia y úsala como parámetro. Esto hará que se ejecuten con anticipación las corrutinas nuevas creadas en runTest, ya que heredan el despachador de TestScope.

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

En este ejemplo, las llamadas de inicio empezarán sus nuevas corrutinas con anticipación en UnconfinedTestDispatcher, lo que significa que cada llamada que se inicie solo devolverá resultados después de que se complete el registro.

Recuerda que UnconfinedTestDispatcher inicia corrutinas nuevas con anticipación, pero eso no significa que las ejecute en su totalidad de la misma manera. Si se suspende la corrutina nueva, se reanudará la ejecución de otras corrutinas.

Por ejemplo, la corrutina nueva que se inicie en esta prueba registrará a Alice, pero luego se suspenderá cuando se llame a delay. Esto permite que la corrutina de nivel superior continúe con la aserción y la prueba falle, ya que Bob aún no está registrado:

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Cómo insertar despachadores de pruebas

El código que se está probando podría usar despachadores para cambiar subprocesos (con withContext) o para iniciar corrutinas nuevas. Cuando se ejecuta el código en múltiples subprocesos en paralelo, las pruebas pueden volverse inestables. Puede ser difícil realizar aserciones en el momento correcto o esperar a que se completen las tareas si se ejecutan en subprocesos en segundo plano sobre los que no tienes control.

En las pruebas, reemplaza estos despachadores con instancias de TestDispatchers. Esto tiene varios beneficios, como los siguientes:

  • El código se ejecutará en un único subproceso de prueba y las pruebas serán más deterministas.
  • Puedes controlar cómo se programan y ejecutan las corrutinas nuevas.
  • TestDispatchers usa un programador para tiempo virtual, que omite los retrasos automáticamente y te permite adelantar el tiempo de forma manual.

El uso de la inserción de dependencias para proporcionar despachadores a tus clases facilita el reemplazo de despachadores reales en las pruebas. En estos ejemplos, insertaremos un CoroutineDispatcher, pero también puedes insertar el tipo CoroutineContext más amplio, lo que permite aún más flexibilidad durante las pruebas.

Para las clases que inician corrutinas, también puedes insertar un CoroutineScope en lugar de un despachador, como se detalla en la sección Cómo insertar un alcance.

De forma predeterminada, TestDispatchers creará un nuevo programador cuando se creen instancias de él. Dentro de runTest, puedes acceder a la propiedad testScheduler de TestScope y pasarla a cualquier TestDispatchers recién creado. De esta forma, se comparte su comprensión del tiempo virtual, y los métodos como advanceUntilIdle ejecutarán corrutinas en todos los despachadores de prueba hasta su finalización.

En el siguiente ejemplo, puedes ver una clase Repository que crea una corrutina nueva usando el despachador IO en su método initialize y cambia el llamador al despachador IO en su fetchData método:

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

En las pruebas, puedes insertar una implementación de TestDispatcher para reemplazar al despachador de IO.

En el siguiente ejemplo, insertamos un StandardTestDispatcher en el repositorio y usamos advanceUntilIdle para asegurarnos de que la corrutina nueva que se inició en initialize se complete antes de continuar.

fetchData también se beneficiará de ejecutarse en un TestDispatcher, ya que se ejecutará en el subproceso de prueba y omitirá el retraso que contiene durante la prueba.

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

Las corrutinas nuevas que se iniciaron en TestDispatcher se pueden continuar manualmente como se muestra arriba con initialize. Sin embargo, ten en cuenta que esto no sería posible o deseable en el código de producción. En su lugar, se debe rediseñar este método para que esté suspendido (para la ejecución secuencial) o que muestre un valor de Deferred (para la ejecución simultánea).

Por ejemplo, puedes usar async para iniciar una corrutina nueva y crear un Deferred:

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

Esto te permite await completar de forma segura este código en las pruebas y en el código de producción:

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest esperará a que se completen las corrutinas pendientes antes de mostrarlas si están en un TestDispatcher con el que comparta un programador. Además, esperará a las corrutinas que sean elementos secundarios de la corrutina de prueba de nivel superior, incluso si están en otros despachadores (hasta un tiempo de espera especificado en el parámetro dispatchTimeoutMs, cuyo valor predeterminado es de 60 segundos).

Cómo configurar el despachador principal

En las pruebas de unidades locales, el despachador Main que une el subproceso de IU de Android no está disponible, ya que estas pruebas se ejecutan en una JVM local y no en un dispositivo Android. Si el código que estás probando hace referencia al subproceso principal, generará una excepción durante las pruebas de unidades.

En algunos casos, puedes insertar el despachador Main de la misma manera que otros despachadores, como se describe en la sección anterior, lo que te permite reemplazarlo por un TestDispatcher en las pruebas. Sin embargo, algunas APIs, como viewModelScope, usan un despachador Main codificado de forma interna.

El siguiente es un ejemplo de una implementación de ViewModel que usa viewModelScope para iniciar una corrutina que carga datos:

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

Para reemplazar el despachador Main por un TestDispatcher en todos los casos, usa las funciones Dispatchers.setMain y Dispatchers.resetMain.

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

Si el despachador Main se reemplazó por un elemento TestDispatcher, cualquier TestDispatchers creada recientemente usará de forma automática el programador del Main despachador, incluido el StandardTestDispatcher creado por runTest si no se le pasa ningún otro despachador.

Esto facilita que te asegures de que se use un solo programador durante la prueba. Para que esto funcione, recuerda crear todas las demás instancias de TestDispatcher después de llamar a Dispatchers.setMain.

Un forma común para evitar duplicar el código que reemplaza al despachador Main en cada prueba es extraerlo en una regla de prueba de JUnit:

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

Esta implementación de reglas usa un UnconfinedTestDispatcher de forma predeterminada, pero se puede pasar un StandardTestDispatcher como parámetro si el despachador Main no debería ejecutarse con anticipación en una clase de prueba determinada.

Cuando necesitas una instancia de TestDispatcher en el cuerpo de la prueba, puedes volver a usar testDispatcher de la regla, siempre y cuando sea del tipo deseado. Si quieres explicitar el tipo de TestDispatcher que se usa en la prueba o si necesitas un TestDispatcher diferente del que se usa para Main, puedes crear un TestDispatcher en un runTest. Como el despachador Main está establecido en TestDispatcher, cualquier TestDispatchers recién creado compartirá su programador automáticamente.

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

Cómo crear despachadores fuera de una prueba

En algunos casos, es posible que necesites un objeto TestDispatcher para estar disponible fuera del método de prueba. Por ejemplo, durante la inicialización de una propiedad en la clase de prueba:

class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

Si reemplazas el despachador Main, como se muestra en la sección anterior, el TestDispatchers que se creó después de reemplazar el despachador Main compartirá automáticamente su programador.

Sin embargo, este no es el caso de los TestDispatchers creados como propiedades de la clase de prueba ni los TestDispatchers creados durante la inicialización de propiedades en la clase de prueba. Estos se inicializan antes de que se reemplace el despachador Main. Por lo tanto, crearían nuevos programadores.

Para asegurarte de que solo haya un programador en tu prueba, primero debes crear la propiedad MainDispatcherRule. Luego, vuelve a usar el despachador (o su programador, si necesitas un TestDispatcher de un tipo diferente) en los inicializadores de otras propiedades de nivel de clase según sea necesario.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = Repository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

Ten en cuenta que los elementos runTest y TestDispatchers creados en la prueba compartirán automáticamente el programador del despachador Main.

Si no reemplazas al despachador de Main, crea tu primer TestDispatcher (que crea un programador nuevo) como propiedad de la clase. Luego, pasa ese programador de forma manual a cada invocación de runTest y cada nuevo TestDispatcher creada, como propiedades y dentro de la prueba:

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

En este ejemplo, el programador del primer despachador se pasa a runTest. A partir de esto, se creará un nuevo StandardTestDispatcher para el TestScope mediante ese programador. También puedes pasar el despachador directamente a runTest para ejecutar la corrutina de prueba en ese despachador.

Cómo crear tu propio TestScope

Al igual que con TestDispatchers, es posible que debas acceder a un TestScope fuera del cuerpo de la prueba. Si bien runTest crea un TestScope de forma interna automáticamente, también puedes crear tu propio TestScope para usar con runTest.

Cuando lo hagas, asegúrate de llamar a runTest en el objeto TestScope que creaste:

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

El código anterior crea un StandardTestDispatcher para el TestScope de manera implícita, así como un nuevo programador. Todos estos objetos también se pueden crear de manera explícita. Esto puede ser útil si necesitas integrarlo a configuraciones de inserción de dependencias.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Cómo insertar un alcance

Si tienes una clase que crea corrutinas y necesitas controlarla durante las pruebas, puedes insertar un alcance de corrutinas en esa clase y reemplazarla por un TestScope en las pruebas.

En el siguiente ejemplo, la clase UserState depende de un UserRepository para registrar usuarios nuevos y recuperar la lista de usuarios registrados. Como estas llamadas a UserRepository suspenden las llamadas a funciones, UserState usa el CoroutineScope insertado para iniciar una corrutina nueva dentro de su función registerUser.

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

Para probar esta clase, puedes pasar el TestScope de runTest cuando creas el objeto UserState:

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

Para insertar un alcance fuera de la función de prueba, por ejemplo, en un objeto en prueba que se crea como una propiedad en la clase de prueba, consulta Cómo crear tu propio TestScope.

Recursos adicionales