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 elTestDispatcher
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
inyectar el modelo
CoroutineContext
de estado, lo que permite una mayor 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 devuelva 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 ExampleRepository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ } class RepositoryTestWithRule { private val repository = ExampleRepository(/* 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 = ExampleRepository(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 = ExampleRepository(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.