¿Cómo pruebas unitariamente un ViewModel que depende de corrutinas y un repositorio?
Una respuesta senior de Android conecta un repositorio fake y un dispatcher de prueba controlado en vez de llamadas reales a Retrofit, y valida el StateFlow con Turbine.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo pruebas unitariamente un ViewModel que depende de corrutinas y un repositorio?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Lo que realmente te están preguntando
"¿Cómo probás unitariamente un ViewModel que depende de corrutinas y un repositorio?" en realidad son tres preguntas apiladas: cómo reemplazás Dispatchers.Main en una prueba unitaria de JVM pura donde no existe un main thread real de Android, cómo controlás el momento en que corren las corrutinas para que una aserción no se adelante a un launch que todavía no se ejecutó, y cómo reemplazás el repositorio real por algo que controlás vos. Una respuesta senior nombra las tres costuras explícitamente en lugar de describir "escribo una prueba y pasa".
#Costura 1: Dispatchers.Main no existe fuera del dispositivo
viewModelScope está fijado para lanzar sobre Dispatchers.Main.immediate. En un dispositivo o emulador real, el Looper.getMainLooper() de Android respalda ese dispatcher. En una prueba unitaria de JVM pura — sin Robolectric, sin instrumentación — ese looper no existe, así que cualquier corrutina lanzada dentro de init { viewModelScope.launch { ... } } falla de inmediato con Method getMainLooper not mocked. kotlinx-coroutines-test soluciona esto dejándote instalar un TestDispatcher como dispatcher principal durante la prueba: Dispatchers.setMain(StandardTestDispatcher()) en @Before, Dispatchers.resetMain() en @After. La mayoría de los equipos envuelven ese par en una pequeña regla de JUnit para que cada prueba de ViewModel lo reciba gratis en vez de repetir el boilerplate a mano.
#Costura 2: controlar cuándo corren realmente las corrutinas
StandardTestDispatcher — el scheduler por defecto dentro de runTest { } — no ejecuta de inmediato una corrutina recién lanzada; la encola y solo la ejecuta cuando la prueba avanza el tiempo explícitamente o cede el control. Esto importa porque una prueba ingenua que dispara una función del ViewModel y afirma un resultado en la línea siguiente puede adelantarse a un trabajo que todavía no corrió. runTest avanza el scheduler automáticamente en cuanto el cuerpo de la prueba queda inactivo, y advanceUntilIdle() te permite forzar eso explícitamente a mitad de la prueba — recurrí a él cada vez que necesites "dejar que termine todo lo que está encolado" antes de afirmar sobre el resultado.
#Costura 3: reemplazar el repositorio — pero con qué
Inyectá el repositorio como una interfaz a través del constructor, nunca construyas la implementación real de Retrofit/Room dentro del propio ViewModel. Esa única decisión es lo que hace que el resto de esta pregunta se pueda responder. Para la mayoría de las pruebas de ViewModel, un fake escrito a mano — una implementación en memoria respaldada por un MutableSharedFlow o un simple mapa — le gana a un mock: puede mantener estado real entre llamadas, emitir varios valores a lo largo del tiempo tal como lo hace el repositorio de producción, y no necesita re-stubbearse para cada forma de llamada. Recurrí a un mock (every/coEvery de MockK) solo cuando la llamada en sí no tiene un valor de retorno interesante y lo que realmente estás probando es que haya ocurrido.
#Ejemplo resuelto
interface UserRepository {fun observeUser(id: String): Flow<User>suspend fun refresh(id: String)}data class User(val id: String, val name: String)sealed interface ProfileUiState {data object Loading : ProfileUiStatedata class Success(val user: User) : ProfileUiStatedata class Error(val message: String) : ProfileUiState}class ProfileViewModel(private val repository: UserRepository,private val userId: String,) : ViewModel() {private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()init {viewModelScope.launch {repository.observeUser(userId).catch { e -> _uiState.value = ProfileUiState.Error(e.message ?: "Unknown error") }.collect { user -> _uiState.value = ProfileUiState.Success(user) }}}fun retry() {viewModelScope.launch {try {repository.refresh(userId)} catch (e: Exception) {_uiState.value = ProfileUiState.Error(e.message ?: "Refresh failed")}}}}
class FakeUserRepository : UserRepository {private val userFlow = MutableSharedFlow<User>(replay = 1)var refreshCalls = 0private setvar shouldFailRefresh = falsefun emit(user: User) = userFlow.tryEmit(user)override fun observeUser(id: String): Flow<User> = userFlowoverride suspend fun refresh(id: String) {refreshCalls++if (shouldFailRefresh) throw IOException("network down")}}@OptIn(ExperimentalCoroutinesApi::class)class ProfileViewModelTest {private val dispatcher = StandardTestDispatcher()@Beforefun setUp() = Dispatchers.setMain(dispatcher)@Afterfun tearDown() = Dispatchers.resetMain()@Testfun `emits Success once the repository publishes a user`() = runTest {val repository = FakeUserRepository()val viewModel = ProfileViewModel(repository, userId = "42")viewModel.uiState.test {assertEquals(ProfileUiState.Loading, awaitItem())repository.emit(User(id = "42", name = "Ada Lovelace"))assertEquals(ProfileUiState.Success(User("42", "Ada Lovelace")), awaitItem())cancelAndIgnoreRemainingEvents()}}@Testfun `retry surfaces a failed refresh as an error state`() = runTest {val repository = FakeUserRepository().apply { shouldFailRefresh = true }val viewModel = ProfileViewModel(repository, userId = "42")viewModel.uiState.test {awaitItem() // LoadingviewModel.retry()advanceUntilIdle()assertTrue(awaitItem() is ProfileUiState.Error)cancelAndIgnoreRemainingEvents()}assertEquals(1, repository.refreshCalls)}}
#Por qué el StateFlow necesita Turbine, no solo .value
Leer viewModel.uiState.value justo después de disparar una acción solo te dice el valor actual, lo cual engaña cada vez que un flow legítimamente pasa por varios estados — Loading y después Success — antes de asentarse. El DSL flow.test { } de Turbine colecta el flow dentro de una corrutina y te deja afirmar sobre la secuencia de emisiones una por una con awaitItem(), fallar ruidosamente con expectNoEvents() cuando un supuesto sobre la forma del stream está mal, y limpiar con cancelAndIgnoreRemainingEvents() para que el colector no siga vivo más allá de las aserciones que realmente te importan.
🧠 Check your understanding
0/3 · 0/3 answered1. Una prueba de ViewModel falla con "Method getMainLooper not mocked" apenas se construye el ViewModel. ¿Qué falta?
2. ¿Por qué StandardTestDispatcher necesita advanceUntilIdle() (o que runTest quede inactivo) para observar el efecto de una corrutina lanzada a mitad de la prueba?
3. ¿Por qué usar un repositorio fake en vez de un mock al probar un StateFlow que transiciona Loading -> Success -> Error?