Esta pagina se ve mejor con JavaScript habilitado

Patrones de Testing para Coroutines: Estrategias Efectivas para Testear Código Asíncrono en Kotlin

 ·  ☕ 6 minutos lectura  ·  ✍️ Ignacio Carrión

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 coroutines
  • StandardTestDispatcher: Un dispatcher que utiliza el scheduler de test
  • UnconfinedTestDispatcher: Un dispatcher que ejecuta coroutines de manera inmediata
  • TestScope: 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:

  1. Ejecuta tu test en un TestScope
  2. Utiliza un StandardTestDispatcher por defecto
  3. Avanza automáticamente el tiempo virtual para completar coroutines suspendidas
  4. 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:

  1. Usa kotlinx-coroutines-test como base para testear coroutines
  2. Testea operadores de Flow personalizados a fondo con diferentes entradas y casos extremos
  3. Simula varios escenarios de dispatch para asegurar que tu código funcione en diferentes modelos de threading
  4. Verifica el manejo adecuado de timeouts y cancelaciones
  5. Crea dispatchers de test personalizados cuando necesites más control
  6. 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.

compartir en

Ignacio Carrión
Escrito por
Ignacio Carrión
Android Developer