Test Doubles Demystified: Stubs, Spies, Mocks, and Fakes in Go

Automated tests that hit real databases, live APIs, and production file systems are slow, flaky, and expensive to maintain. The standard solution is to replace those dependencies with test doubles — stand-in objects that mimic real collaborators just enough for the test to exercise the unit under inspection. But the vocabulary around test doubles is notoriously muddled. Developers use “mock” to mean any fake thing in a test, and that imprecision leads to brittle test suites where every refactor breaks fifty tests for no good reason.

The fix is understanding that test doubles come in distinct flavors, each with a specific purpose. A stub returns canned data. A spy records calls. A mock verifies expectations. A fake provides a working but simplified implementation. Using the right one for the job makes tests faster, more readable, and dramatically more resilient to change.

Let’s walk through each type with practical Go examples, then build a decision framework for choosing between them.

The Taxonomy at a Glance

There are five categories of test doubles, ordered from simplest to most complex:

  • Dummy — passed around but never actually used. Exists only to satisfy parameter lists.
  • Stub — returns hardcoded responses to calls made during the test. Doesn’t verify anything.
  • Spy — wraps a real object (or implements an interface) and records the calls made to it for later inspection.
  • Mock — pre-programmed with expectations about which calls should be made. The test fails if those expectations aren’t met.
  • Fake — a working implementation that takes a shortcut (e.g., an in-memory database instead of a real one).

The key distinction is direction: stubs and fakes provide state to the system under test, while mocks and spies verify behavior. This matters because behavior verification couples your tests to implementation details, while state verification couples them to outcomes.

Setting Up: The Interface Under Test

Throughout these examples, we’ll test an order processing service that depends on a payment gateway and a notification sender. Here’s the starting point:

package order

type PaymentGateway interface {
    Charge(cardToken string, amountCents int) (txID string, err error)
    Refund(txID string) error
}

type Notifier interface {
    SendOrderConfirmation(email, orderID string) error
}

type Service struct {
    payments PaymentGateway
    notifier Notifier
}

func (s *Service) PlaceOrder(orderID, email, cardToken string, amountCents int) error {
    txID, err := s.payments.Charge(cardToken, amountCents)
    if err != nil {
        return fmt.Errorf("payment failed: %w", err)
    }
    if err := s.notifier.SendOrderConfirmation(email, orderID); err != nil {
        // Payment succeeded but notification failed — refund
        _ = s.payments.Refund(txID)
        return fmt.Errorf("notification failed: %w", err)
    }
    return nil
}

Stub: Canned Responses for State Testing

A stub returns predetermined answers. It doesn’t care how many times it was called or in what order — it just feeds data into the system under test. This is what you reach for most often.

type stubPaymentGateway struct {
    chargeTXID string
    chargeErr  error
    refundErr  error
}

func (s *stubPaymentGateway) Charge(cardToken string, amountCents int) (string, error) {
    return s.chargeTXID, s.chargeErr
}

func (s *stubPaymentGateway) Refund(txID string) error {
    return s.refundErr
}

type stubNotifier struct {
    err error
}

func (s *stubNotifier) SendOrderConfirmation(email, orderID string) error {
    return s.err
}

func TestPlaceOrder_Success(t *testing.T) {
    payments := &stubPaymentGateway{
        chargeTXID: "tx_123",
    }
    notifier := &stubNotifier{err: nil} // always succeeds

    svc := &Service{payments: payments, notifier: notifier}
    err := svc.PlaceOrder("ord_1", "user@example.com", "tok_visa", 4999)

    if err != nil {
        t.Fatalf("expected nil error, got %v", err)
    }
}

func TestPlaceOrder_PaymentFailure(t *testing.T) {
    payments := &stubPaymentGateway{
        chargeErr: errors.New("card declined"),
    }
    notifier := &stubNotifier{}

    svc := &Service{payments: payments, notifier: notifier}
    err := svc.PlaceOrder("ord_1", "user@example.com", "tok_visa", 4999)

    if err == nil {
        t.Fatal("expected error on declined card, got nil")
    }
}

The stub doesn’t track anything. It’s purely declarative — “when Charge is called, return this.” The test then asserts on the return value of PlaceOrder, which is the outcome we actually care about.

Spy: Recording Interactions for Later Inspection

A spy wraps or implements a dependency and records the calls made to it. After the system under test runs, the test inspects what happened. Spies are useful when the fact that a call was made (or wasn’t) is the thing you need to verify.

type spyNotifier struct {
    confirmations []struct {
        email   string
        orderID string
    }
    sendErr error
}

func (s *spyNotifier) SendOrderConfirmation(email, orderID string) error {
    s.confirmations = append(s.confirmations, struct {
        email   string
        orderID string
    }{email, orderID})
    return s.sendErr
}

