Skip to content
All posts
March 16, 20265 min read

Mock vs Fake vs Stub: Choosing the Right Test Double

Mocks, fakes, stubs, and spies are test doubles — but they're not interchangeable. Using the wrong one leads to over-specified tests that break on refactoring or under-specified tests that miss bugs. Here's when to use each.

TestingKotlinBest Practices
Share:

The terms "mock," "fake," and "stub" are often used interchangeably in the wild. They're not the same thing, and the distinction matters for test design.

Using the wrong test double leads to brittle tests, tests that miss bugs, or tests that test the framework instead of your code.


The Taxonomy

Stub — returns hard-coded responses. No behavior. No verification.

Fake — a simplified working implementation. Has real behavior, simplified for testing.

Mock — a fake that also records interactions for later verification. The test asserts that specific methods were called.

Spy — a wrapper around a real object that records calls. Same behavior as real, but observable.


Stubs: Simple Return Values

A stub answers questions with pre-configured responses:

kotlin
// Stub using MockK
val userRepository = mockk<UserRepository>()
every { userRepository.findById("user-1") } returns User("user-1", "Alice", "alice@example.com")

// The stub doesn't care how many times findById is called or with what else

Use stubs when:

  • You need a dependency to return a specific value
  • You don't care whether or how the dependency was called
  • You're testing the behavior of the code under test, not its interactions

Fakes: Working Implementations

A fake is a real implementation that's simpler than the production version:

kotlin
class FakeUserRepository : UserRepository {
    private val users = mutableMapOf<String, User>()
    
    override suspend fun findById(id: String): User? = users[id]
    
    override suspend fun save(user: User) {
        users[user.id] = user
    }
    
    override suspend fun delete(id: String) {
        users.remove(id)
    }
    
    // Test helper — not in the interface
    fun add(user: User) = users.put(user.id, user)
    fun count() = users.size
}

Fakes are valuable because:

  • They support multiple interactions realistically
  • State is consistent across calls
  • No mock framework overhead
  • They can be reused across many tests

In-memory databases are the classic fake: Room's in-memory database builder creates a fake that behaves exactly like the real database — just without persistence.

Use fakes when:

  • Multiple tests use the same dependency
  • You need stateful behavior (insert, then retrieve)
  • The dependency is complex and has multiple methods

Mocks: Verify Interactions

Mocks track how they were used. You assert on those interactions:

kotlin
val emailService = mockk<EmailService>()
every { emailService.sendWelcomeEmail(any()) } just Runs

val authService = AuthService(emailService)
authService.registerUser("alice@example.com")

// Verify the mock was called correctly
verify { emailService.sendWelcomeEmail("alice@example.com") }
verify(exactly = 1) { emailService.sendWelcomeEmail(any()) }

Use mocks when:

  • You need to verify that a side effect happened (email sent, event published, log written)
  • The interaction itself is the behavior being tested
  • The return value doesn't matter — the call matters

[!WARNING] Mocks that verify every method call are over-specified. If you refactor the implementation but keep the same behavior, the test breaks because the interaction pattern changed. Verify the calls that are semantically important, not all calls.


Spies: Real Object With Observation

A spy wraps a real object and records calls without changing behavior:

kotlin
val realRepository = TaskRepositoryImpl(realDatabase)
val spyRepository = spyk(realRepository)

taskService.processTask("task-1", repository = spyRepository)

// Verify the real method was called
verify { spyRepository.getTask("task-1") }
verify { spyRepository.update(any()) }

Spies are useful when:

  • You need real behavior but want to verify specific calls happened
  • You're adding tests to existing code and don't want to refactor yet

Spies are also the most dangerous test double — they use real implementations, so test isolation is weaker.


The Decision Framework

code
Does the test need to verify a specific call was made?
├── Yes → Mock
└── No

Does the test need stateful behavior across multiple calls?
├── Yes → Fake
└── No

Does the test just need a specific return value?
└── Stub

Common Mistakes

Mocking everything. When every dependency in a test is a mock, you end up testing the interaction protocol, not the behavior. A mock-heavy test breaks when you refactor — even if behavior is unchanged.

kotlin
// Over-mocked — tests interactions, not behavior
val dao = mockk<TaskDao>()
val cache = mockk<TaskCache>()
val logger = mockk<TaskLogger>()

every { dao.getTask("1") } returns taskEntity
every { cache.get("1") } returns null
every { logger.log(any()) } just Runs

taskRepository.getTask("1")

verify { dao.getTask("1") }
verify { cache.get("1") }
verify { logger.log(any()) } // Who cares if it logs?

Using fakes for interaction verification. Fakes don't record calls. If you need to verify a call happened, use a mock.

Using mocks instead of fakes for state. Configuring mock return values for every possible call sequence becomes unmanageable. Use a fake with real state.


In Practice

kotlin
class TaskServiceTest {
    // Fake — stateful, reusable, supports multiple operations
    private val repository = FakeTaskRepository()
    
    // Stub — only needs to return a value
    private val clock = mockk<Clock>()
    
    // Mock — need to verify the email was sent
    private val notifier = mockk<NotificationService>()
    
    private val service = TaskService(repository, clock, notifier)
    
    @Before
    fun setup() {
        every { clock.now() } returns fixedTime
        every { notifier.sendReminder(any()) } just Runs
    }
    
    @Test
    fun `overdue tasks trigger reminder notifications`() = runTest {
        repository.add(Task("1", "Overdue task", dueDate = yesterday))
        
        service.checkOverdueTasks()
        
        verify { notifier.sendReminder("1") } // Mock verifies the side effect
        assertEquals(1, repository.count()) // Fake verifies state wasn't changed
    }
}

Takeaways

  • Stub when you need a return value and don't care about calls
  • Fake when you need realistic stateful behavior across multiple calls
  • Mock when you need to verify a specific interaction happened
  • Spy rarely — use fakes instead when possible
  • Over-using mocks leads to brittle tests tied to implementation details
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this