Logo
Logo

Atharva Pandey/Lesson 6: Mocking Alternatives — Interfaces over mocks, always

Created Tue, 05 Nov 2024 00:00:00 +0000 Modified Tue, 05 Nov 2024 00:00:00 +0000

Mocking has a bad reputation in the Go community, and some of it is deserved. Not because mocks are inherently wrong, but because the way most people use them — generating verbose stubs from interfaces, asserting on call counts, verifying argument order — produces tests that are coupled to implementation details rather than behaviour. Refactor the internals of a function without changing its public contract, and suddenly half your mocks break. That’s not a test problem. That’s a mock-as-test-double problem.

The Go answer is simpler: define a narrow interface, write a fake implementation by hand, and test against the fake. No code generation, no mock frameworks, no EXPECT().Times(1).Return(...). Just Go.

The Problem

Generated mocks coupled to method signatures create brittle tests:

// WRONG — generated mock that breaks on every refactor
// Generated by mockery or gomock:
type MockEmailService struct {
    mock.Mock
}

func (m *MockEmailService) SendWelcomeEmail(ctx context.Context, to, name string) error {
    args := m.Called(ctx, to, name)
    return args.Error(0)
}

func TestRegisterUser(t *testing.T) {
    emailSvc := new(MockEmailService)
    // Asserting on specific arguments — now your test knows too much
    emailSvc.On("SendWelcomeEmail",
        mock.Anything,
        "alice@example.com",
        "Alice",
    ).Return(nil).Times(1)

    svc := NewUserService(emailSvc)
    err := svc.RegisterUser(ctx, RegisterRequest{
        Email: "alice@example.com",
        Name:  "Alice",
    })
    if err != nil {
        t.Fatal(err)
    }
    emailSvc.AssertExpectations(t)
}

Now imagine you rename the parameter from name to displayName inside RegisterUser, or you add a new optional parameter. The mock breaks. The test breaks. But the behaviour — “a welcome email is sent when a user registers” — hasn’t changed at all. Your test was testing the internal call signature, not the contract.

A second problem is deeply nested mocks for chains of dependencies:

// WRONG — mocking every layer creates a fragile web of fakes
func TestProcessPayment(t *testing.T) {
    mockDB := new(MockDB)
    mockQueue := new(MockQueue)
    mockNotifier := new(MockNotifier)
    mockLogger := new(MockLogger)

    // Four mocks, each with its own set of expectations
    // Change any layer and all four break
    mockDB.On("GetOrder", ...).Return(order, nil)
    mockQueue.On("Enqueue", ...).Return(nil)
    mockNotifier.On("Notify", ...).Return(nil)
    mockLogger.On("Info", ...).Return(nil)

    svc := NewPaymentService(mockDB, mockQueue, mockNotifier, mockLogger)
    // ...
}

This test is a maintenance nightmare. It’s also not testing anything meaningful — it’s documenting that the function calls four things in a particular order. That’s not a behaviour contract; that’s a call log.

The Idiomatic Way

Define a narrow interface for each dependency. Write a hand-coded fake that stores state. Test against the fake’s stored state, not against call expectations.

// RIGHT — narrow interface + hand-coded fake
type EmailSender interface {
    SendWelcomeEmail(ctx context.Context, to, subject, body string) error
}

// Fake implementation — simple, hand-written, no framework
type fakeEmailSender struct {
    sent []struct {
        to      string
        subject string
        body    string
    }
    shouldFail bool
}

func (f *fakeEmailSender) SendWelcomeEmail(ctx context.Context, to, subject, body string) error {
    if f.shouldFail {
        return fmt.Errorf("simulated send failure")
    }
    f.sent = append(f.sent, struct {
        to      string
        subject string
        body    string
    }{to, subject, body})
    return nil
}

