Cómo testear ViewModels con corrutinas en Android

Resumen

Testear un ViewModel que ejecuta corrutinas en Android requiere algo más que un runTest: necesitas controlar el dispatcher principal para que tus pruebas sean predecibles. Aquí verás cómo hacerlo paso a paso, qué problemas vas a encontrar y cómo resolverlos con StandardTestDispatcher y UnconfinedTestDispatcher.

La idea es simple: doblar las dependencias del ViewModel con fakes, reemplazar el Dispatchers.Main por uno de prueba y validar los estados que emite tu pantalla cuando carga datos o falla la red.

¿Qué hace el ViewModel que vamos a testear?

El ProfileViewModel depende de un UserRepository y recibe un userId a través de un SavedStateHandle con la key userId. Cuando invocas loadProfile(), ocurren tres cosas en orden dentro del viewModelScope:

  • Emite un estado de loading en true.
  • Llama a getProfile(id), que es una operación asíncrona.
  • Actualiza el estado con el profile o con un error, y deja isLoading en false.

¿Qué es un test dispatcher? Es un CoroutineDispatcher especializado para pruebas que reemplaza al Dispatchers.Main. Te permite controlar el avance del tiempo y ejecutar corrutinas de forma determinista.

¿Cómo creo un fake del repositorio para aislar el ViewModel?

En lugar de usar el repository real, creas un UserRepositoryFake que extiende de la interfaz original. Ese fake expone dos propiedades públicas que te permiten manipular el comportamiento desde el test:

  • profileToReturn: el profile que devolverá getProfile.
  • errorToReturn: una excepción opcional para forzar el camino de error.

Dentro de getProfile, si errorToReturn no es nulo retornas Result.failure(errorToReturn). Si es nulo, retornas Result.success(profileToReturn). Para getPlaces basta un Result.success(places) directo, porque el caso de error que nos interesa probar es solo el de profile.

Los stubs del user y del profile generan un id aleatorio, un username, un place con coordenadas y una lista de 10 lugares para alimentar las pruebas con datos realistas.

¿Cómo simulo el SavedStateHandle en un test unitario?

Una duda común es cómo pasar parámetros de navegación en un test. La respuesta es que el SavedStateHandle es una clase de Android perfectamente válida en pruebas unitarias. Puedes instanciarlo con un map que contenga la key esperada:

kotlin SavedStateHandle(mapOf("userId" to repository.profileToReturn.user.id))

Así simulas que la pantalla anterior pasó el userId correcto y tu ViewModel lo lee como en producción.

¿Por qué falla el test con "Module with the Main dispatcher had failed to initialize"?

Este es el momento donde aparece el error más típico al testear corrutinas en Android. Cuando ejecutas tu primer test con runTest, lanzas loadProfile() y obtienes un mensaje que dice que el Main dispatcher falló al inicializarse.

La razón está en el viewModelScope: por debajo usa Dispatchers.Main, pensado para escuchar eventos de UI. En un entorno de test ese dispatcher no existe, y tampoco es confiable para validar resultados.

¿Cómo soluciono el error del Main dispatcher en tests? Usa Dispatchers.setMain(testDispatcher) en el setup y Dispatchers.resetMain() al finalizar. Eso reemplaza temporalmente el dispatcher principal por uno de prueba.

La configuración queda así:

kotlin private val testDispatcher = StandardTestDispatcher()

@Before fun setup() { Dispatchers.setMain(testDispatcher) } @After fun tearDown() { Dispatchers.resetMain() }

¿Cuál es la diferencia entre StandardTestDispatcher y UnconfinedTestDispatcher?

Acá viene una distinción que vale la pena entender bien, porque cambia la forma en que escribes tus aserciones.

¿Cuándo usar StandardTestDispatcher?

El StandardTestDispatcher te da control manual sobre la ejecución. Cuando llamas loadProfile(), la corrutina queda en cola pero no se ejecuta hasta que se lo pidas. Si haces tu assert enseguida, el profile aparecerá como null.

Para avanzar el tiempo virtual y procesar todo lo pendiente usas:

kotlin advanceUntilIdle()

Ese operador le dice al dispatcher que ejecute todas las corrutinas hasta que no quede trabajo pendiente.

¿Cuándo usar UnconfinedTestDispatcher?

Si prefieres no manejar el avance manualmente, cambias a UnconfinedTestDispatcher. Las corrutinas se ejecutan inmediatamente al ser lanzadas, así que tus asserts funcionan sin necesidad de advanceUntilIdle(). Es más cómodo cuando solo te interesa el estado final.

¿Cómo escribo un test para el caso de éxito al cargar el perfil?

Siguiendo la convención given, when, then, el test queda claro y legible. La condición es un userId válido, la acción es invocar loadProfile, y la verificación valida tres cosas con la librería Truth, que permite encadenar aserciones según el tipo de dato.

kotlin @Test fun given valid user id, when load profile, then profile is loaded() = runTest { viewModel.loadProfile() advanceUntilIdle() assertThat(viewModel.state.value.profile).isEqualTo(repository.profileToReturn) assertThat(viewModel.state.value.isLoading).isFalse() }

¿Cómo testeo el caso de error del repositorio?

Este es el reto que queda planteado. Cuando el repository lanza una excepción, el ViewModel debe emitir un estado con profile en null, un errorMessage igual al mensaje de la excepción, y isLoading en false.

La estructura del test es:

  • Asignar repository.errorToReturn = TestException("TestException").
  • Invocar viewModel.loadProfile().
  • Validar que profile es nulo, que errorMessage coincide con la excepción y que isLoading quedó en false.

Con esos dos casos cubres el camino feliz y el de error de un ViewModel que orquesta corrutinas. El siguiente paso natural es evitar repetir setMain y resetMain en cada test usando una regla personalizada con TestWatcher que automatice esa configuración. Cuéntame en los comentarios cuál de los dos dispatchers te resulta más cómodo y por qué.