Testing de corutinas con test dispatchers en ViewModels

Clase 8 de 16Curso de Android Testing

Resumen

Aprender a testear corutinas mediante el uso de test dispatchers facilita un control claro y efectivo del comportamiento asíncrono en pruebas unitarias. Esta técnica permite verificar estados específicos en un ViewModel cuando se cargan datos desde repositorios falsos (fakes), obteniendo resultados predecibles y simplificando el proceso de pruebas en aplicaciones Android.

¿Qué son los test dispatchers para corutinas?

Los test dispatchers son herramientas específicas del framework de testing de corutinas diseñadas para manejar operaciones asíncronas durante pruebas unitarias. Utilizando estas herramientas, los desarrolladores pueden:

  • Controlar la ejecución exacta de corutinas durante una prueba.
  • Evaluar secuencias y estados intermedios como loading, success o failure.
  • Evitar test inestables o que dependan del tiempo real de ejecución.

¿Cuáles tipos de test dispatchers existen?

  • Standard test dispatcher: permite control manual de la ejecución; requiere explicitar avances en el tiempo usando métodos como advanceUntilIdle().
  • Unconfined test dispatcher: ejecuta corutinas automáticamente, sin necesidad de controles explícitos, simplificando los tests.

¿Cómo probar un ViewModel que usa corutinas?

Para validar la lógica contenida en un ViewModel con operaciones asíncronas, considera los siguientes pasos clave:

Creando repositorios falsos (fakes) para pruebas

La técnica implica extender repositorios reales para simular respuestas específicas. Por ejemplo:

  • Retornar un objeto de perfil válido al invocar una operación existente.
  • Lanzar excepciones definidas claramente para simular errores específicos.
class UserRepositoryFake : UserRepository {
    var profileToReturn: Profile? = null
    var errorToReturn: Exception? = null

    override suspend fun getProfile(userId: String): Result<Profile> {
        return errorToReturn?.let { Result.failure(it) } ?: Result.success(profileToReturn!!)
    }
}

Configuración inicial del test unitario

Preparar el entorno de pruebas necesita una configuración inicial sencilla y clara, como esta:

@Before
fun setUp() {
    repository = UserRepositoryFake()
    viewModel = ProfileViewModel(repository, SavedStateHandle(mapOf("userId" to repository.profileToReturn?.user?.id)))
}

Realizando y validando un test unitario

Un test bien estructurado usa combinaciones de acciones y afirmaciones de resultados esperados:

@Test
fun `given valid user ID, when load profile, then profile loaded`() = runTest {
    viewModel.loadProfile()
    advanceUntilIdle() // Usar con Standard Test Dispatcher

    assertThat(viewModel.state.value.profile).isEqualTo(repository.profileToReturn)
    assertThat(viewModel.state.value.isLoading).isFalse()
}

Al utilizar Unconfined test dispatcher, puedes prescindir del uso de advanceUntilIdle.

¿Cómo manejar errores con test dispatchers?

Es fundamental considerar la validación de estados en escenarios de error:

  • Configurar el repositorio para lanzar una excepción concreta.
  • Validar que el ViewModel actualice su estado correctamente ante errores.
@Test
fun `given repository error, when load profile, then ViewModel error state set`() = runTest {
    repository.errorToReturn = TestException("testException")

    viewModel.loadProfile()
    assertThat(viewModel.state.value.profile).isNull()
    assertThat(viewModel.state.value.errorMessage).isEqualTo("testException")
    assertThat(viewModel.state.value.isLoading).isFalse()
}

Aprender esta metodología asegura robustez y claridad en pruebas unitarias, aportando a la mejora continua y mantenimiento eficiente del código.