Patrones de Testing para Coroutines: Estrategias Efectivas para Testear Código Asíncrono en Kotlin
Testear código asíncrono siempre ha sido un desafío, y las coroutines y flows de Kotlin no son una excepción. Sin embargo, el equipo de Kotlin ha proporcionado potentes utilidades de test que hacen que este proceso sea más manejable y confiable. En este artículo, exploraremos patrones efectivos para testear coroutines y flows, desde tests unitarios básicos hasta escenarios de integración complejos.
La Base: kotlinx-coroutines-test
Antes de profundizar en patrones específicos, establezcamos la base. La biblioteca kotlinx-coroutines-test
proporciona herramientas esenciales para testear coroutines:
1
2
3
| dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
|
Esta biblioteca ofrece varios componentes clave:
TestCoroutineScheduler
: Controla el tiempo virtual para las coroutinesStandardTestDispatcher
: Un dispatcher que utiliza el scheduler de testUnconfinedTestDispatcher
: Un dispatcher que ejecuta coroutines de manera inmediataTestScope
: Un scope de coroutine con funcionalidad específica para tests
Veamos cómo configurar un entorno de test básico:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class BasicCoroutineTest {
@Test
fun `basic coroutine test`() = runTest {
// runTest crea un TestScope con un StandardTestDispatcher
val result = fetchData() // función suspend llamada dentro de un scope de coroutine
assertEquals("Data", result)
}
private suspend fun fetchData(): String {
// Simular retraso de red
delay(1000)
return "Data"
}
}
|
La función runTest
crea un entorno de test de coroutine que:
- Ejecuta tu test en un
TestScope
- Utiliza un
StandardTestDispatcher
por defecto - Avanza automáticamente el tiempo virtual para completar coroutines suspendidas
- Falla el test si alguna coroutine lanza una excepción
Testeando Operadores de Flow Personalizados
Los operadores de Flow personalizados son una forma poderosa de encapsular lógica de procesamiento de flujos reutilizable. Testearlos a fondo es esencial para garantizar que se comporten según lo esperado en diversas condiciones.
Consideremos un operador personalizado que filtra y transforma elementos:
1
2
3
4
5
6
7
8
9
10
| fun <T, R> Flow<T>.filterAndMap(
predicate: suspend (T) -> Boolean,
transform: suspend (T) -> R
): Flow<R> = flow<R> {
collect { value ->
if (predicate(value)) {
emit(transform(value))
}
}
}
|
Así es cómo testear este operador:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @Test
fun `filterAndMap should filter and transform items`() = runTest {
// Given
val sourceFlow = flowOf(1, 2, 3, 4, 5)
val isEven: suspend (Int) -> Boolean = { it % 2 == 0 }
val double: suspend (Int) -> Int = { it * 2 }
// When
val resultFlow = sourceFlow.filterAndMap(isEven, double)
// Then
val result = resultFlow.toList()
assertEquals(listOf(4, 8), result)
}
|
Para operadores más complejos, testea diferentes casos extremos:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| @Test
fun `filterAndMap should handle empty flows`() = runTest {
// Given
val emptyFlow = emptyFlow<Int>()
// When
val resultFlow = emptyFlow.filterAndMap(
predicate = { true },
transform = { it * 2 }
)
// Then
val result = resultFlow.toList()
assertEquals(emptyList(), result)
}
@Test
fun `filterAndMap should propagate exceptions from predicate`() = runTest {
// Given
val sourceFlow = flowOf(1, 2, 3)
val throwingPredicate: suspend (Int) -> Boolean = {
if (it == 2) throw RuntimeException("Test exception")
true
}
// When/Then
assertThrows<RuntimeException> {
runBlocking {
sourceFlow.filterAndMap(
predicate = throwingPredicate,
transform = { it }
).toList()
}
}
}
|
Al testear operadores que involucran temporización, usa el scheduler de test para controlar el tiempo virtual:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| @Test
fun `debounce operator should emit only after specified delay`() = runTest {
// Given
val testScope = this
val flow = flow<String> {
emit("A")
testScope.advanceTimeBy(90)
emit("B")
testScope.advanceTimeBy(90)
emit("C")
testScope.advanceTimeBy(200)
emit("D")
}
// When
val results = mutableListOf<String>()
val job = launch {
flow.debounce(100).collect { results.add(it) }
}
// Avanzar el tiempo para completar todas las operaciones
advanceUntilIdle()
job.cancel()
// Then
assertEquals(listOf("C", "D"), results)
}
|
Testeando Timeout y Cancelación
El manejo adecuado de timeouts y cancelaciones es crucial para un código de coroutine robusto. Así es cómo testear estos escenarios:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| class TimeoutService {
suspend fun fetchWithTimeout(id: String, api: Api): Result<Data> {
return try {
// Usar withTimeout para limitar el tiempo de ejecución
val data = withTimeout(1000) {
api.fetchData(id)
}
Result.success(data)
} catch (e: TimeoutCancellationException) {
Result.failure(e)
}
}
fun processWithCancellationCheck(input: Flow<Int>): Flow<Int> = input
.map<Int, Int> { value ->
ensureActive() // Verificar cancelación
value * 2
}
.onCompletion<Int> { cause ->
if (cause is CancellationException) {
// Registrar o manejar la cancelación
}
}
}
|
Testeando el comportamiento de timeout:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| @Test
fun `fetchWithTimeout should return success when API responds in time`() = runTest {
// Given
val mockApi = mock<Api> {
onBlocking { fetchData("123") } doAnswer {
delay(500) // Responder dentro del timeout
Data("test")
}
}
val service = TimeoutService()
// When
val result = service.fetchWithTimeout("123", mockApi)
// Then
assertTrue(result.isSuccess)
assertEquals(Data("test"), result.getOrNull())
}
@Test
fun `fetchWithTimeout should return failure when API times out`() = runTest {
// Given
val mockApi = mock<Api> {
onBlocking { fetchData("123") } doAnswer {
delay(2000) // Exceder el timeout
Data("test")
}
}
val service = TimeoutService()
// When
val result = service.fetchWithTimeout("123", mockApi)
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is TimeoutCancellationException)
}
|
Testeando el manejo de cancelación:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| @Test
fun `processWithCancellationCheck should handle cancellation properly`() = runTest {
// Given
val service = TimeoutService()
val flow = flow {
repeat(10) {
emit(it)
delay(100)
}
}
// When
val results = mutableListOf<Int>()
val job = launch {
service.processWithCancellationCheck(flow).collect {
results.add(it)
if (results.size >= 5) {
cancel() // Cancelar después de recolectar 5 elementos
}
}
}
// Then
advanceUntilIdle()
assertEquals(5, results.size)
assertEquals(listOf(0, 2, 4, 6, 8), results)
}
|
Conclusión
Testear coroutines y flows de manera efectiva requiere comprender tanto las utilidades de test proporcionadas por el equipo de Kotlin como los patrones que funcionan mejor para diferentes escenarios. Utilizando las técnicas descritas en este artículo, puedes crear tests confiables incluso para el código asíncrono más complejo:
- Usa
kotlinx-coroutines-test
como base para testear coroutines - Testea operadores de Flow personalizados a fondo con diferentes entradas y casos extremos
- Simula varios escenarios de dispatch para asegurar que tu código funcione en diferentes modelos de threading
- Verifica el manejo adecuado de timeouts y cancelaciones
- Crea dispatchers de test personalizados cuando necesites más control
- Construye tests de integración completos que verifiquen todo el flujo de datos
Recuerda que los buenos tests no solo verifican que tu código funcione correctamente, sino que también sirven como documentación sobre cómo debe usarse. Al invertir tiempo en escribir tests exhaustivos para tu código de coroutines, crearás aplicaciones más robustas y facilitarás el mantenimiento futuro.
A medida que las coroutines y flows continúan evolucionando, mantente actualizado con las últimas utilidades de test y mejores prácticas. El equipo de Kotlin mejora regularmente las bibliotecas de test para hacer nuestras vidas como desarrolladores más fáciles y nuestro código más confiable.