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()
    }
}