Skip to content
intermediate Phase 11 · Testing, Coverage, and Benchmarks

Test Doubles with Interfaces

Substitute fakes and stubs for slow dependencies, including in-process HTTP servers.

45m
0 problems
Topic Progress 0%

The Test That Needed a Running Postgres

A UserService writes rows through *sql.DB directly. Its unit test starts a Postgres container, waits eight seconds for readiness, and fails on laptops where Docker is absent. CI pays the eight seconds on every run, and the test's actual subject — what the service does with a found user, a missing user, or a store that returns a connection error — is buried under infrastructure that proves none of it.

The dependency that hurts is the concrete type in the signature. Replace it with the smallest interface that expresses what the service needs, declared in the service's own package:

package users

type Store interface {
	Get(ctx context.Context, id int64) (User, error)
	Save(ctx context.Context, u User) error
}

type Service struct {
	store Store
}

func NewService(s Store) Service { return Service{store: s} }

Two decisions hide in that shape. The interface belongs to the consumer, not the provider — users.Store is defined where it is used, so the service's contract never bends to what the SQL package happens to export, and swapping implementations requires no changes to either package (interfaces and implicit satisfaction). And the method set is three methods, not the twenty that *sql.DB offers: a narrow interface is a document of what the service actually calls.

Now the test needs no database at all:

type fakeStore struct {
	users map[int64]User
	saved []User
	getErr error
}

func (f *fakeStore) Get(ctx context.Context, id int64) (User, error) {
	if f.getErr != nil {
		return User{}, f.getErr
	}
	u, ok := f.users[id]
	if !ok {
		return User{}, ErrNotFound
	}
	return u, nil
}

func (f *fakeStore) Save(ctx context.Context, u User) error {
	f.saved = append(f.saved, u)
	f.users[u.ID] = u
	return nil
}

func TestService_Profile_Present(t *testing.T) {
	store := &fakeStore{users: map[int64]User{42: {ID: 42, Name: "Ada"}}}
	svc := NewService(store)

	p, err := svc.Profile(context.Background(), 42)
	if err != nil {
		t.Fatalf("Profile: %v", err)
	}
	if p.Name != "Ada" {
		t.Errorf("Name = %q, want Ada", p.Name)
	}
}

This is a fake: it implements the interface with working in-memory logic, so the test exercises the real lookup path rather than a canned response. That distinction matters. A stub returns pre-programmed answers and remembers nothing — right for forcing one branch. A recorder (what mock frameworks call a mock or spy) captures what was called so the test can assert on it — right when the call itself is the behavior. The fake above happens to be both: stateful behavior for the happy path, and a getErr field to stub the failure path. In a moment the assertions need one more piece: the test can set getErr = context.DeadlineExceeded and prove the service propagates timeouts as errors instead of panicking, which is the exact failure error handling warns about.

When the Call Is the Assertion

Some functions exist only for their side effect. Notify(userID, message) returns nothing useful; if it is never called, the feature failed silently, and no amount of inspecting the service's return value reveals it. For those, the test must read the interaction record — which does not require a mocking framework. A struct with function fields is a recorder you can read at a glance:

type mailer struct {
	sent    []Message
	wantErr error
}

func (m *mailer) Send(_ context.Context, msg Message) error {
	if m.wantErr != nil {
		return m.wantErr
	}
	m.sent = append(m.sent, msg)
	return nil
}

func TestService_WelcomeSendsMail(t *testing.T) {
	m := &mailer{}
	svc := NewService(&fakeStore{users: map[int64]User{7: {ID: 7, Email: "ada@example.com"}}}, m)

	if err := svc.Activate(context.Background(), 7); err != nil {
		t.Fatalf("Activate: %v", err)
	}
	if len(m.sent) != 1 {
		t.Fatalf("sent %d messages, want 1", len(m.sent))
	}
	if m.sent[0].To != "ada@example.com" {
		t.Errorf("To = %q, want ada@example.com", m.sent[0].To)
	}
}

