El problema con coroutines en tests
Los ViewModels usan viewModelScope que corre en Dispatchers.Main. En un test unitario no hay un hilo principal de Android — si intentás correr un ViewModel sin configuración especial, el test falla con:
java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize
La solución: reemplazar Dispatchers.Main con un dispatcher de test que corre sincrónicamente en el hilo del test.
TestDispatcher — los dos tipos
// StandardTestDispatcher (recomendado para nuevos proyectos)
// Las coroutines NO avanzan automáticamente — hay que avanzarlas explícitamente
// Más predecible y controlable
val dispatcher = StandardTestDispatcher()
// UnconfinedTestDispatcher (comportamiento más eager)
// Las coroutines avanzan automáticamente cuando se suspenden
// Más simple pero menos control sobre el orden de ejecución
val dispatcher = UnconfinedTestDispatcher()
MainDispatcherRule — la forma correcta
El patrón estándar es crear un TestRule que reemplaza Dispatchers.Main antes de cada test y lo restaura después:
// Crear la regla una vez — reutilizar en todos los tests de ViewModel
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
// Usarla en el test:
class ProductosViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
// Ya podés crear ViewModels normalmente — Dispatchers.Main está reemplazado
private val fakeRepo = FakeProductoRepository()
private val viewModel = ProductosViewModel(fakeRepo)
}
runTest — el scope de coroutines para tests
// runTest es el equivalente de runBlocking para tests
// Maneja el tiempo virtual — los delays no esperan en tiempo real
@Test
fun `cargar productos emite estado de loading y luego success`() = runTest {
// El test corre dentro de un scope de coroutines controlado
val fakeRepo = FakeProductoRepository()
val viewModel = ProductosViewModel(fakeRepo)
viewModel.cargar()
// Con UnconfinedTestDispatcher las coroutines avanzan inmediatamente
assertThat(viewModel.uiState.value.isLoading).isFalse()
assertThat(viewModel.uiState.value.productos).hasSize(2)
}
Testear UiState — el patrón completo
// El ViewModel a testear:
@HiltViewModel
class ProductosViewModel @Inject constructor(
private val repo: ProductoRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductosUiState())
val uiState = _uiState.asStateFlow()
fun cargar() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val productos = repo.getProductos()
_uiState.update { it.copy(isLoading = false, productos = productos) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
}
}
// Los tests:
@OptIn(ExperimentalCoroutinesApi::class)
class ProductosViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private lateinit var fakeRepo: FakeProductoRepository
private lateinit var viewModel: ProductosViewModel
@Before
fun setUp() {
fakeRepo = FakeProductoRepository()
viewModel = ProductosViewModel(fakeRepo)
}
@Test
fun `estado inicial es vacio y no loading`() {
val state = viewModel.uiState.value
assertThat(state.productos).isEmpty()
assertThat(state.isLoading).isFalse()
assertThat(state.error).isNull()
}
@Test
fun `cargar con exito actualiza la lista`() = runTest {
fakeRepo.setProductos(listOf(productoFake1, productoFake2))
viewModel.cargar()
val state = viewModel.uiState.value
assertThat(state.productos).hasSize(2)
assertThat(state.isLoading).isFalse()
assertThat(state.error).isNull()
}
@Test
fun `cargar con error actualiza el mensaje de error`() = runTest {
fakeRepo.setError(IOException("Sin conexión"))
viewModel.cargar()
val state = viewModel.uiState.value
assertThat(state.productos).isEmpty()
assertThat(state.isLoading).isFalse()
assertThat(state.error).isEqualTo("Sin conexión")
}
@Test
fun `cargar con lista vacia muestra estado vacio`() = runTest {
fakeRepo.setProductos(emptyList())
viewModel.cargar()
assertThat(viewModel.uiState.value.productos).isEmpty()
assertThat(viewModel.uiState.value.isLoading).isFalse()
}
}
Turbine — testear la secuencia de estados
A veces no alcanza con verificar el estado final — necesitás verificar que la secuencia de estados emitidos fue la correcta (ej: primero loading=true, después loading=false con datos). Turbine hace eso:
// Turbine captura todos los valores emitidos por un Flow durante el test
@Test
fun `cargar emite loading y luego success en orden correcto`() = runTest {
// Usar StandardTestDispatcher para controlar el avance de coroutines
val dispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(dispatcher)
fakeRepo.setProductos(listOf(productoFake1))
viewModel.cargar()
viewModel.uiState.test {
// Estado inicial
val inicial = awaitItem()
assertThat(inicial.isLoading).isFalse()
// Avanzar las coroutines un paso
advanceUntilIdle()
// Estado loading
// (dependiendo del dispatcher, puede o no aparecer)
// Estado final con datos
val final = awaitItem()
assertThat(final.isLoading).isFalse()
assertThat(final.productos).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
// Turbine para flows más simples:
@Test
fun `flow de productos emite lista correcta`() = runTest {
fakeRepo.setProductos(listOf(p1, p2, p3))
fakeRepo.getProductosFlow().test {
val lista = awaitItem()
assertThat(lista).hasSize(3)
cancelAndIgnoreRemainingEvents()
}
}
Testear side effects con Channel
// ViewModel con effects:
class ProductosViewModel(...) : ViewModel() {
private val _effects = Channel<ProductosEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun seleccionar(id: Int) {
viewModelScope.launch {
_effects.send(ProductosEffect.NavigarADetalle(id))
}
}
}
// Testear el effect:
@Test
fun `seleccionar producto emite efecto de navegacion`() = runTest {
viewModel.seleccionar(42)
viewModel.effects.test {
val effect = awaitItem()
assertThat(effect).isInstanceOf(ProductosEffect.NavigarADetalle::class.java)
val navEffect = effect as ProductosEffect.NavigarADetalle
assertThat(navEffect.productoId).isEqualTo(42)
cancelAndIgnoreRemainingEvents()
}
}