Unit tests verify that a single piece of logic produces the right output for a given input. For Android developers, the most important things to unit test are ViewModels, use cases, and repository logic — because these hold the business rules that cannot easily be verified by looking at a screen.
MockK is the Kotlin-native mocking library. It supports coroutines, object mocking, and Kotlin-specific features that Mockito handles awkwardly.
MockK Basics
// Create a mock
val repository = mockk<UserRepository>()
// Stub a return value
every { repository.getUser("id-1") } returns User("id-1", "Akshay")
// Verify a call happened
verify { repository.getUser("id-1") }
// Verify a call never happened
verify(exactly = 0) { repository.deleteUser(any()) }
Use every to stub. Use verify to assert. Keep them separate — do not mix stubbing and verification in the same call.
Suspend Function Mocking
MockK handles suspend functions with coEvery and coVerify:
val repository = mockk<UserRepository>()
coEvery { repository.syncUsers() } returns Result.success(Unit)
// in test
coVerify { repository.syncUsers() }
Without co prefix, MockK will throw when the function is called from a coroutine.
Testing ViewModels with Coroutines
Use kotlinx-coroutines-test and replace the main dispatcher so coroutines run synchronously during tests:
@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val authRepository = mockk<AuthRepository>()
private lateinit var viewModel: LoginViewModel
@Before
fun setup() {
viewModel = LoginViewModel(authRepository)
}
@Test
fun `submit shows loading then success`() = runTest {
coEvery { authRepository.login(any(), any()) } returns Result.success(User("1", "Akshay"))
viewModel.onEmailChange("akshay@example.com")
viewModel.onPasswordChange("secure123")
viewModel.submit()
advanceUntilIdle()
val state = viewModel.state.value
assertFalse(state.isLoading)
assertNotNull(state.user)
assertNull(state.error)
}
@Test
fun `submit shows error on failure`() = runTest {
coEvery { authRepository.login(any(), any()) } returns
Result.failure(IOException("No internet"))
viewModel.submit()
advanceUntilIdle()
assertTrue(viewModel.state.value.error != null)
}
}
MainDispatcherRule replaces Dispatchers.Main with a test dispatcher so viewModelScope.launch runs on the test thread.
class MainDispatcherRule : TestWatcher() {
val testDispatcher = UnconfinedTestDispatcher()
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Testing Flow with Turbine
Turbine is a small library that makes Flow assertions readable:
@Test
fun `items update after sync`() = runTest {
val fakeItems = listOf(Item("1", "Task A"), Item("2", "Task B"))
coEvery { repository.observeItems() } returns flowOf(fakeItems)
viewModel.items.test {
assertEquals(emptyList<Item>(), awaitItem())
viewModel.sync()
assertEquals(fakeItems, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
awaitItem() suspends until the next item is emitted. cancelAndIgnoreRemainingEvents() cleans up without failing on pending items.
Fakes vs Mocks
Mocks (MockK) generate test doubles at runtime. Fakes are hand-written implementations.
Use a mock when:
- You only need to stub a return value or verify a call
- The interface is large but only a few methods are called in the test
Use a fake when:
- Multiple tests need consistent in-memory behavior
- The real behavior (like a database) needs to be simulated across calls
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override suspend fun getUser(id: String) =
users.find { it.id == id }?.let { Result.success(it) }
?: Result.failure(Exception("Not found"))
override suspend fun saveUser(user: User): Result<Unit> {
users.removeAll { it.id == user.id }
users.add(user)
return Result.success(Unit)
}
fun seed(vararg user: User) { users.addAll(user) }
}
A fake is more work to write but gives you a more realistic test subject.
Argument Matchers
// Match any string
coEvery { repository.search(any()) } returns emptyList()
// Match a specific condition
coEvery { repository.getUser(match { it.startsWith("admin") }) } returns adminUser
// Capture for later assertion
val slot = slot<String>()
coEvery { repository.search(capture(slot)) } returns emptyList()
viewModel.search("kotlin")
assertEquals("kotlin", slot.captured)
Test Naming
Use backtick function names to describe what the test verifies:
@Test
fun `submit with empty email shows validation error`() { ... }
@Test
fun `loading state is shown while request is in flight`() { ... }
Names like test1 or submitTest tell you nothing about intent.
Practice
Write tests for a SearchViewModel that has a search(query: String) function. Cover: empty query shows a validation error, a successful response updates state.results, and a network failure shows state.error. Use MockK for the repository and Turbine to assert Flow emissions.
Summary
MockK handles all Kotlin mocking patterns including suspend functions (coEvery, coVerify). Replace the main dispatcher with a test rule to make ViewModel coroutines synchronous. Use Turbine for Flow assertions. Choose fakes over mocks when you need stateful behavior across multiple operations.