Fakes vs Mocks: Choosing Your Test Doubles
Fakes prove behavior, mocks prove conversations โ pick the double that matches what you're actually testing.
A unit test rarely runs your code in isolation โ your service talks to a repository, a clock, a message bus, an HTTP client. A *test double* stands in for those collaborators so the test stays fast, deterministic, and focused. The two doubles you will reach for most often in Kotlin/Spring are **fakes** and **mocks**, and confusing them is the single most common cause of brittle, low-value test suites. A fake is a real, working implementation that takes a shortcut โ an in-memory `MutableMap` instead of Postgres. A mock is a programmable stand-in whose calls you record and verify. Both replace a dependency, but they answer different questions.
A **fake** is *state-based*: you exercise it like the real thing and then assert on the observable outcome. An in-memory repository actually stores what you save and returns it on `findById`. Your test reads almost like production code โ call the service, then check the resulting state โ and it survives refactoring, because it never hardcodes *how* the service uses the repository, only *what* the end result must be. The cost is that you have to write and maintain the fake, and it must honor the same contract as the real implementation (same uniqueness rules, same null behavior). That up-front cost pays off when many tests share the dependency.
A **mock** is *interaction-based*: it records the calls made against it and lets you verify them. With MockK you stub return values (`every { repo.findById(1) } returns user`) and assert that specific interactions happened (`verify { bus.publish(any()) }`). Mocks shine when the *interaction itself* is the behavior you care about โ "did we publish exactly one event?", "did we never hit the network when the cache was warm?" โ or when a real collaborator is genuinely awkward to stand up. The danger is over-specification: tests that assert every call become change-detectors that break on harmless refactors without catching real bugs.
A useful rule of thumb: **use a fake for queries, a mock for commands you can't otherwise observe.** If you can assert the result by reading state back, prefer the fake โ it tests outcomes, not implementation. Reach for a mock when the side effect leaves no observable trace in your test boundary (an email sent, an event published, a third-party call) or when the collaborator is non-deterministic, slow, or hard to construct. Avoid mocking types you don't own โ wrap that SDK behind your own interface and fake or mock *that*, so a vendor API change doesn't ripple through hundreds of stubs.
Both doubles live inside the **test pyramid**: a wide base of fast, isolated unit tests (where fakes and mocks belong), a thinner middle of integration tests that wire real components together (a real repository against Testcontainers Postgres, a `@SpringBootTest` slice), and a few end-to-end tests at the top. Doubles are a unit-test tool; they let you keep that base wide and fast. They are *not* a substitute for integration tests โ a fake repository can never catch a broken SQL query or a bad JPA mapping. That is exactly why an integration test must verify the real implementation your fake stands in for.
Whatever double you choose, structure every test with **Arrange-Act-Assert** (AAA, also called Given-When-Then). *Arrange* builds the system under test and its doubles and seeds any starting state. *Act* performs exactly one call โ the single behavior under test. *Assert* checks the outcome (state for fakes, interactions for mocks). Keeping these phases visually separate, with one logical action per test, makes failures self-explanatory and stops tests from quietly verifying three things at once. A test that has two Act sections is usually two tests wearing a trenchcoat.
Coroutines add one wrinkle: suspend functions. Use `runTest` from `kotlinx-coroutines-test` to drive suspending code with a virtual clock that auto-advances delays, so a function that `delay(10_000)` finishes instantly. MockK supports suspend functions natively with `coEvery { }` and `coVerify { }` โ use those instead of `every`/`verify` whenever the stubbed function is `suspend`. A fake simply marks its methods `suspend` to match the interface; no special machinery needed, which is one more quiet point in the fake's favor.
In practice, a healthy Kotlin/Spring suite mixes both. Default to fakes for your repositories and domain collaborators โ they read well, refactor safely, and double as living documentation of the contract. Reserve mocks for the genuine seams where you must assert that a message left the building, and keep those verifications minimal: assert the one interaction that defines correctness, not every incidental call. Pair that discipline with strict AAA and a real integration test behind every fake, and your pyramid stays fast at the bottom, trustworthy in the middle, and cheap to change all the way up.
import kotlinx.coroutines.runBlockingimport java.util.concurrent.ConcurrentHashMapimport java.util.concurrent.atomic.AtomicLongdata class User(val id: Long = 0, val email: String)interface UserRepository {suspend fun save(user: User): Usersuspend fun findByEmail(email: String): User?}// The fake: honors the same contract (unique email) as the real repo.class FakeUserRepository : UserRepository {private val store = ConcurrentHashMap<Long, User>()private val seq = AtomicLong(0)override suspend fun save(user: User): User {val existing = findByEmail(user.email)require(existing == null || existing.id == user.id) { "email already taken: " + user.email }val saved = if (user.id == 0L) user.copy(id = seq.incrementAndGet()) else userstore[saved.id] = savedreturn saved}override suspend fun findByEmail(email: String): User? =store.values.firstOrNull { it.email == email }}fun main() = runBlocking {val repo = FakeUserRepository()val saved = repo.save(User(email = "ada@calc.io"))check(saved.id != 0L) { "id should be assigned" }check(repo.findByEmail("ada@calc.io") == saved)println("fake works: " + saved)}
A FAKE in-memory repository. It is a real, working implementation of the same interface the production JPA repository implements โ so the service can't tell the difference. State-based: you save, then read back. Runnable on plain kotlin + coroutines (no Spring, no DB).
Arena IDEimport kotlinx.coroutines.test.runTestimport org.junit.jupiter.api.Testimport kotlin.test.assertEqualsimport kotlin.test.assertFailsWithclass RegistrationService(private val users: UserRepository) {suspend fun register(email: String): User {users.findByEmail(email)?.let { error("already registered") }return users.save(User(email = email))}}class RegistrationServiceTest {@Testfun `registers a brand-new user`() = runTest {// Arrangeval repo = FakeUserRepository()val service = RegistrationService(repo)// Actval result = service.register("grace@navy.mil")// Assert (on observable state, not interactions)assertEquals("grace@navy.mil", result.email)assertEquals(result, repo.findByEmail("grace@navy.mil"))}@Testfun `rejects a duplicate email`() = runTest {val repo = FakeUserRepository().also { it.save(User(email = "dup@x.io")) }val service = RegistrationService(repo)assertFailsWith<IllegalStateException> { service.register("dup@x.io") }}}
STATE-BASED test using the fake, written with strict Arrange-Act-Assert. We assert the OUTCOME (the user is persisted and retrievable), never how the service called the repo โ so it survives refactoring. Uses JUnit5 + kotlin.test + runTest, so it is not browser-runnable.
import io.mockk.coEveryimport io.mockk.coVerifyimport io.mockk.mockkimport io.mockk.slotimport kotlinx.coroutines.test.runTestimport org.junit.jupiter.api.Testimport kotlin.test.assertEqualsdata class UserRegistered(val email: String)interface EventBus { suspend fun publish(event: UserRegistered) }class NotifyingRegistration(private val users: UserRepository,private val bus: EventBus,) {suspend fun register(email: String): User {val saved = users.save(User(email = email))bus.publish(UserRegistered(saved.email))return saved}}class NotifyingRegistrationTest {@Testfun `publishes exactly one UserRegistered event`() = runTest {// Arrange: fake for the query collaborator, mock for the command seamval repo = FakeUserRepository()val bus = mockk<EventBus>()val captured = slot<UserRegistered>()coEvery { bus.publish(capture(captured)) } returns Unitval service = NotifyingRegistration(repo, bus)// Actservice.register("linus@kernel.org")// Assert: the interaction IS the behavior under testcoVerify(exactly = 1) { bus.publish(any()) }assertEquals("linus@kernel.org", captured.captured.email)}}
INTERACTION-BASED test using MockK. Here the behavior we care about is a side effect with no observable trace in the test boundary: publishing exactly one event. We stub with coEvery (the function is suspend) and assert with coVerify. Note we mock the seam we OWN (EventBus), not a vendor SDK.
๐ง Check your understanding
0/1 ยท 0/1 answered1. Your service saves a user and you want to assert the user can be retrieved afterward. Which test double best fits, and why?