Test Doubles

8 min read

Reading Progress0%
Software Engineering Practices Index
Tier 1 -- Foundations
Tier 2 -- Core Practices
Tier 3 -- Platform & Delivery
Software Engineering Practices Index
Tier 1 -- Foundations
Tier 2 -- Core Practices
Tier 3 -- Platform & Delivery

Test Doubles

What and Why

A test double is any object or function that replaces a real dependency in a test. The term comes from film: a stunt double stands in for an actor. In testing, a double stands in for a database, an API client, a clock, a queue — anything external that you want to control or observe.

You encounter test doubles whenever your code interacts with I/O. Without them, unit tests become integration tests: slow, unreliable, dependent on external systems being available.

Why they matter:

  • Speed: a test that hits a real database takes 10–100ms. A test using a double takes microseconds. This compounds across hundreds of tests.
  • Determinism: real external systems introduce variability (network latency, stale data, rate limits). Doubles return exactly what you configure.
  • Edge case coverage: simulating a database timeout, a malformed API response, or a race condition is trivial with a double and nearly impossible with the real thing.
  • Isolation: when a test fails, you know it's your code that's wrong — not a flaky downstream service.

QUICK CHECK

A backend service fetches exchange rates from a third-party currency API. During unit testing, the team notices that tests occasionally fail because the API returns different values at different times, and sometimes the API is rate-limited. Which benefit of using a test double most directly addresses this problem?

Choose one answer

Core Concepts

The Five Types of Test Doubles

These terms come from Gerard Meszaros' xUnit Test Patterns and are widely used (though many engineers use "mock" to mean all of them — a source of confusion):

Dummy — An object that is passed around but never used. Satisfies a parameter type requirement.

// UserService requires a Logger, but this test doesn't care about logging
logger := &DummyLogger{}  // implements Logger interface, all methods are no-ops
svc := NewUserService(db, logger)

Stub — Returns pre-configured responses. Used to feed controlled inputs to the unit under test.

type StubUserRepo struct{}

func (r *StubUserRepo) GetByID(id string) (*User, error) {
    return &User{ID: id, Name: "Alice", Role: "admin"}, nil
}

The stub doesn't verify how it was called — it just provides canned data.

Fake — A working, simplified implementation. Has real behavior but isn't production-grade (e.g., in-memory store instead of a database).

type InMemoryUserRepo struct {
    users map[string]*User
}

func (r *InMemoryUserRepo) GetByID(id string) (*User, error) {
    user, ok := r.users[id]
    if !ok {
        return nil, ErrNotFound
    }
    return user, nil
}

func (r *InMemoryUserRepo) Save(user *User) error {
    r.users[user.ID] = user
    return nil
}

Fakes are more expensive to build but more versatile — they support testing state across multiple calls.

Spy — Records how it was called. You assert on the recording after the fact.

type SpyEmailSender struct {
    sentEmails []Email
}

func (s *SpyEmailSender) Send(email Email) error {
    s.sentEmails = append(s.sentEmails, email)
    return nil
}

// In test:
spy := &SpyEmailSender{}
svc := NewNotificationService(spy)
svc.WelcomeUser("alice@example.com")

if len(spy.sentEmails) != 1 {
    t.Errorf("expected 1 email, got %d", len(spy.sentEmails))
}
if spy.sentEmails[0].To != "alice@example.com" {
    t.Errorf("expected email to alice, got %s", spy.sentEmails[0].To)
}

Mock — A pre-configured object that also has built-in expectations. The mock will fail the test if its expectations aren't met. Mock libraries (like testify/mock in Go, Mockito in Java, unittest.mock in Python) generate these.

// Using testify/mock
mockRepo := new(MockUserRepo)
mockRepo.On("GetByID", "user-123").Return(&User{ID: "user-123"}, nil)

svc := NewUserService(mockRepo)
svc.GetProfile("user-123")

mockRepo.AssertExpectations(t)  // fails if GetByID wasn't called with "user-123"

Stubs vs. Mocks: The Key Distinction

Stubs control inputs to the unit under test. Mocks verify interactions (that the unit called its dependencies correctly).

Stub → used for state verification: "did the output have the right value?"
Mock → used for behavior verification: "did the code call the dependency correctly?"

