androidengineers.Book a session

Testing & Automation

Advanced Unit Testing (MockK)

article45 minMedium

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.

YOUR LEARNING JOURNEY

0 of 17 available lessons completed

Progress saved in this browser. No account needed.
Advanced Unit Testing (MockK) | Senior Android Developer | Android Engineers