Fakes Over Mocks: Tests That Survive a Refactor
A mock encodes how your code calls its dependencies; a fake encodes what those dependencies do. One breaks on every refactor, the other doesn't. When to write a fake, when a mock is genuinely correct, and how to keep fakes honest.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — A mock asserts interactions: that you called this method with these arguments. That's a statement about your current implementation, so it breaks whenever you change how the code is organised — even when behaviour is identical. A fake is a working implementation of the interface, written once, that lets tests assert observable outcomes. Prefer fakes. Keep mocks for cases where the interaction genuinely is the requirement.
The test that quietly taxes you
@Test fun `load fetches the user`() {
whenever(repository.getUser("1")).thenReturn(user)
viewModel.load("1")
verify(repository).getUser("1")
assertEquals("Hessam", viewModel.state.value.name)
}
Now make a change that improves the code without changing behaviour: switch getUser to return a
Flow so the screen updates live. Or add a cache so a repeat visit doesn't hit the network. Or batch
two lookups into one call.
Every one of those turns this test red, and none of them changed what the user sees. So you edit the test to match the new implementation — and a test you edit to match the code has stopped being a check on the code.
Multiply by forty tests and you have a suite that makes refactoring expensive, which is the precise opposite of what tests are for.
What a fake looks like
A fake is a real implementation with the production concerns removed — in-memory instead of a database, deterministic instead of networked:
class FakeUserRepository : UserRepository {
private val users = MutableStateFlow(emptyMap<String, User>())
private var failNext = false
// Test-only controls
fun seed(vararg user: User) { users.update { it + user.associateBy(User::id) } }
fun failNextWrite() { failNext = true }
override fun observe(id: String): Flow<User?> = users.map { it[id] }
override suspend fun save(user: User) {
if (failNext) { failNext = false; throw IOException("boom") }
users.update { it + (user.id to user) }
}
}
You write this once per interface. Tests then read as scenarios rather than call traces:
@Test fun `the screen shows a seeded user`() = runTest {
repository.seed(user)
val state = viewModel.state.first { it !is Loading }
assertEquals("Hessam", state.name)
}
@Test fun `a failed save keeps the draft and offers retry`() = runTest {
repository.failNextWrite()
viewModel.save(draft)
assertEquals(UiState.Error(RETRY), viewModel.state.value)
assertEquals(draft, viewModel.draft)
}
Neither test mentions how the ViewModel calls the repository. Both would survive every refactor listed above.
Why fakes hold up
They survive refactors. The fake behaves like a repository; how you consume it is your business.
They read like the product. "Given a seeded user, the screen shows their name" is reviewable by
someone who doesn't know the implementation. verify(repo).getUser("1") isn't.
They catch bugs mocks can't. A mock returns whatever you stubbed, in the order you stubbed it. A fake has real state, so it surfaces ordering problems, double-writes, and stale-read bugs — exactly the defects that reach production.
They amortise. One fake serves forty tests. Forty mocks get re-stubbed forty times, and each stub is another chance to encode an assumption that isn't true.
When a mock is the right call
Fakes aren't a religion. Use a mock when the interaction is the requirement:
// The observable behaviour IS the call. There's no state to inspect.
@Test fun `a completed purchase is logged exactly once`() {
viewModel.completePurchase(order)
verify(analytics, times(1)).log(PurchaseCompleted(order.id))
}
Analytics, payment capture, push registration, audit logging — these are fire-and-forget side effects with no queryable result. Verifying the call is the only way to test them, and the interaction is genuinely what the product requires.
The distinction: mock what you can't observe, fake what you can. If the collaborator has state you could inspect, inspect it.
Keeping fakes honest
The real risk with fakes is drift — a fake that behaves differently from production, so tests pass while the app breaks. Two habits contain it:
Share the contract test. Write one test suite against the interface and run it against both the fake and the real implementation:
abstract class UserRepositoryContract {
abstract fun create(): UserRepository
@Test fun `save then observe returns the saved user`() = runTest { /* ... */ }
@Test fun `observing an unknown id emits null`() = runTest { /* ... */ }
}
class FakeUserRepositoryTest : UserRepositoryContract() { override fun create() = FakeUserRepository() }
class RoomUserRepositoryTest : UserRepositoryContract() { override fun create() = RoomUserRepository(db) }
Any behaviour the fake gets wrong shows up as a failing contract test rather than a production incident.
Keep fakes in a shared test module, next to the interface, owned by whoever owns the interface. A fake copied into three modules diverges into three different fakes within a quarter.
The rule
Test doubles are a design decision, not a tooling one. Ask what the test should still be true after a refactor — and write the double that lets you say it. Nine times in ten that's a fake; the tenth time, when the call itself is the product behaviour, reach for a mock and mean it.