Mocks vs fakes vs stubs in mobile testing — when do you actually reach for each?
Stubs answer 'what did it return,' mocks answer 'was it called,' and fakes are the only one of the three with real, working state.
LEARN BY DOINGPut your intuition to work3 steps
Mocks vs fakes vs stubs in mobile testing — when do you actually reach for each?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#One umbrella term, three everyday tools
"Test double" is the umbrella term — Gerard Meszaros' original taxonomy actually names five: dummy, stub, spy, fake, and mock — but day-to-day mobile work only needs three of them to reason clearly about a test: stub, fake, and mock. The distinction that matters isn't vocabulary trivia. It's whether your test is checking a value your code produced (state-based testing) or checking that your code made a particular call (behavior-based testing), and each double exists for one of those two jobs.
#Stub: a canned answer, nothing more
A stub returns a pre-programmed value when called and nothing else — your test doesn't care how many times it was called or in what order, only that it handed back something you can build an assertion on. coEvery { api.currentWeather("Lima") } returns Weather(city = "Lima", sunny = true) is a stub: it exists purely to get your ViewModel into the state you want to assert on. Reach for a stub whenever the collaborator's return value drives a branch and the call itself isn't the interesting part.
#Fake: a smaller, real implementation
A fake is a genuine working implementation, just a lighter one than production — an in-memory map standing in for Room, a plain list standing in for a network queue. Unlike a stub, a fake has real state that persists and evolves across calls within a test, which makes it the right tool whenever a sequence matters: cache a value then read it back, seed several records then filter them, emit "loading" then "success" from the same collaborator over time. A fake costs more to write once, and then gets reused across every test that needs that collaborator, while a stub is usually rewritten per test.
#Mock: proving a call happened
A mock exists to verify behavior, not to hand back a value your code needs to keep going — you set an expectation, exercise the code, then assert the interaction happened: verify(exactly = 1) { tracker.track("checkout_completed", ...) }. Reach for a mock specifically when there is no return value to assert on and the fact of the call is the behavior under test: analytics events, crash-reporter breadcrumbs, a push token being registered, a navigation command fired exactly once.
#Where this goes wrong
The over-mocking failure mode is mocking a collaborator that has real, testable behavior — a cache, a validator, a mapper — just because a mocking library makes it easy, then asserting the mock was called with specific arguments in a specific order. That test now knows the implementation of the collaboration, not the outcome, and it breaks on any refactor that doesn't change behavior at all — exactly the brittleness a mobile team can't afford once a codebase has hundreds of ViewModels and screens.
#Platform note: iOS ships no mocking library
Kotlin/MockK generates dynamic mocks at runtime because the JVM allows bytecode manipulation of open classes. Swift's static dispatch and value types make that approach far harder, so XCTest ships with no built-in mocking framework at all — iOS teams either hand-write fakes and stubs conforming to a protocol (the idiomatic approach, and often the better one regardless) or reach for a code-generation tool like Mockolo or Cuckoo that produces mock conformances at build time. Naming that tradeoff out loud — "on iOS I'd hand-roll a protocol-conforming fake before reaching for a mocking library" — is itself a signal of platform fluency.
#Worked example, side by side
interface WeatherApi {suspend fun currentWeather(city: String): Weather}interface AnalyticsTracker {fun track(event: String, params: Map<String, String> = emptyMap())}data class Weather(val city: String, val sunny: Boolean)// STUB -- a canned answer, no call verification needed.class WeatherViewModelStubTest {private val api = mockk<WeatherApi>()@Testfun `shows sunny banner when the api reports clear skies`() = runTest {coEvery { api.currentWeather("Lima") } returns Weather(city = "Lima", sunny = true)val viewModel = WeatherViewModel(api)viewModel.load("Lima")assertEquals(WeatherUiState.Sunny, viewModel.uiState.value)// No coVerify here -- we don't care HOW it was called, only what it returned.}}// FAKE -- a real, working, smaller implementation with actual state.class InMemoryWeatherApi : WeatherApi {private val cannedResponses = mutableMapOf<String, Weather>()fun seed(city: String, weather: Weather) {cannedResponses[city] = weather}override suspend fun currentWeather(city: String): Weather =cannedResponses[city] ?: error("No fixture seeded for $city")}// MOCK -- behavior verification: the point of the test IS that the call happened.class CheckoutAnalyticsTest {private val tracker = mockk<AnalyticsTracker>(relaxUnitFun = true)@Testfun `tracks checkout_completed exactly once with the order total`() {val checkout = CheckoutFlow(tracker)checkout.complete(orderTotal = 42.50)verify(exactly = 1) {tracker.track("checkout_completed", mapOf("total" to "42.50"))}}}
🧠 Check your understanding
0/3 · 0/3 answered1. A test stubs a repository call to return a fixed value and never checks whether or how many times it was invoked. That double is functioning as a:
2. You need to test that a repository correctly caches a value on write and returns the cached value on a subsequent read, without hitting the network twice. What's the best double?
3. Why does iOS testing culture lean toward hand-written fakes over a mocking library, unlike typical Kotlin/JVM testing?