How do you unit test a ViewModel that depends on coroutines and a repository?
A senior Android answer wires a fake repository and a controlled test dispatcher instead of real Retrofit calls, then asserts on StateFlow with Turbine.
LEARN BY DOINGPut your intuition to work3 steps
How do you unit test a ViewModel that depends on coroutines and a repository?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What you're actually being asked
"How do you unit test a ViewModel that depends on coroutines and a repository?" is really three questions stacked on top of each other: how do you replace Dispatchers.Main in a plain JVM unit test where no real Android main thread exists, how do you control coroutine timing so an assertion doesn't race ahead of a launch that hasn't run yet, and how do you swap the real repository for something you control. A senior answer names all three seams explicitly instead of describing "I write a test and it passes."
#Seam 1: Dispatchers.Main doesn't exist off-device
viewModelScope is hardcoded to launch on Dispatchers.Main.immediate. On a real device or emulator, Android's Looper.getMainLooper() backs that dispatcher. In a plain JVM unit test — no Robolectric, no instrumentation — there is no such looper, so any coroutine launched inside init { viewModelScope.launch { ... } } crashes immediately with Method getMainLooper not mocked. kotlinx-coroutines-test fixes this by letting you install a TestDispatcher as the main dispatcher for the duration of a test: Dispatchers.setMain(StandardTestDispatcher()) in @Before, Dispatchers.resetMain() in @After. Most teams wrap that pair in a small JUnit rule so every ViewModel test gets it for free instead of repeating the boilerplate by hand.
#Seam 2: controlling when coroutines actually run
StandardTestDispatcher — the default scheduler inside runTest { } — does not run a newly launched coroutine immediately; it queues it and only executes it once the test explicitly advances time or yields. That matters because a naive test that triggers a ViewModel function and asserts a result on the very next line can race ahead of work that hasn't executed yet. runTest auto-advances the scheduler once the test body itself goes idle, and advanceUntilIdle() lets you force that mid-test explicitly — reach for it whenever you need "let everything currently queued finish" before asserting on the result.
#Seam 3: replace the repository — but with what
Inject the repository as an interface through the constructor, never construct the real Retrofit/Room implementation inside the ViewModel itself. That single decision is what makes the rest of this question answerable at all. For most ViewModel tests a hand-written fake — an in-memory implementation backed by a MutableSharedFlow or a plain map — beats a mock: it can hold real state across calls, emit multiple values over time the way the production repository does, and doesn't need re-stubbing for every call shape. Reach for a mock (MockK's every/coEvery) only when the call itself has no interesting return value and the fact that it happened is what you're actually testing.
#Worked example
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)}}
#Why StateFlow needs Turbine, not just .value
Reading viewModel.uiState.value right after triggering an action only tells you the current value, which is misleading whenever a flow legitimately passes through several states — Loading then Success — before settling. Turbine's flow.test { } DSL collects the flow inside a coroutine and lets you assert on the sequence of emissions one at a time with awaitItem(), fail loudly with expectNoEvents() when an assumption about the stream's shape is wrong, and clean up with cancelAndIgnoreRemainingEvents() so the collector doesn't leak past the assertions you actually care about.
🧠 Check your understanding
0/3 · 0/3 answered1. A ViewModel test crashes with "Method getMainLooper not mocked" the instant the ViewModel is constructed. What's missing?
2. Why does StandardTestDispatcher require advanceUntilIdle() (or letting runTest go idle) to observe the effect of a launched coroutine mid-test?
3. Why reach for a fake repository instead of a mock when testing a StateFlow that transitions Loading -> Success -> Error?