type spyPaymentGateway struct {
    chargeTXID string
    refunds    []string
}

func (s *spyPaymentGateway) Charge(cardToken string, amountCents int) (string, error) {
    return s.chargeTXID, nil
}

func (s *spyPaymentGateway) Refund(txID string) error {
    s.refunds = append(s.refunds, txID)
    return nil
}

func TestPlaceOrder_RefundsOnNotificationFailure(t *testing.T) {
    payments := &spyPaymentGateway{
        chargeTXID: "tx_456",
    }
    notifier := &spyNotifier{
        sendErr: errors.New("SMTP timeout"),
    }

    svc := &Service{payments: payments, notifier: notifier}
    _ = svc.PlaceOrder("ord_2", "user@example.com", "tok_visa", 4999)

    // Verify the refund was called after notification failed
    if len(payments.refunds) != 1 {
        t.Fatalf("expected 1 refund call, got %d", len(payments.refunds))
    }
    if payments.refunds[0] != "tx_456" {
        t.Errorf("expected refund for tx_456, got %s", payments.refunds[0])
    }
}

Here we’re checking behavior: the service should issue a refund when the notification fails. The spy records the calls without pre-declaring expectations, and the test makes assertions afterward. This is more flexible than a mock because you only assert on what you care about, not on the full call sequence.

Mock: Pre-Programmed Expectations

A mock declares expectations before the code runs: “I expect Charge to be called once with these arguments, then Refund to never be called.” The mock verifies these expectations itself, typically in a teardown or explicit verify step. Go doesn’t have a built-in mock framework, but libraries like testify and gomock provide this pattern. Here’s a hand-rolled version to show the mechanics:

type mockPaymentGateway struct {
    t *testing.T

    expectChargeCalled bool
    expectChargeWith   string
    expectChargeAmount int
    chargeWasCalled    bool

    expectRefundNotCalled bool
    refundWasCalled       bool
}

func (m *mockPaymentGateway) Charge(cardToken string, amountCents int) (string, error) {
    m.chargeWasCalled = true
    if cardToken != m.expectChargeWith {
        m.t.Errorf("Charge called with %q, expected %q", cardToken, m.expectChargeWith)
    }
    if amountCents != m.expectChargeAmount {
        m.t.Errorf("Charge amount %d, expected %d", amountCents, m.expectChargeAmount)
    }
    return "tx_mock", nil
}

func (m *mockPaymentGateway) Refund(txID string) error {
    m.refundWasCalled = true
    return nil
}

func (m *mockPaymentGateway) Verify() {
    if m.expectChargeCalled && !m.chargeWasCalled {
        m.t.Error("expected Charge to be called, but it wasn't")
    }
    if m.expectRefundNotCalled && m.refundWasCalled {
        m.t.Error("expected Refund to NOT be called, but it was")
    }
}