Overusing mocks leads to brittle tests. If you're mocking every layer and asserting on every internal call, you're testing the implementation, not the behavior. When you refactor, your tests break even if the observable behavior is unchanged.

Dependency Injection Enables Test Doubles

You can only substitute doubles if the code accepts its dependencies from outside. Hard-coding dependencies (instantiating them inside functions) makes swapping impossible.

// Hard to test — dependency is internal
func ProcessPayment(amount float64) error {
    stripe := stripe.NewClient(os.Getenv("STRIPE_KEY"))  // baked in
    return stripe.Charge(amount)
}

// Testable — dependency is injected
type PaymentGateway interface {
    Charge(amount float64) error
}

func ProcessPayment(gateway PaymentGateway, amount float64) error {
    return gateway.Charge(amount)
}

Now your test passes a StubPaymentGateway or MockPaymentGateway. The production caller passes the real Stripe client.


QUICK CHECK

A team is writing a test for a NotificationService that sends welcome emails. They want to verify that the service actually called the email sender with the correct recipient address — not just that the final state of some object is correct. Which type of test double is most appropriate for this purpose?

Choose one answer

How It Works in Practice

Building a Stub in Go

// Interface your code depends on
type UserRepository interface {
    GetByID(id string) (*User, error)
    Save(user *User) error
}

// Stub for tests that need a user to exist
type StubUserRepository struct {
    user *User
    err  error
}

func (r *StubUserRepository) GetByID(id string) (*User, error) {
    return r.user, r.err
}

func (r *StubUserRepository) Save(user *User) error {
    return r.err
}

// Test
func TestGetProfile_UserExists_ReturnsProfile(t *testing.T) {
    repo := &StubUserRepository{
        user: &User{ID: "abc", Name: "Alice"},
    }
    svc := NewUserService(repo)

    profile, err := svc.GetProfile("abc")

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if profile.Name != "Alice" {
        t.Errorf("expected Alice, got %s", profile.Name)
    }
}

func TestGetProfile_UserNotFound_ReturnsError(t *testing.T) {
    repo := &StubUserRepository{err: ErrNotFound}
    svc := NewUserService(repo)

    _, err := svc.GetProfile("missing")

    if err != ErrNotFound {
        t.Errorf("expected ErrNotFound, got %v", err)
    }
}

Using testify/mock in Go

import "github.com/stretchr/testify/mock"

type MockEmailClient struct {
    mock.Mock
}

func (m *MockEmailClient) Send(to, subject, body string) error {
    args := m.Called(to, subject, body)
    return args.Error(0)
}

func TestWelcomeEmail_NewUser_SendsEmail(t *testing.T) {
    emailClient := new(MockEmailClient)
    emailClient.On("Send", "alice@example.com", "Welcome!", mock.AnythingOfType("string")).
        Return(nil)

    svc := NewOnboardingService(emailClient)
    err := svc.WelcomeUser("alice@example.com")

    assert.NoError(t, err)
    emailClient.AssertExpectations(t)
}

Python: unittest.mock

from unittest.mock import MagicMock, patch
from billing import BillingService

def test_charge_customer_calls_gateway():
    mock_gateway = MagicMock()
    mock_gateway.charge.return_value = {"status": "success", "transaction_id": "txn_123"}

    svc = BillingService(gateway=mock_gateway)
    result = svc.charge_customer(customer_id="cust_1", amount=50.0)

    mock_gateway.charge.assert_called_once_with(customer_id="cust_1", amount=50.0)
    assert result["transaction_id"] == "txn_123"

Using patch to replace a module-level dependency:

from unittest.mock import patch

def test_send_notification_calls_smtp():
    with patch("notifications.smtp_client") as mock_smtp:
        mock_smtp.send.return_value = True

        send_notification("user@example.com", "Subject", "Body")

        mock_smtp.send.assert_called_once()

Controlling Time

Non-deterministic time is a common source of flaky tests. Inject clocks:

type Clock interface {
    Now() time.Time
}

type RealClock struct{}
func (c *RealClock) Now() time.Time { return time.Now() }

type FixedClock struct{ t time.Time }
func (c *FixedClock) Now() time.Time { return c.t }

// In test:
fixedTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
clock := &FixedClock{t: fixedTime}
svc := NewTokenService(clock)