func TestRegisterUser(t *testing.T) {
    t.Run("sends welcome email on success", func(t *testing.T) {
        emailSvc := &fakeEmailSender{}
        svc := NewUserService(emailSvc)

        err := svc.RegisterUser(context.Background(), RegisterRequest{
            Email: "alice@example.com",
            Name:  "Alice",
        })
        if err != nil {
            t.Fatalf("RegisterUser: %v", err)
        }

        // Assert on outcome, not on call signature
        if len(emailSvc.sent) != 1 {
            t.Fatalf("expected 1 email sent, got %d", len(emailSvc.sent))
        }
        if emailSvc.sent[0].to != "alice@example.com" {
            t.Errorf("email sent to %q, want alice@example.com", emailSvc.sent[0].to)
        }
    })

    t.Run("returns error when email fails", func(t *testing.T) {
        emailSvc := &fakeEmailSender{shouldFail: true}
        svc := NewUserService(emailSvc)

        err := svc.RegisterUser(context.Background(), RegisterRequest{
            Email: "bob@example.com",
            Name:  "Bob",
        })
        if err == nil {
            t.Fatal("expected error when email fails, got nil")
        }
    })
}

The fakeEmailSender is plain Go. It doesn’t depend on any framework. It doesn’t break when you change parameter names. And because it stores what was sent, you can assert on the result (an email with a specific recipient) rather than on the call (method invoked with these exact arguments).

For complex state, a fake can be a full in-memory implementation:

// RIGHT — in-memory fake store as a test double
type fakeOrderStore struct {
    mu     sync.Mutex
    orders map[string]*Order
    nextID int
}

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

func (f *fakeOrderStore) Create(ctx context.Context, o *Order) error {
    f.mu.Lock()
    defer f.mu.Unlock()
    f.nextID++
    o.ID = fmt.Sprintf("ord-%d", f.nextID)
    f.orders[o.ID] = o
    return nil
}

func (f *fakeOrderStore) Get(ctx context.Context, id string) (*Order, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    o, ok := f.orders[id]
    if !ok {
        return nil, ErrNotFound
    }
    return o, nil
}

func (f *fakeOrderStore) ListByUser(ctx context.Context, userID int) ([]*Order, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    var result []*Order
    for _, o := range f.orders {
        if o.UserID == userID {
            result = append(result, o)
        }
    }
    return result, nil
}

This fake is reusable across every test that needs an OrderStore. It behaves like a real store (modulo actual database guarantees) and lets you test multi-step flows — create an order, then list orders for the user — without a database.

In The Wild

The narrow interface pattern really pays off when you have cross-cutting concerns. I keep fakes for things like:

  • ClockSource — returns the current time. In tests, returns a fixed or controllable time.
  • IDGenerator — generates IDs. In tests, returns predictable sequences.
  • EventPublisher — publishes events. In tests, captures events so you can assert they were published.
type ClockSource interface {
    Now() time.Time
}

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

func TestOrderExpiry(t *testing.T) {
    clock := fixedClock{t: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)}
    svc := NewOrderService(store, clock)

    order, _ := svc.CreateOrder(ctx, req) // created at "noon on Jan 15"

    // Advance fake clock by 25 hours
    clock.t = clock.t.Add(25 * time.Hour)
    svc = NewOrderService(store, clock) // rebuild with new clock

    expired, err := svc.IsExpired(ctx, order.ID)
    if err != nil {
        t.Fatal(err)
    }
    if !expired {
        t.Error("expected order to be expired after 25 hours")
    }
}

Without a controllable clock, testing time-dependent logic requires sleeping in tests or manipulating system time. Both are terrible. A ClockSource interface makes it trivial.

The Gotchas

Fakes that grow to mirror production. A fake that becomes as complex as the real thing is a signal that your interface is too wide. Split it. Narrow interfaces keep fakes small and focused.

Not testing the real implementation path. Fakes are for unit tests. You still need integration tests (see Lesson 3 and Lesson 5) that exercise the real database, real email sender, real queue. The fake is a development tool; the real implementation is what ships.

Shared mutable fakes in parallel tests. The fakeOrderStore above uses a mutex. If you share one instance across parallel subtests without synchronization, you’ll get data races. Either give each subtest its own fake instance, or synchronize the shared one.

Key Takeaway

The shift from “mock framework” thinking to “narrow interface + hand-coded fake” thinking is one of the most productive mindset changes I’ve made as a Go engineer. Fakes are explicit. They’re readable. They break only when the interface contract changes, not when the internal call signature does. Write the fake by hand, store state as plain fields, assert on outcomes. Your tests will be simpler and your refactors will be painless.


Course Index | ← Lesson 5 | Next → Lesson 7: Golden File Testing