Unit Testing & MockK Explained
TESTING › Unit
Android testing splits into two very different worlds. A local unit test lives under src/test, runs on your development machine's plain JVM, needs no device or emulator, and finishes in milliseconds. An instrumented test lives under src/androidTest, runs on a real or emulated device, and gets the actual Android framework in exchange for being far slower to run.
This lesson stays on the JVM side: testing your business logic in isolation, fast, and by the hundreds. The tool that makes that 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 dragging in an entire dependency graph. Interviewers ask about this constantly because it separates candidates who write real regression coverage from candidates who only click through the app by hand.
JUnit gives every test class a predictable lifecycle. @Test marks a method as a test case. @Before runs before each individual test method, not once for the whole class, so every test gets a fresh fixture instead of leaking state from the test that ran before it. @After runs after each test to clean up. @BeforeClass is the one that runs a single time for the whole class, and it has to be static.
The other lifecycle piece worth knowing cold is how you assert that code throws. The idiomatic Kotlin way is assertFailsWith<T> { ... }: it runs the block, fails the test if nothing throws, fails it if the wrong exception type throws, and hands back the exception for further checks. A bare try/catch that just swallows the exception is a trap, it can pass even when the code never throws at all, because an empty catch block silently absorbs the failure.
class UserViewModelTest {
private lateinit var vm: UserViewModel
@Before
fun setUp() {
vm = UserViewModel(FakeUserRepository())
}
@Test
fun `rejects blank name`() {
assertFailsWith<IllegalArgumentException> { vm.setName("") }
}
}
MockK's core building block is mockk<T>(), a stand-in for an interface or class where nothing works until you tell it to. You script behavior with every { ... } returns ..., and later confirm a call actually happened with verify { ... }. When the number of invocations itself matters, verify also takes a count, verify(exactly = 2) { ... }, or atLeast/atMost bounds, instead of just checking whether a call happened at all.
By default a MockK mock is strict. Any call you never stubbed throws an exception instead of quietly returning null or zero. That is usually exactly what you want in a test: a missing stub surfaces immediately as a loud failure, rather than hiding a real bug behind a null value that only breaks something three lines later.
val repo = mockk<UserRepository>()
every { repo.findById(1) } returns User(1, "Ada")
val result = repo.findById(1)
verify { repo.findById(1) }
Interviews often expect the annotation style of declaring a mock, not the inline mockk<T>() call. Declare a lateinit var field annotated @MockK, then initialize every such field in one call, usually inside @Before, with MockKAnnotations.init(this). Miss that call and the field is never actually a mock, it just throws an uninitialized-property exception the moment a test touches it.
This is also a naming trap worth knowing cold: @Mock and Mockito.openMocks() are Mockito's API, not MockK's. MockK's annotation is @MockK, and its initializer is MockKAnnotations.init(this). Mixing the two libraries' names up mid-interview is a quick way to look like you've memorized syntax without understanding either tool.
class UserViewModelTest {
@MockK lateinit var repo: UserRepository
@Before
fun setUp() {
MockKAnnotations.init(this)
}
}
Sometimes you only care about two or three calls on a mock and stubbing every single method it might receive is wasted effort. A relaxed mock flips MockK's default strictness: create it with mockk<T>(relaxed = true), or declare the field @RelaxedMockK instead of @MockK, and any call you never stubbed returns a sensible default, zero, an empty string, an empty collection, or null for an object type, instead of throwing.
That convenience has a cost. A relaxed mock can hide a genuine bug: if you forgot to stub a call that actually matters, the test keeps passing on a silent default instead of failing loudly. Reach for relaxed mocks when a class has many collaborator calls and you truly only care about a handful, not as a default habit.
val strict = mockk<UserRepository>()
// strict.findById(99) would throw
val relaxed = mockk<UserRepository>(relaxed = true)
val result = relaxed.findById(99)
Real code is full of suspend functions, and plain every/verify cannot stub or check them, they only understand regular calls. MockK's coroutine variants exist for exactly this: coEvery { ... } returns ... to stub a suspend function, and coVerify { ... } to confirm it was called.
You also need to run the test body inside runTest, from kotlinx-coroutines-test, so the suspend calls actually execute on a controlled test scheduler instead of suspending forever with nothing to resume them. Marking the test function itself suspend is not the fix, JUnit doesn't know how to run a suspending test method, runTest is what bridges a regular test method into coroutine-land.
@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) }
}
A ViewModel that launches work in viewModelScope is launching onto Dispatchers.Main under the hood, and Dispatchers.Main simply does not exist on a bare JVM. Call that code from a local unit test with no setup and you get a crash reporting that the Main dispatcher module failed to initialize, because Android's real Main looper was never there to begin with.
kotlinx-coroutines-test fixes this with Dispatchers.setMain(...), which swaps in a test dispatcher you control, paired with Dispatchers.resetMain() afterward so the substitution doesn't leak into other tests. The standard pattern wraps that pair in a small JUnit @Rule, often called MainDispatcherRule, because every ViewModel test needs the identical setup and teardown, and a @Rule guarantees resetMain() still runs even if the test itself fails partway through. LiveData has the same class of problem in miniature: without InstantTaskExecutorRule, posting a value to a MutableLiveData in a unit test schedules work on a background executor the test never pumps, so that rule swaps in an executor that runs each task synchronously and inline instead.
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
// or manually:
@Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) }
@After fun tearDown() { Dispatchers.resetMain() }
A single returns value is a snapshot, but some tests need a stub whose result changes across successive calls. andThen chains multiple return values onto one every block: the first call gets the first value, the second call advances to the next, and once the chain runs out the last value just keeps repeating.
Stacking a second, separate every block on the same call is a common mistake. It does not accumulate into a sequence, it just overwrites the first stub entirely, so only the second block's value ever comes back. If you need a value that depends on the actual arguments passed in rather than a fixed sequence, answers { } runs a lambda with access to the call and computes the result dynamically instead.
every { counter.next() } returns 1 andThen 2 andThen 3
counter.next() // 1
counter.next() // 2
counter.next() // 3
counter.next() // 3 again
Verifying that a call happened isn't always enough, sometimes you need to inspect exactly what was passed in. A slot<T>() captures an argument so you can assert on it after the fact: wire it into the stub with capture(slot), run the code under test, and then slot.captured holds the real object that was actually passed.
If the mock might receive the same call more than once and you want every argument, not just the last one, capture into a mutableListOf<T>() instead of a single slot, MockK appends each captured value to the list in call order.
val slot = slot<User>()
every { dao.insert(capture(slot)) } returns Unit
subject.createUser("Alice")
assertEquals("Alice", slot.captured.name)
MockK has three flavors of order verification, and mixing them up is a classic interview slip. verifyOrder { ... } checks that the listed calls happened in that relative order, but tolerates other calls happening in between them. verifySequence { ... } is strict, it demands exactly those calls, in that order, and nothing else touched the mock at all. verifyAll { ... } checks that every listed call happened, but in any order.
Reach for verifyOrder most of the time, it is the least brittle of the three and survives incidental extra calls that don't matter to the behavior you're testing. Save verifySequence for the rare case where the exact call script genuinely is the thing under test.
verifyOrder {
mock.foo()
mock.bar()
} // fine even if mock.baz() also happened in between
verifySequence {
mock.foo()
mock.bar()
} // fails if anything else touched this mock at all
A plain mock has no real implementation behind it at all, but 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 genuinely runs.
That makes spyk useful when a class is fine as-is except for one method you need to swap out for a test, without hand-writing a fake for the whole interface. It's also easy to overuse: reaching for a spy around your own logic instead of just calling the real object directly reintroduces some of the same brittleness plain mocks have, so treat it as a tool for boundaries, not a default choice.
val service = spyk(RealService())
val result = service.foo() // runs the REAL foo()
every { service.bar() } returns "mocked"
service.bar() // now intercepted
Once a test has several @MockK fields, wiring them into the class under test by hand gets repetitive fast. @InjectMockKs automates exactly that: annotate a lateinit var for the subject under test, and after MockKAnnotations.init(this) runs, MockK constructs that object and injects every matching @MockK field into its constructor, or its settable properties, for you.
Matching happens by type, falling back to name when two fields share a type, so the constructor parameters of the class under test need to line up with the declared mock fields. @InjectMockKs still depends on those mocks being initialized first, it builds and wires the object, it does not replace the call to MockKAnnotations.init(this).
@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) }
Not every test double should be a mock, and knowing the vocabulary matters in an interview. A dummy is passed in but never actually used. A stub returns canned answers and nothing more. A fake is a real, simplified, working implementation, an in-memory FakeUserRepository that genuinely stores and returns users rather than reciting a script. A spy wraps real behavior while recording calls. A mock is a generated object configured to return values and verify interactions happened.
For collaborators you own, repositories, use cases, Android's testing guidance recommends fakes over mocks. A fake exercises real behavior, so it keeps passing when an implementation detail changes but the observable behavior doesn't. A mock couples the test to exact call patterns, which breaks the test on a harmless refactor even though nothing actually went wrong. Save mocks for boundaries you genuinely don't control, or for the specific moments you need to verify an interaction took place rather than a result.
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 }
}
The last piece is judgment: what's actually worth a unit test? Target business logic, state transformations, and edge cases, code where a bug would be a real, user-visible bug. Skip framework code you don't own, the Android SDK itself, trivial getters or setters that just return a backing field, and plain data classes with no logic. A test on a trivial getter has no independent behavior to check, it just restates the implementation right back at itself.
None of this works without dependency injection. A class that constructs its own UserRepository internally can never have a fake or a mock swapped in underneath it. A class that takes one through its constructor can. That's the real reason testable Android architecture pushes logic out of Activity and Fragment and into plain classes wired by DI, it isn't a style preference, it's the precondition for fast, isolated JVM tests existing at all.