token := svc.GenerateToken("user-123")
// token's expiry is deterministic: fixedTime + expiry duration

QUICK CHECK

A test for a token generation service is occasionally failing because the token's expiry timestamp differs by a few milliseconds between runs. Which design change would make this test deterministic?

Choose one answer

Common Mistakes

Using mocks for everything. Not every test needs a mock. Pure functions don't need any doubles. Reaching for a mock library by default couples your tests to implementation details. Start with stubs and fakes; use mocks only when you need to verify that a specific interaction occurred.

Stubbing the unit under test. You stub dependencies of the unit you're testing. Stubbing the unit itself means you're not testing it at all.

Mocking third-party packages directly. When you mock a library (like a Stripe SDK), you're testing against your assumed behavior of that library, not its actual behavior. Wrap third-party clients in an interface your code depends on; mock the interface, not the SDK.

Overly specific mock expectations. If a mock expects Send("alice@example.com", "Welcome!", "Dear Alice, your account is active") and the body changes slightly, the test fails even though the behavior is correct. Use argument matchers (AnythingOfType, mock.Anything) for parts you don't care about.

Not cleaning up doubles between tests. Shared mock state across tests causes order-dependent failures. Instantiate doubles fresh for each test; don't reuse them across test functions.


QUICK CHECK

Your team has a mock set up for an email service that expects the exact call Send("alice@example.com", "Welcome!", "Dear Alice, your account is active"). A developer updates the email body to say "Dear Alice, welcome to the platform!" — a valid product change — but the test now fails. What is the best fix?

Choose one answer

Tradeoffs

Doubles vs. real implementations. Fakes and stubs are faster and more controlled, but they can diverge from the real implementation. An in-memory store that doesn't enforce uniqueness constraints can let a test pass that would fail against a real DB with a unique index. Integration tests with real infrastructure catch this; unit tests with fakes don't.

Mocks vs. stubs. Mocks verify behavior (were the right calls made?). Stubs verify state (did the output have the right value?). Behavior verification is more brittle under refactoring. Prefer state verification (stubs + assertions on results) unless verifying interaction is the explicit goal.

Hand-written doubles vs. generated mocks. Hand-written stubs and fakes are verbose but simple to understand. Mock-generation libraries (Mockito, testify/mock, mockery) reduce boilerplate but add a dependency and can hide complexity. For small interfaces, hand-roll. For large interfaces, generate.

Test isolation vs. test realism. Fully isolated unit tests with doubles are fast and deterministic but test each piece in a vacuum. Real bugs often emerge from component interactions. The answer isn't to avoid doubles — it's to run both unit tests (with doubles) and integration tests (with real infrastructure), and understand what each layer tells you.


QUICK CHECK

A team refactors an order service by splitting a single placeOrder() method into separate validateOrder() and persistOrder() methods internally, while keeping the same public interface and output. Tests that used mocks to verify that placeOrder() called specific internal methods now fail, even though the feature works correctly. Which testing approach would have been more resilient to this refactoring?

Choose one answer

Quick Reference

TypeReturns data?Verifies calls?Has real behavior?Use when…
DummyNoNoNoMust satisfy a type but won't be used
StubYes (fixed)NoNoNeed controlled inputs to test state
FakeYes (real logic)NoYes (simplified)Need stateful behavior across calls
SpyYes (records)Yes (manually)NoWant to assert calls after the fact
MockYes (configured)Yes (automatic)NoWant built-in call expectations
Dependency injection pattern:
  1. Define an interface for the dependency
  2. Accept it in the constructor / function params
  3. In tests, pass a double
  4. In production, pass the real implementation

When to use which:
  Pure function, no deps         → no double needed
  Read-only dependency           → stub
  Read/write stateful dependency → fake
  Must verify "was X called?"    → spy or mock
  Third-party I/O                → wrap in interface, then stub/mock the interface
QUICK CHECK

You are testing a shopping cart service that applies discount rules and accumulates totals across multiple method calls. The dependency you need to replace tracks running totals and persists state between calls. Which test double is the best fit for this dependency?

Choose one answer
Glossary History

Click dotted jargon to save explanations here.

Glossary History

Click dotted jargon to save explanations here.