MockK Fundamentals: Stubbing, Verifying, and Capturing
Replace real collaborators with controllable test doubles so you can assert behavior, not implementation.
MockK is the de facto mocking library for Kotlin. Unlike Mockito, it was written in Kotlin from the ground up, so it understands the language's idioms: final classes (which are final by default), extension functions, objects, coroutines, and nullable types all work without ceremony. In a Spring Boot service you typically want to unit-test a class in isolation by replacing its collaborators (repositories, clients, gateways) with mocks. A mock records every interaction and lets you both program its responses and assert how it was called. Add the dependency with testImplementation("io.mockk:mockk:1.13.x") and you are ready to go.
You create a mock with the inline reified function mockk<T>(). By default a MockK mock is 'strict': every method you call on it must first be stubbed, otherwise the test fails with a clear MockKException telling you which call was not configured. This strictness is a feature, not a nuisance: it forces you to be explicit about exactly which interactions your test depends on, which makes tests document the contract between a class and its collaborators. Stubbing is done with the every { } block, which captures the call you want to intercept.
Inside every { } you write the exact call you want to program, then chain returns to supply a result or throws to simulate a failure. For example, every { userRepository.findById(42L) } returns user tells the mock to hand back your fixture whenever findById is invoked with 42L. You can match any argument with the any() matcher instead of a literal, and you can chain multiple return values (returns a andThen b) to make successive calls return different things. Use throws to drive the unhappy path: every { client.fetch(any()) } throws IOException("boom") lets you assert your error handling without an unreliable network.
After exercising the code under test, you assert the interactions with verify { }. A plain verify { service.save(any()) } checks that the call happened at least once. To be precise about cardinality, pass the exactly parameter: verify(exactly = 1) { repo.save(user) } asserts it happened exactly once, and verify(exactly = 0) { repo.delete(any()) } asserts a call never occurred, which is invaluable for guarding against accidental side effects. There is also verifyOrder and verifySequence when call ordering matters. Verification and stubbing are deliberately separate steps, so your tests read as arrange, act, assert.
Strict mocks can be verbose when a collaborator has many methods you do not care about. A relaxed mock, created with mockk<T>(relaxed = true) or mockk<T>(relaxedUnitFun = true), returns sensible defaults for any un-stubbed call: zero for numbers, false for booleans, empty collections, and a fresh deep-stub for object types. The relaxedUnitFun variant only relaxes functions returning Unit, which is the common case for fire-and-forget collaborators like loggers or event publishers, while still keeping you honest about value-returning calls. Reach for relaxed mocks to reduce noise, but prefer strict mocks when you want the test to fail loudly if an unexpected method is touched.
Sometimes asserting that a method was called is not enough: you need to inspect the argument that was passed, especially when the code under test builds an object internally. That is what a CapturingSlot is for. Declare val slot = slot<Order>(), use capture(slot) in place of a matcher inside every or verify, and after the call read slot.captured to make rich assertions on the value. This is the cleanest way to verify, say, that a service constructed an Order with the correct total and a generated id. For multiple invocations use mutableListOf<Order>() with capture(list) and inspect every captured element.
MockK also has first-class coroutine support. Use coEvery { } to stub a suspend function and coVerify { } to verify it, mirroring the synchronous API exactly. So coEvery { repository.loadAsync(id) } returns user and coVerify(exactly = 1) { repository.loadAsync(id) } behave just like their blocking counterparts but can be called from inside a runTest or runBlocking block. This symmetry means you do not learn a separate mental model for asynchronous code: every becomes coEvery, verify becomes coVerify, and everything else is identical.
A few habits keep MockK tests healthy. Clear or reset shared mocks between tests with clearMocks(...) or annotate the test class with MockKExtension and use @MockK / @RelaxedMockK fields; in JUnit 5 the extension wires them up automatically. Prefer verifying behavior at the boundary (what the collaborator was asked to do) over re-implementing logic in the test. And resist over-mocking: if a dependency is a pure function or a simple data holder, use the real thing. Mocks are most valuable for I/O, time, randomness, and anything slow or non-deterministic, exactly the seams where unit tests would otherwise become flaky.
Putting it together, a typical service test arranges its mocks with every/coEvery and a few fixtures, acts by calling the public method under test, and asserts with verify(exactly = n) plus slot captures for the interesting arguments. Because MockK speaks Kotlin natively, the resulting tests are concise and read almost like a specification of how your service collaborates with the rest of the system, which is precisely what a good unit test should communicate.
import io.mockk.*import org.junit.jupiter.api.Testimport kotlin.test.assertEqualsclass UserService(private val repo: UserRepository) {fun displayName(id: Long): String {val user = repo.findById(id) ?: error("not found")return user.name.uppercase()}}class UserServiceTest {private val repo = mockk<UserRepository>() // strict: every call must be stubbedprivate val service = UserService(repo)@Testfun `returns uppercased name`() {every { repo.findById(42L) } returns User(42L, "ada")val result = service.displayName(42L)assertEquals("ADA", result)verify(exactly = 1) { repo.findById(42L) } // exact cardinalityverify(exactly = 0) { repo.save(any()) } // guard: no accidental write}}
Strict mock: stub with every { } returns / throws, then verify call counts.
import io.mockk.*import org.junit.jupiter.api.Testimport org.junit.jupiter.api.assertThrowsimport java.io.IOExceptionclass SyncService(private val client: ApiClient, private val log: Logger) {fun sync(id: Long) {log.info("syncing " + id) // fire-and-forget, returns Unitclient.fetch(id)}}class SyncServiceTest {// relaxed = true: log.info(...) needs no explicit stubprivate val log = mockk<Logger>(relaxed = true)private val client = mockk<ApiClient>()private val service = SyncService(client, log)@Testfun `propagates client failure`() {every { client.fetch(any()) } throws IOException("boom")assertThrows<IOException> { service.sync(7L) }verify { log.info(any()) } // still called before the failure}}
throws for the unhappy path, plus a relaxed mock to silence uninteresting calls.
import io.mockk.*import org.junit.jupiter.api.Testimport kotlin.test.assertEqualsclass OrderService(private val repo: OrderRepository) {fun place(customerId: Long, total: Int) {repo.save(Order(id = 0L, customerId = customerId, total = total))}}class OrderServiceTest {private val repo = mockk<OrderRepository>(relaxed = true)private val service = OrderService(repo)@Testfun `saves order with correct total`() {val slot = slot<Order>()every { repo.save(capture(slot)) } returns Unitservice.place(customerId = 99L, total = 250)val saved = slot.capturedassertEquals(99L, saved.customerId)assertEquals(250, saved.total)}}
slot captures the argument the service builds internally so you can assert on it.
import io.mockk.*import kotlinx.coroutines.test.runTestimport org.junit.jupiter.api.Testimport kotlin.test.assertEqualsclass ProfileService(private val repo: ProfileRepository) {suspend fun greet(id: Long): String = "Hi " + repo.loadAsync(id).name}class ProfileServiceTest {private val repo = mockk<ProfileRepository>()private val service = ProfileService(repo)@Testfun `greets loaded profile`() = runTest {coEvery { repo.loadAsync(1L) } returns Profile(1L, "Lin")assertEquals("Hi Lin", service.greet(1L))coVerify(exactly = 1) { repo.loadAsync(1L) }}}
Coroutine support: coEvery / coVerify mirror every / verify for suspend functions.
🧠Check your understanding
0/1 · 0/1 answered1. You write val m = mockk<UserRepository>() and your code under test calls m.findById(5L), but the test fails before reaching your assertions. What is the most likely cause?