Unit Testing & MockK Explained
TESTING › Unit
Android has two flavors of test, and knowing which one you're writing matters. A **local unit test** lives in src/test, runs on your development machine's plain JVM, needs no device or emulator, and finishes in milliseconds. An **instrumented test** lives in src/androidTest, runs on a real or emulated device, and gets the real Android framework in exchange for being much slower.
This lesson is about the local, JVM-side kind, testing your business logic in isolation. The tool that makes isolation possible is **MockK**, a Kotlin-first mocking library: it lets you swap a class's real collaborators (a repository, a network client) for stand-ins you fully control, so a test exercises exactly one unit of logic instead of an entire dependency graph.
JUnit gives every test class a predictable lifecycle. @Test marks a method as a test case; @Before runs before **each** test method to build a fresh fixture, and @After runs after each one to clean up. This matters because tests should never depend on each other's leftover state.
class UserViewModelTest {
private lateinit var vm: UserViewModel
@Before
fun setUp() {
vm = UserViewModel(FakeUserRepository()) // fresh instance per test
}
@Test
fun `rejects blank name`() {
assertFailsWith<IllegalArgumentException> { vm.setName("") }
}
}
@Before runs before *every single* @Test, not once for the class, @BeforeClass (once, static) is for that. That last test also shows the idiomatic way to assert a thrown exception: assertFailsWith<T> { ... } fails the test if nothing throws or the wrong type throws, and hands back the exception for further checks. A bare try/catch that swallows the exception can pass even when the code never throws.
MockK's core building block is mockk<T>(), a fake implementation of an interface or class where nothing works until you tell it to. You stub behavior with every { ... } returns ... and later confirm a call happened with verify { ... }:
val repo = mockk<UserRepository>()
every { repo.findById(1) } returns User(1, "Ada")
val result = repo.findById(1)
verify { repo.findById(1) }
By default a mock is **strict**: any call you didn't stub throws an exception rather than silently returning null. That's usually what you want, it surfaces missing stubs immediately instead of hiding a bug behind a null.
**MockK mocks can be declared two ways, and interviews often expect the annotation form.** Instead of val repo = mockk<UserRepository>(), declare a lateinit var field annotated @MockK and initialize every such field in one call, usually inside @Before:
class UserViewModelTest {
@MockK lateinit var repo: UserRepository
@Before
fun setUp() {
MockKAnnotations.init(this) // populates every @MockK field
}
}
By default that mock is still strict, any unstubbed call throws. A **relaxed** mock flips that: mockk<T>(relaxed = true), or the @RelaxedMockK field annotation, returns sensible defaults (0, "", null, an empty collection) for anything you never stubbed, instead of throwing:
val relaxed = mockk<UserRepository>(relaxed = true)
relaxed.findById(99) // returns null, not an exception
Relaxed mocks trade the safety net of a loud failure for convenience when you only care about a couple of the mock's calls.
Real code is full of suspend functions, and MockK has dedicated variants for them: coEvery { ... } returns ... to stub, and coVerify { ... } to verify. You need to run the test body inside runTest (from kotlinx-coroutines-test) so the suspend calls actually execute:
@Test
fun `loads user`() = runTest {
val repo = mockk<UserRepository>()
coEvery { repo.getUser(1) } returns User(1, "Alice")
val vm = UserViewModel(repo)
vm.loadUser(1)
coVerify { repo.getUser(1) }
}
Swapping plain every or verify for coEvery or coVerify on a suspend function is not optional, every cannot stub a suspending call, it only understands regular ones.
**A ViewModel that launches work in viewModelScope uses Dispatchers.Main under the hood, and Dispatchers.Main does not exist on a plain JVM.** Call it from a local unit test with no setup and you get IllegalStateException: Module with the Main dispatcher had failed to initialize, Android's real Main looper simply isn't there.
kotlinx-coroutines-test fixes this with Dispatchers.setMain(...), which swaps in a test dispatcher you control, paired with Dispatchers.resetMain() afterward so it doesn't leak into other tests:
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // wraps setMain/resetMain
// or manually:
@Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) }
@After fun tearDown() { Dispatchers.resetMain() }
Wrapping the pair in a JUnit @Rule (a small custom MainDispatcherRule) is the standard pattern, since every ViewModel test needs the same setup and teardown, and a @Rule guarantees resetMain() still runs even if the test fails.
**A single returns value is a snapshot, but some tests need a stub whose result changes across calls.** andThen chains multiple return values onto one every block; each call to the mocked method advances to the next value, and the last one repeats once the chain is exhausted:
every { counter.next() } returns 1 andThen 2 andThen 3
counter.next() // 1
counter.next() // 2
counter.next() // 3
counter.next() // 3 again, the last value repeats
For a return value that depends on the arguments passed in, answers { } runs a lambda with access to the call and computes the result dynamically instead of returning a fixed value:
every { repo.double(any()) } answers { firstArg<Int>() * 2 }
repo.double(5) // 10
Stacking a second plain every block on the same call doesn't accumulate, it just overwrites the first stub entirely.
Sometimes verifying *that* a call happened isn't enough, you need to inspect *what was passed*. A slot<T>() captures an argument so you can assert on it after the fact:
val slot = slot<User>()
every { dao.insert(capture(slot)) } returns Unit
subject.createUser("Alice")
assertEquals("Alice", slot.captured.name)
capture() wires the slot into the stub's argument matcher, and after the call runs, slot.captured holds the real object that was passed in. If the mock might be called more than once and you want every argument, capture into a mutableListOf<T>() instead of a single slot.
MockK has three flavors of order verification, and mixing them up is a classic interview slip. verifyOrder { ... } checks the listed calls happened in that relative order, but tolerates other calls in between. verifySequence { ... } is strict: exactly those calls, in that order, nothing else on the mock. verifyAll { ... } checks all listed calls happened, in **any** order.
verifyOrder {
mock.foo()
mock.bar()
} // fine even if mock.baz() also happened somewhere in between
verifySequence {
mock.foo()
mock.bar()
} // fails if anything else touched this mock at all
Reach for verifyOrder most of the time, it's the least brittle. verifySequence is for when the exact call script genuinely matters.
**A mock has no real implementation behind it, a spyk does.** spyk(RealService()) wraps an actual object, and by default every call falls through to that real implementation. You only intercept the specific methods you explicitly stub or verify, everything else runs for real:
val service = spyk(RealService())
val result = service.foo() // runs the REAL foo()
every { service.bar() } returns "mocked"
service.bar() // now returns "mocked", intercepted
That makes spyk useful for testing a class where most of the behavior is fine as-is but one method needs to be swapped out, without hand-writing a fake for the whole interface. It's also easy to misuse: leaning on a spyk around your own logic instead of just calling the real object directly reintroduces some of the same brittleness mocks have, so reach for it at boundaries, not as a default.
**Once a test has several @MockK fields, wiring them into the class under test by hand gets repetitive.** @InjectMockKs automates that: annotate a lateinit var for the subject under test, and after MockKAnnotations.init(this) runs, MockK constructs it and injects every matching @MockK field into its constructor (or settable properties) for you:
class UserViewModelTest {
@MockK lateinit var repo: UserRepository
@MockK lateinit var logger: Logger
@InjectMockKs
lateinit var vm: UserViewModel // built as UserViewModel(repo, logger)
@Before
fun setUp() { MockKAnnotations.init(this) }
}
Matching is by type (falling back to name when types collide), so UserViewModel's constructor parameters need to line up with the declared @MockK fields. It still depends on those mocks being initialized first, @InjectMockKs only builds and wires, it doesn't replace MockKAnnotations.init(this).
Not every test double should be a mock. A **fake** is a real, hand-written implementation, an in-memory FakeUserRepository that actually stores and returns users, not a scripted stub:
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override suspend fun save(user: User) { users.add(user) }
override suspend fun findById(id: Int) = users.find { it.id == id }
}
For collaborators **you own** (repositories, use cases), Google's testing guidance recommends fakes over mocks: a fake tests real behavior, so it stays correct when implementation details change. A mock couples the test to exact call patterns, breaking it on harmless refactors even when behavior is unchanged. Save mocks for boundaries you don't control, or when you specifically need to verify an interaction happened.
The last piece is judgment: what's actually worth unit-testing? Target your **business logic**, state transformations, and edge cases, code where a bug would be a real bug. Skip **framework code** you don't own (the Android SDK itself), trivial getters or setters, and plain data classes with no logic, a test there just restates the implementation.
None of this works without **dependency injection**. A class that constructs its own UserRepository internally can never swap in a fake; a class that takes one through its constructor can. That's the whole reason testable Android architecture pushes logic out of Activity or Fragment and into plain classes wired by DI: it's not a style preference, it's what makes fast, isolated JVM tests possible at all.