Test Dispatchers

Clase 8 de 16Curso de Android Testing

Resumen

La programación asíncrona en Android es fundamental para crear aplicaciones fluidas y responsivas. Sin embargo, testear componentes que utilizan corrutinas puede ser un desafío debido a su naturaleza no determinista. Dominar el arte de probar ViewModels que ejecutan corrutinas es esencial para garantizar la calidad y confiabilidad de nuestras aplicaciones.

¿Cómo testear ViewModels que utilizan corrutinas?

Cuando trabajamos con ViewModels en Android, frecuentemente implementamos operaciones asíncronas mediante corrutinas. Estas operaciones pueden incluir llamadas a APIs, acceso a bases de datos o cualquier tarea que no deba bloquear el hilo principal. Para testear estos componentes de manera efectiva, necesitamos controlar el comportamiento asíncrono de forma predecible.

El ViewModel que vamos a testear tiene las siguientes características:

  • Depende de un repositorio de usuarios
  • Recibe un ID de usuario a través de un SaveStateHandle
  • Maneja estados de carga, éxito y error
  • Utiliza corrutinas para cargar datos de perfil

Preparando el entorno de pruebas

Para comenzar, necesitamos crear un "fake" o doble de prueba para el repositorio de usuarios. Este enfoque nos permite simular diferentes escenarios sin depender de implementaciones reales:

class UserRepositoryFake : UserRepository {
    // Datos de prueba
    val user = User(
        id = UUID.randomUUID().toString(),
        username = "test_username"
    )
    
    val place = Place(
        id = UUID.randomUUID().toString(),
        name = "test_place",
        coordinates = Coordinates(0.0, 0.0)
    )
    
    val profile = Profile(
        user = user,
        places = List(10) { place }
    )
    
    // Variables para controlar el comportamiento
    var profileToReturn: Profile = profile
    var errorToReturn: Exception? = null
    
    override suspend fun getProfile(userId: String): Result<Profile> {
        return if (errorToReturn != null) {
            Result.failure(errorToReturn!!)
        } else {
            Result.success(profileToReturn)
        }
    }
    
    override suspend fun getPlaces(): Result<List<Place>> {
        return Result.success(places)
    }
}

Este fake nos permite controlar exactamente qué datos o errores devolverá el repositorio durante nuestras pruebas.

Creando la clase de prueba para el ViewModel

Ahora podemos crear nuestra clase de prueba para el ProfileViewModel:

class ProfileViewModelTest {
    private lateinit var viewModel: ProfileViewModel
    private lateinit var repository: UserRepositoryFake
    private val testDispatcher = StandardTestDispatcher()
    
    @Before
    fun setup() {
        // Configuramos el dispatcher de prueba
        Dispatchers.setMain(testDispatcher)
        
        // Inicializamos el repositorio fake
        repository = UserRepositoryFake()
        
        // Creamos el ViewModel con el SaveStateHandle que simula la navegación
        viewModel = ProfileViewModel(
            repository = repository,
            savedStateHandle = SavedStateHandle(
                mapOf("userId" to repository.profileToReturn.user.id)
            )
        )
    }
    
    @After
    fun tearDown() {
        // Restauramos el dispatcher original
        Dispatchers.resetMain()
    }
}

Es importante destacar que estamos utilizando SavedStateHandle para simular los parámetros de navegación, exactamente como lo haría el sistema real de navegación de Android.

El problema con los dispatchers en pruebas

Cuando intentamos ejecutar pruebas en ViewModels que utilizan corrutinas, nos encontramos con un problema: el viewModelScope utiliza por defecto Dispatchers.Main, que no está disponible en entornos de prueba unitaria.

Para solucionar esto, necesitamos reemplazar el dispatcher principal con uno específico para pruebas:

@Before
fun setup() {
    Dispatchers.setMain(testDispatcher)
    // Resto del código...
}

@After
fun tearDown() {
    Dispatchers.resetMain()
}

Escribiendo casos de prueba efectivos

Ahora podemos escribir pruebas que validen el comportamiento de nuestro ViewModel:

@Test
fun `given valid userId when loadProfile then profile is loaded`() = runTest {
    // Act
    viewModel.loadProfile()
    
    // Avanzamos hasta que no haya operaciones pendientes
    testDispatcher.advanceUntilIdle()
    
    // Assert
    assertThat(viewModel.state.value.profile).isEqualTo(repository.profileToReturn)
    assertThat(viewModel.state.value.isLoading).isFalse()
}

En esta prueba, verificamos que:

  1. El perfil se carga correctamente
  2. El estado de carga se actualiza a false cuando termina la operación

Controlando el flujo de tiempo en las pruebas

Existen dos enfoques principales para controlar el tiempo en pruebas de corrutinas:

  1. StandardTestDispatcher: Requiere control manual del tiempo mediante advanceUntilIdle() o advanceTimeBy()
  2. UnconfinedTestDispatcher: Ejecuta las corrutinas inmediatamente sin necesidad de control manual
// Usando StandardTestDispatcher (control manual)
private val testDispatcher = StandardTestDispatcher()

@Test
fun testWithStandardDispatcher() = runTest {
    viewModel.loadProfile()
    testDispatcher.advanceUntilIdle() // Necesario para avanzar el tiempo
    // Verificaciones...
}

// Usando UnconfinedTestDispatcher (ejecución inmediata)
private val testDispatcher = UnconfinedTestDispatcher()

@Test
fun testWithUnconfinedDispatcher() = runTest {
    viewModel.loadProfile()
    // No es necesario avanzar el tiempo manualmente
    // Verificaciones...
}

Probando escenarios de error

Es igualmente importante probar cómo se comporta nuestro ViewModel cuando ocurren errores:

@Test
fun `given repository error when loadProfile then error is set`() = runTest {
    // Arrange
    repository.errorToReturn = Exception("Test exception")
    
    // Act
    viewModel.loadProfile()
    testDispatcher.advanceUntilIdle()
    
    // Assert
    assertThat(viewModel.state.value.profile).isNull()
    assertThat(viewModel.state.value.errorMessage).isEqualTo("Test exception")
    assertThat(viewModel.state.value.isLoading).isFalse()
}

En esta prueba verificamos que:

  1. El perfil permanece nulo cuando ocurre un error
  2. El mensaje de error se establece correctamente
  3. El estado de carga se actualiza a false incluso cuando hay un error

¿Cómo mejorar la estructura de nuestras pruebas?

Repetir la configuración de dispatchers en cada clase de prueba puede volverse tedioso y propenso a errores. En la próxima clase, aprenderemos a crear una regla personalizada con TestWatcher que automatice este proceso, haciendo nuestras pruebas más limpias y mantenibles.

Los test dispatchers son herramientas poderosas que nos permiten controlar con precisión el comportamiento asíncrono en nuestras pruebas. Dominar su uso es esencial para crear pruebas confiables y predecibles para componentes que utilizan corrutinas en Android.

¿Has tenido problemas al testear componentes asíncronos en tus aplicaciones? Comparte tus experiencias y dudas en los comentarios para seguir aprendiendo juntos.