Fakes Stubs and Mocks in Go Explained With Real Code
Learn stubs, fakes, spies and mocks in Go with one signup function and two implementations, and why a mock broke on a safe refactoring.

The vocabulary of test doubles is used loosely, and the looseness causes real problems. People say "mock" for anything fake, then write tests that break whenever the code is refactored. Others avoid test doubles altogether and end up with tests that need a database to run. The four terms have specific meanings, each is the right tool in some situation, and Go's small interfaces make all of them cheap to write by hand.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article defines each with working code, runs them against two implementations of the same behaviour, and uses the results to say when to use which. It builds on mocking with small interfaces, compares code generators in gomock and mockery and gives the testing context in your first test. It ran on Go 1.25.6.
The code under test
A signup function takes a store and a mailer. It refuses an email that is already registered, creates the user and sends a welcome mail. Two implementations have the same behaviour: V1 asks the store whether the email exists, then creates; V2 skips the check and relies on Create to report the duplicate, which is one query instead of two and avoids a race between the check and the insert. A refactoring from V1 to V2 changes no observable behaviour.
package main
import (
"errors"
"fmt"
)
var ErrTaken = errors.New("email already registered")
type UserStore interface {
Exists(email string) bool
Create(email string) (int, error)
}
type Mailer interface {
Send(to, subject string) error
}
// SignupV1 checks first, then creates.
func SignupV1(s UserStore, m Mailer, email string) (int, error) {
if s.Exists(email) {
return 0, ErrTaken
}
id, err := s.Create(email)
if err != nil {
return 0, err
}
if err := m.Send(email, "Welcome"); err != nil {
return id, fmt.Errorf("created user %d but the welcome mail failed: %w", id, err)
}
return id, nil
}
// SignupV2 is a refactoring with the same behaviour: it lets Create report the duplicate (one query instead of two,
// and no race between the check and the insert).
func SignupV2(s UserStore, m Mailer, email string) (int, error) {
id, err := s.Create(email)
if err != nil {
return 0, err
}
if err := m.Send(email, "Welcome"); err != nil {
return id, fmt.Errorf("created user %d but the welcome mail failed: %w", id, err)
}
return id, nil
}The two interfaces are what make doubles possible. Each is small and defined where it is used, as is idiomatic in Go (accept interfaces, return structs).
The four doubles
package main
import (
"fmt"
"strings"
)
// STUB: returns fixed answers. It has no behaviour of its own.
type stubStore struct{}
func (stubStore) Exists(string) bool { return false }
func (stubStore) Create(string) (int, error) { return 1, nil }
// FAKE: a small but working implementation (in memory). It enforces the same rules as the real thing.
type fakeStore struct {
byEmail map[string]int
}
func newFakeStore() *fakeStore { return &fakeStore{byEmail: map[string]int{}} }
func (f *fakeStore) Exists(email string) bool { _, ok := f.byEmail[email]; return ok }
func (f *fakeStore) Create(email string) (int, error) {
if f.Exists(email) {
return 0, ErrTaken // the real store's unique index reports the same error
}
f.byEmail[email] = len(f.byEmail) + 1
return f.byEmail[email], nil
}
// SPY: records what happened so the test can look afterwards.
type spyMailer struct {
sent []string
fail error
}
func (s *spyMailer) Send(to, subject string) error {
s.sent = append(s.sent, to+": "+subject)
return s.fail
}
// MOCK: knows which calls it expects, in which order, and fails if the real calls differ.
type mockStore struct {
expect []string
got []string
}
func (m *mockStore) Exists(email string) bool {
m.got = append(m.got, "Exists("+email+")")
return false
}
func (m *mockStore) Create(email string) (int, error) {
m.got = append(m.got, "Create("+email+")")
return 7, nil
}
func (m *mockStore) verify() error {
if strings.Join(m.expect, ",") != strings.Join(m.got, ",") {
return fmt.Errorf("expected calls %v, got %v", m.expect, m.got)
}
return nil
}Stub. stubStore returns fixed answers: nobody exists, Create returns id 1. It has no state and no logic. Use it when the test needs some answer from a collaborator but the answer is not the point, such as "a valid signup sends a welcome mail". It is the cheapest double and the limit of its usefulness is quick to find.
Fake. fakeStore is a working implementation backed by a map. It stores users, rejects duplicates and returns incrementing ids. It is simpler than the real store, but it follows the same rules, including the one that matters here: Create of an existing email returns ErrTaken. Use a fake when the behaviour of the collaborator is part of what you are testing, which is when state matters across calls. A fake can be shared between tests and verified against the real thing with a contract test that runs the same assertions on both.
Spy. spyMailer records the calls it receives (sent holds the recipients and subjects) and can be told to fail. The test runs the code and then looks at what the spy saw. A spy checks outcomes after the fact, without declaring expectations up front.
Mock. mockStore is told in advance which calls to expect, in order, and verify fails if the actual calls differ. The mock asserts on the interaction. It is what libraries like gomock and mockery generate.
The word "mock" is often used for all four. In the Go community's ordinary usage that is fine in conversation and a hazard in design, because each kind pushes the test in a different direction.
The matrix
The program runs five scenarios against both implementations and prints which pass.
package main
import (
"errors"
"fmt"
)
type impl struct {
name string
fn func(UserStore, Mailer, string) (int, error)
}
func verdict(err error) string {
if err != nil {
return "FAIL (" + err.Error() + ")"
}
return "pass"
}
func main() {
impls := []impl{{"SignupV1 (check, then create)", SignupV1}, {"SignupV2 (create reports the duplicate)", SignupV2}}
for _, im := range impls {
fmt.Println(im.name)
// 1. Stub + spy: "a valid signup sends one welcome mail".
spy := &spyMailer{}
id, err := im.fn(stubStore{}, spy, "[email protected]")
var e1 error
if err != nil || id != 1 || len(spy.sent) != 1 {
e1 = fmt.Errorf("id=%d err=%v sent=%v", id, err, spy.sent)
}
fmt.Printf(" stub store + spy mailer, one signup sends one mail: %s\n", verdict(e1))
// 2. Stub: "a duplicate is rejected" cannot even be expressed, because the stub says nobody exists.
_, err = im.fn(stubStore{}, &spyMailer{}, "[email protected]")
var e2 error
if !errors.Is(err, ErrTaken) {
e2 = fmt.Errorf("duplicate was not rejected: err=%v", err)
}
fmt.Printf(" stub store, a duplicate is rejected: %s\n", verdict(e2))
// 3. Fake: behaviour with state, the same test passes for both implementations.
store, spy2 := newFakeStore(), &spyMailer{}
im.fn(store, spy2, "[email protected]")
_, err = im.fn(store, spy2, "[email protected]")
var e3 error
if !errors.Is(err, ErrTaken) || len(spy2.sent) != 1 {
e3 = fmt.Errorf("err=%v mails=%d", err, len(spy2.sent))
}
fmt.Printf(" fake store, second signup is rejected, one mail only: %s\n", verdict(e3))
// 4. Mock: asserts the calls made, which is the implementation, not the behaviour.
mock := &mockStore{expect: []string{"Exists([email protected])", "Create([email protected])"}}
im.fn(mock, &spyMailer{}, "[email protected]")
fmt.Printf(" mock store expecting Exists then Create: %s\n", verdict(mock.verify()))
// 5. A failing mailer, with the spy: the error path.
spy3 := &spyMailer{fail: errors.New("smtp down")}
_, err = im.fn(newFakeStore(), spy3, "[email protected]")
var e5 error
if err == nil {
e5 = errors.New("mail failure was swallowed")
}
fmt.Printf(" fake store + failing mailer reports the error: %s\n", verdict(e5))
}
}SignupV1 (check, then create)
stub store + spy mailer, one signup sends one mail: pass
stub store, a duplicate is rejected: FAIL (duplicate was not rejected: err=<nil>)
fake store, second signup is rejected, one mail only: pass
mock store expecting Exists then Create: pass
fake store + failing mailer reports the error: pass
SignupV2 (create reports the duplicate)
stub store + spy mailer, one signup sends one mail: pass
stub store, a duplicate is rejected: FAIL (duplicate was not rejected: err=<nil>)
fake store, second signup is rejected, one mail only: pass
mock store expecting Exists then Create: FAIL (expected calls [Exists([email protected]) Create([email protected])], got [Create([email protected])])
fake store + failing mailer reports the error: passRead the output as a table:
| Scenario | V1 | V2 |
|---|---|---|
| Stub store and spy mailer: one signup sends one mail | pass | pass |
| Stub store: a duplicate is rejected | fail | fail |
| Fake store: the second signup is rejected, one mail sent | pass | pass |
Mock store expecting Exists then Create | pass | fail |
| Fake store and failing mailer: the error is reported | pass | pass |
The stub's limit
Row two fails for both versions, by design. A stub always answers "nobody exists", so a second signup with the same email looks like a first one, and the behaviour "a duplicate is rejected" cannot be observed. The failure message is correct: the duplicate was not rejected, because the stub could not say it was. The lesson is not that the code is wrong, it is that a stateless double cannot test a stateful rule.
The fake survives the refactor
Row three uses the fake: signing up twice with the same email, the second returns ErrTaken, and the spy sees exactly one welcome mail. It passes for both implementations. V1 learns about the duplicate from Exists; V2 learns it from Create's error. The fake behaves like the real store in both, so the test describes the behaviour, "a second signup is rejected and sends no mail", and not the means.
The mock does not
Row four is the cost of mocking. The mock expects Exists([email protected]) then Create([email protected]). V1 makes exactly those calls and passes. V2 makes only Create, so the mock fails with expected calls [Exists(...) Create(...)], got [Create(...)]. Nothing the code does changed: a user is created, a mail is sent, duplicates are rejected. A test that fails after a correct refactoring teaches the team that refactoring is expensive and the test suite is in the way. This is the main criticism of mocks: they test the implementation, so every change to the implementation is a change to the test.
When to use which
Stubs for collaborators whose answer is incidental. Keep them dumb.
Fakes for collaborators with behaviour your code relies on: stores, caches, queues, clocks, file systems (fstest.MapFS is a fake file system in the standard library, and injected clocks are fakes of time). Fakes give the most durable tests. The price is writing and maintaining them, which is worth it when several tests use them. Keep a fake honest by running the same test suite against the fake and the real implementation, so the fake cannot drift.
Spies for outputs that leave your code: emails, events, log lines, outgoing HTTP requests. Assert on what was sent, not on how.
Mocks when the call itself is the behaviour and there is no state to observe: "the payment gateway's Charge is called once with this amount and not again", "the audit logger is called before the delete". For these, the interaction is what you promised, and a mock states it directly. Even then, a spy that records the calls and a plain assertion can say the same thing without a framework.
A useful test for any double: if you refactored the code under test without changing its behaviour, would this test still pass? If not, the double is pinning down implementation.
Writing doubles by hand in Go
Go's structural interfaces make this easy. Define the interface in the consumer package with only the methods the code uses ("the bigger the interface, the weaker the abstraction", from the Go proverbs), implement the double in the test file, and pass it in. Some practices make hand-written doubles pleasant.
- Embed the interface for partial doubles.
type partialStore struct{ UserStore }lets you override one method and panic on the rest if called, which is a cheap way to assert "this code does not touch that method". - Use function fields for per-test behaviour:
type stub struct{ CreateFunc func(string) (int, error) }with methods that call the field. Each test sets the one function it needs. - Record with a mutex if the code under test is concurrent, and run with
-race. - Return errors on demand: add a field
failNext errorand use it for the error-path tests. - Check at compile time that the double implements the interface:
var _ UserStore = (*fakeStore)(nil).
Keeping a fake honest with a contract test
A fake is code, so it can be wrong, and a test passing against a wrong fake is worse than no test. The remedy is a contract test: one test function that takes the interface and checks its rules, run against the fake in every build and against the real implementation where a database is available.
func testUserStoreContract(t *testing.T, newStore func(t *testing.T) UserStore) {
t.Run("create then exists", func(t *testing.T) {
s := newStore(t)
if _, err := s.Create("[email protected]"); err != nil {
t.Fatal(err)
}
if !s.Exists("[email protected]") {
t.Error("a created user should exist")
}
})
t.Run("duplicate create fails with ErrTaken", func(t *testing.T) {
s := newStore(t)
s.Create("[email protected]")
if _, err := s.Create("[email protected]"); !errors.Is(err, ErrTaken) {
t.Errorf("second Create error = %v, want ErrTaken", err)
}
})
}
func TestFakeStore(t *testing.T) { testUserStoreContract(t, func(*testing.T) UserStore { return newFakeStore() }) }
func TestPostgresStore(t *testing.T) { testUserStoreContract(t, newTestPostgresStore) } // skipped without a databaseIf someone changes the real store so duplicates return a different error, the Postgres run of the contract test fails, and the fix is to update the fake as well. Without the contract, the fake keeps returning the old behaviour and the unit tests stay green while production does something else. The same technique keeps hand-written HTTP fakes in line with the services they imitate.
Passing the doubles in
None of this needs a framework. The signup function takes its collaborators as parameters, which is dependency injection in the plainest form: the caller decides what to pass. For a type with several collaborators, a struct with the dependencies as fields and methods on it does the same:
type Signup struct {
Store UserStore
Mailer Mailer
}
func (s *Signup) Register(email string) (int, error) { ... }A test builds Signup{Store: newFakeStore(), Mailer: &spyMailer{}}. Production code builds it in main with the real ones. Avoid global variables for dependencies (var mailer = realMailer) and test-only setters; they make tests depend on each other and block parallel execution.
Mistakes that make doubles painful
An interface with too many methods. If a double has to implement fifteen methods to satisfy the interface and the code uses two, the interface is too big. Define a small one at the point of use.
Stubbing what the code does not need. A stub that answers eight questions to get one assertion in is a sign that the function does too much, or that the setup belongs in a helper.
Asserting on call counts and order by default. Count and order are implementation details unless the specification says otherwise. Assert them when they are the behaviour.
Doubles that duplicate the real thing's logic. A fake that reimplements the algorithm under test checks nothing. Keep fakes dumb about the logic being tested and faithful about the rules of the collaborator.
Mocking types you do not own. A mock of *sql.DB or an SDK client encodes your guess about how it behaves. Wrap the third-party type in a small interface of your own, mock or fake that, and test the wrapper against the real thing.
Sharing mutable doubles between tests. Build a fresh one per test, or the order of tests starts to matter.
Generators
For large interfaces, writing doubles by hand gets tedious. gomock and mockery generate mocks from interfaces, and moq generates function-field stubs. They are good at producing the boilerplate and make it easy to write the brittle style demonstrated above, so use them with the habits above: assert on behaviour where possible, and keep mock expectations loose (gomock.Any() for arguments that do not matter, no strict ordering unless order is the behaviour). The comparison of the two main generators is in gomock versus mockery.
A decision path for the next test
When you sit down to test a function with collaborators, ask in this order. Does the collaborator's answer matter to what I am checking? If not, a stub. Does the collaborator keep state across calls that my code depends on? Then a fake. Do I need to know afterwards what my code sent out? A spy. Is the interaction itself the requirement, such as a call that must happen exactly once? A mock, or a spy and an assertion. Is the collaborator cheap to run for real (a pure function, an in-memory component, a temporary directory)? Then use the real one and no double at all. Using the real thing wherever it is cheap keeps tests honest and shrinks the number of doubles you have to maintain.
Teams that follow this order end up with a few well-kept fakes, a handful of spies and very few mocks, and their tests survive refactoring. Teams that default to mocking every collaborator end up rewriting tests alongside code, and then stop trusting them.
Integration instead of doubles
A double stands in for something you do not control or that is slow. For a database, the better stand-in is often the real one in a container (Testcontainers, a real Postgres), with fakes reserved for third-party services you cannot run. The test pyramid still applies: many fast tests with doubles at the unit level, fewer with real dependencies above.
The terms come from Martin Fowler's article Mocks Aren't Stubs, which is still the clearest discussion of the distinctions, and the Go-specific guidance is in the Go wiki's test comments.