func TestPlaceOrder_HappyPath_MockStyle(t *testing.T) {
    mock := &mockPaymentGateway{
        t:                     t,
        expectChargeCalled:    true,
        expectChargeWith:      "tok_visa",
        expectChargeAmount:    4999,
        expectRefundNotCalled: true,
    }
    defer mock.Verify()

    notifier := &spyNotifier{}
    svc := &Service{payments: mock, notifier: notifier}
    err := svc.PlaceOrder("ord_3", "user@example.com", "tok_visa", 4999)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

The mock encodes expectations before execution and verifies them after. This is powerful but also the most coupling-heavy approach. If someone refactors PlaceOrder to batch refunds instead of doing them inline, this test breaks — even if the behavior is correct. That’s the tradeoff: mocks catch interaction bugs but resist refactoring.

Fake: A Real (But Simplified) Implementation

A fake is a lightweight but functional implementation of an interface. It doesn’t return canned data — it actually does something meaningful, just in a way that’s unsuitable for production. The classic example is an in-memory repository instead of a SQL database.

// FakeOrderRepository stores orders in a map instead of PostgreSQL.
// It behaves correctly (persists, retrieves, handles IDs) but
// loses everything when the process exits.
type FakeOrderRepository struct {
    mu     sync.Mutex
    orders map[string]Order
    nextID int
}

func NewFakeOrderRepository() *FakeOrderRepository {
    return &FakeOrderRepository{orders: make(map[string]Order)}
}

func (r *FakeOrderRepository) Save(order Order) (string, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    r.nextID++
    id := fmt.Sprintf("ord_%d", r.nextID)
    order.ID = id
    order.CreatedAt = time.Now()
    r.orders[id] = order
    return id, nil
}

func (r *FakeOrderRepository) Get(id string) (Order, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    order, ok := r.orders[id]
    if !ok {
        return Order{}, ErrNotFound
    }
    return order, nil
}

func (r *FakeOrderRepository) ListByCustomer(email string) ([]Order, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    var result []Order
    for _, o := range r.orders {
        if o.Email == email {
            result = append(result, o)
        }
    }
    return result, nil
}

Fakes shine for integration-style tests that exercise multiple components together without needing real infrastructure. A test can use a FakeOrderRepository alongside a real Service to verify that saving and retrieving an order round-trips correctly — something a stub can’t help with because the stub doesn’t actually store anything.

Decision Framework: Which Double to Use

Choosing the right test double comes down to two questions: what are you verifying, and how much coupling can you afford?

  • Need the system under test to receive specific data? Use a stub. This is the default — it’s the least coupling, the easiest to write, and it verifies outcomes through return values.
  • Need to verify that a specific side effect occurred? Use a spy. Record the calls, then assert only on the ones you care about. You get behavior verification without locking down the entire call sequence.
  • Need to verify exact call sequences and arguments? Use a mock. Reserve this for cases where the sequence of interactions is itself the contract — protocol implementations, state machines, things where order genuinely matters.
  • Need a dependency that actually works? Use a fake. This is ideal when the test exercises multiple units collaborating and you need the dependency to hold up its end — state transitions, queries, accumulations.
  • Just need to fill a parameter? Use a dummy (or nil in Go, if the code path doesn’t touch it).

Common Pitfalls

Over-mocking. The most common mistake. Reaching for mocks by default couples tests to implementation details. If you find yourself verifying that five methods were called in a specific order, your test is probably describing how the code works rather than what it should accomplish. Prefer stubs and state assertions, and use mocks only for genuine protocol contracts.

Stub drift. When a stub’s hardcoded return values don’t match what the real dependency produces, tests pass but production breaks. Mitigate this by keeping stubs simple and using fakes for complex logic — a fake that implements the interface correctly is self-validating in a way a stub never is.

Testing the mock instead of the code. If your mock is so elaborate that it’s essentially reimplementing the dependency, you’ve built a second system that needs its own tests. At that point, either simplify to a fake or question whether the unit boundaries are right.

Generating Doubles with Mockgen and Testify

Hand-rolling doubles works for simple interfaces but gets tedious fast. Two tools dominate the Go ecosystem:

gomock generates mock implementations from interface definitions using a code generation directive. You run mockgen on your interfaces, and it produces a file with expectations, argument matchers, and call-count verifications built in. This is the go-to for complex mock behavior:

//go:generate mockgen -source=payment.go -destination=mocks/payment_mock.go -package=mocks

func TestPlaceOrder_WithGeneratedMock(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockPayments := mocks.NewMockPaymentGateway(ctrl)
    mockPayments.EXPECT().
        Charge(gomock.Eq("tok_visa"), gomock.Eq(4999)).
        Return("tx_generated", nil)
    mockPayments.EXPECT().
        Refund(gomock.Any()).
        Times(0) // should not refund on success

    notifier := &spyNotifier{}
    svc := &Service{payments: mockPayments, notifier: notifier}

    err := svc.PlaceOrder("ord_4", "user@example.com", "tok_visa", 4999)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

The testify library takes a lighter approach with mock.Mock embedding. You define behavior with On("Method", args).Return(results), which gives you stub-like setup with optional call-count assertions:

type testifyPaymentMock struct {
    mock.Mock
}

func (m *testifyPaymentMock) Charge(cardToken string, amountCents int) (string, error) {
    args := m.Called(cardToken, amountCents)
    return args.String(0), args.Error(1)
}

func (m *testifyPaymentMock) Refund(txID string) error {
    return m.Called(txID).Error(0)
}

func TestPlaceOrder_WithTestify(t *testing.T) {
    payments := new(testifyPaymentMock)
    payments.On("Charge", "tok_visa", 4999).Return("tx_testify", nil)

    notifier := &spyNotifier{}
    svc := &Service{payments: payments, notifier: notifier}

    err := svc.PlaceOrder("ord_5", "user@example.com", "tok_visa", 4999)
    assert.NoError(t, err)
    payments.AssertExpectations(t)
}

Wrapping Up

The mental model is simple: stubs and fakes test state, spies and mocks test behavior, and dummies fill gaps. Most tests should use stubs — they’re the least coupled and the most readable. Reach for spies when you need to confirm a side effect happened. Use mocks sparingly, for genuine interaction contracts. And when you need a dependency that actually works across multiple collaborating units, invest in a fake.

The payoff is a test suite that catches real bugs, runs fast, and survives refactoring. When your tests break, it should be because behavior changed — not because someone moved a method call from line 12 to line 15.

Leave a Reply

Your email address will not be published. Required fields are marked *