Assert the observable outcome first — the message list — and reach for call-order assertions only when order is the contract ("audit log written before the charge"). A test that verifies seven methods were called in sequence encodes the implementation, so every refactor that reorders harmless calls breaks the suite while the feature still works. The rule: verify state and outputs when you can, interactions when the side effect is the entire point.

HTTP dependencies get an in-process server. A client that calls a payment API can be tested without the network by putting the fake on the other end of a real HTTP connection:

func TestPaymentClient_Charges(t *testing.T) {
	var gotAuth, gotPath string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuth = r.Header.Get("Authorization")
		gotPath = r.URL.Path
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"id":"ch_123","status":"succeeded"}`)
	}))
	defer srv.Close()

	client := NewPaymentClient(srv.URL, "Bearer test-key")
	ch, err := client.Charge(context.Background(), 4999, "tok_visa")
	if err != nil {
		t.Fatalf("Charge: %v", err)
	}
	if ch.ID != "ch_123" {
		t.Errorf("ID = %q, want ch_123", ch.ID)
	}
	if gotAuth != "Bearer test-key" || gotPath != "/v1/charges" {
		t.Errorf("request = %s %s, want POST-style auth and /v1/charges", gotAuth, gotPath)
	}
}

The handler is the recorder: it captures method, path, headers, and body while returning the response the client must parse. httptest binds an ephemeral localhost port, so the test exercises real marshalling, status codes, and error paths — the things a stubbed Doer interface would skip — and tears the server down with defer srv.Close(). Full coverage of handler-side testing arrives with httptest; the point here is that an in-process server replaces both a mock and a staging environment.

The Fake That Lied

The unit suite is fast and green. The service goes to production and deadlocks on its first transaction, because the in-memory fake commits atomically while the real table locks rows under concurrent writes. Nothing was wrong with the tests; the fake had drifted from the thing it stood in for.

This is the standing risk of any test double, and the defenses are specific rather than philosophical.

Give the fake one shared contract with the real implementation. Write the conformance checks once, against the interface, and run them against both:

func TestStoreContract(t *testing.T) {
	t.Run("in-memory", func(t *testing.T) {
		checkStoreContract(t, func() Store { return newFakeStore() })
	})
	t.Run("postgres", func(t *testing.T) {
		if testing.Short() {
			t.Skip("needs a database; skipped with -short")
		}
		checkStoreContract(t, func() Store { return newTestDB(t) })
	})
}

func checkStoreContract(t *testing.T, open func() Store) {
	s := open()
	if err := s.Save(context.Background(), User{ID: 1}); err != nil {
		t.Fatalf("Save: %v", err)
	}
	if _, err := s.Get(context.Background(), 1); err != nil {
		t.Errorf("Get after Save: %v", err)
	}
	if _, err := s.Get(context.Background(), 999); !errors.Is(err, ErrNotFound) {
		t.Errorf("missing id error = %v, want ErrNotFound", err)
	}
}

When the fake stops honoring a documented behavior — a missing row must return ErrNotFound, not a zero value — the contract test fails in milliseconds, before any environment does. The database variant runs behind testing.Short() so the local loop stays fast and CI's full run still executes it.

Keep doubles at the boundary. The service's internal decision logic does not need an interface; test it directly and let it talk to fakes only at genuine edges — storage, network, clock, randomness. Injecting an interface around your own pure function makes the test assert on plumbing you already had coverage for, and each new seam is a place where the test can pass while the wiring is wrong.

Let one layer of reality through. The cheapest honest signal is often a single end-to-end test that runs the real store against a real container once per CI run, while everything above it stays unit-speed. Fakes buy the thousand fast assertions; one integration test keeps them tethered to the world. The same discipline applies when the input to these systems is not handcrafted: the failure you never anticipated is the one fuzzing is built to find.

Quiz

1. A package's own interface says `type Cache interface { Get(k string) (any, bool); Put(k string, v any) }`, but it only exists so tests can substitute a fake. Where should that interface live?

Question 1 options

2. A test must prove the service sent exactly one notification to the right address. What is the right double?

Question 2 options

3. A unit suite passes against an in-memory Store, but production fails because the real store does not return ErrNotFound for missing rows. What is the structural fix?

Question 3 options

4. You are testing a Go HTTP client that sends an Authorization header and parses a JSON response. Which approach exercises the most realistic behavior without network access?

Question 4 options

Flashcards

Question

What separates a fake from a stub and a recorder?

Answer

A fake implements the dependency with real in-memory logic so the production code path actually runs; a stub returns canned answers to force a branch; a recorder captures what was called so the test can assert count, arguments, or order. The choice follows what the test must verify: behavior, branch, or side effect.

Question

Why is a consumer-side interface smaller than the concrete type it replaces?

Answer

The consumer declares only the methods it actually calls — Store with Get and Save rather than the twenty methods on *sql.DB. A narrow interface documents the real contract, makes the fake trivial to implement, and lets the provider change without touching the consumer.

Question

When is asserting on recorded calls appropriate, and when is it over-testing?

Answer

Appropriate when the call is the entire behavior — one notification to one address, an audit entry written before a charge. Over-testing when the observable state already proves the outcome; sequence assertions then couple the suite to implementation order and break on harmless refactors.

Question

What does an httptest.NewServer fake buy over stubbing the HTTP client's Do method?

Answer

A real round trip through net/http: header serialization, status codes, content types, and JSON decoding all execute, plus the handler can record the incoming request for assertion. Stubbing Do skips every wire-level behavior, which is often where the bug lives.

Question

How do you stop an in-memory fake from silently drifting from the real store?

Answer

One contract test suite written against the interface, run against both the fake and the real implementation (the real one behind testing.Short). When either side stops honoring documented semantics — like returning ErrNotFound for a missing row — the contract fails in milliseconds.

Revision Notes

Key Takeaways

  • 1. Interfaces belong to the consumer and should name only the methods the consumer uses — that narrowness is what makes fakes easy to write
  • 2. Fakes exercise behavior, stubs force branches, recorders verify side effects; the assertion you need picks the double
  • 3. Interactions are worth asserting only when the call is the behavior — otherwise verify state and avoid coupling to call order
  • 4. httptest servers give real HTTP semantics in-process and let the handler double as a request recorder
  • 5. Fakes drift from reality; a contract suite run against both implementations is the cheap check that catches it

Interview Tips

  • • "How do you test code that talks to a database?" — interface defined at the consumer, in-memory fake for unit tests, contract suite run against both, one integration test behind -short
  • • "Mocks or fakes?" — answer by what you verify, not by preference: behavior against realistic logic → fake; side effect → recorder; forced failure branch → stub
  • • "Isn't this over-mocking?" — point to the boundary rule: test pure logic directly, substitute only at storage/network/clock edges, and never wrap your own helpers in interfaces for tests
  • • "Show me how you'd test this HTTP client" — httptest.NewServer, record headers and path in the handler, write the real JSON, assert both the parsed result and the recorded request

Cheat Sheet

Test Doubles with Interfaces

Choose the double by what you verify:

You must verify Double Example
logic against realistic dependency behavior fake in-memory Store with working Get/Save
a specific failure branch stub getErr: context.DeadlineExceeded
that a side effect happened recorder sent []Message, assert count and address
HTTP client behavior httptest server handler records request, writes JSON
real semantics end to end integration test behind testing.Short() contract suite vs a container

Placement rule: declare the interface in the consumer package with only the methods the consumer calls. The provider never sees it.

Code template:

type Store interface {
	Get(ctx context.Context, id int64) (User, error)
}

type fakeStore struct {
	users  map[int64]User
	getErr error
}

func (f *fakeStore) Get(ctx context.Context, id int64) (User, error) {
	if f.getErr != nil {
		return User{}, f.getErr
	}
	u, ok := f.users[id]
	if !ok {
		return User{}, ErrNotFound
	}
	return u, nil
}

Honesty guards: run a shared contract suite against fake and real; inject doubles only at real boundaries (storage, network, clock, randomness); keep at least one integration test per boundary that runs the real implementation in CI.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading