Mocking in Go: Test Doubles Without Magic
This tutorial shows how to test Go code that depends on databases, payment gateways, clocks, and HTTP services without a mocking framework. You will define small interfaces, hand-write fakes and spies, inject time and randomness, and use httptest. Then you will see exactly when a generated mock earns its keep.
Works with Go 1.21+. Every stdlib example here was compiled and run on Go 1.24.7, and the output shown is real. Framework snippets that need a third-party library are labeled as not executed. If Go is new to you, read the complete Go tutorial first, and for the deeper testing picture see the Go testing guide.
The core idea: depend on a small interface, substitute a test double
Most “how do I mock this in Go” questions have the same answer, and it is not a library. Your code under test should depend on a small interface, not a concrete type. In the test, you pass a different implementation of that interface. That substitute is a test double. That is the entire mechanism.
Take time. Code that calls time.Now() directly is untestable: you cannot assert on a value that changes every nanosecond. So you do not call time.Now(). You depend on the smallest interface that describes what you need:
type Clock interface {
Now() time.Time
}
Production passes a real clock. The test passes one that returns a fixed instant:
type fixedClock struct {
t time.Time
}
func (c fixedClock) Now() time.Time { return c.t }
No framework, no code generation, no magic. The double is nine lines you can read. This works because Go satisfies interfaces implicitly: fixedClock never declares that it implements Clock, it just has a Now() method, so it qualifies. The rest of this article is that same move applied to gateways, repositories, randomness, and HTTP.
The four kinds of test double, defined precisely
People say “mock” for all of these, which is why the topic is confusing. The categories come from Gerard Meszaros, and the distinctions are worth keeping straight because they change what your test asserts on.
- Stub: returns canned answers, records nothing.
fixedClockabove is a stub. You use it to feed a specific input into the code under test. - Fake: a real, working implementation that takes a shortcut unsuitable for production. An in-memory map standing in for a database is a fake. It has behavior: store something, get it back.
- Spy: a stub that also records how it was called, so the test can inspect the calls afterward. “Was
Chargecalled once, with 1500 cents?” - Mock: a double pre-programmed with expectations that it verifies itself. You tell it up front “expect
Chargeexactly once with these arguments,” and it fails the test if reality differs. This is what mocking frameworks generate.
The practical takeaway: stubs and fakes control inputs, spies and mocks assert on interactions. The first three you hand-write in a few lines. Only the last one, the self-verifying mock with expectations, is what libraries like gomock and testify exist to produce, and you need it far less often than the SERP results imply.
Hand-writing a fake that implements your interface
A fake carries real behavior. Suppose checkout depends on a payment gateway:
type Gateway interface {
Charge(customerID string, cents int) (txID string, err error)
Refund(txID string) error
}
The real implementation calls Stripe. The fake keeps transactions in a map, enforces the same rules (you cannot refund a transaction twice, you cannot charge a non-positive amount), and records charges for assertions:
type FakeGateway struct {
nextID int
captured map[string]int // txID -> cents charged
Charges []string // customerIDs, in call order
}
func NewFakeGateway() *FakeGateway {
return &FakeGateway{captured: make(map[string]int)}
}
func (f *FakeGateway) Charge(customerID string, cents int) (string, error) {
if cents <= 0 {
return "", errors.New("charge amount must be positive")
}
f.nextID++
txID := fmt.Sprintf("tx_%d", f.nextID)
f.captured[txID] = cents
f.Charges = append(f.Charges, customerID)
return txID, nil
}
func (f *FakeGateway) Refund(txID string) error {
if _, ok := f.captured[txID]; !ok {
return fmt.Errorf("unknown transaction %q", txID)
}
delete(f.captured, txID)
return nil
}
// Compile-time proof the fake satisfies the interface.
var _ Gateway = (*FakeGateway)(nil)
That var _ Gateway = (*FakeGateway)(nil) line is worth copying into your own fakes. It makes the compiler verify the fake still satisfies the interface, so if you add a method to Gateway later, the build breaks at the fake instead of silently at a call site.
The test drives the fake through a real charge-then-refund flow and confirms the fake behaves like the contract:
func TestFakeGateway_ChargeThenRefund(t *testing.T) {
gw := NewFakeGateway()
txID, err := gw.Charge("cus_7", 2000)
if err != nil {
t.Fatalf("Charge() error: %v", err)
}
if len(gw.Charges) != 1 || gw.Charges[0] != "cus_7" {
t.Errorf("Charges = %v, want [cus_7]", gw.Charges)
}
if err := gw.Refund(txID); err != nil {
t.Fatalf("Refund(%s) error: %v", txID, err)
}
// Refunding the same transaction twice must fail: the fake enforces it.
if err := gw.Refund(txID); err == nil {
t.Error("second Refund() should have failed, got nil")
}
}
=== RUN TestFakeGateway_ChargeThenRefund
--- PASS: TestFakeGateway_ChargeThenRefund (0.00s)
=== RUN TestFakeGateway_RejectsBadAmount
--- PASS: TestFakeGateway_RejectsBadAmount (0.00s)
PASS
ok pay 0.002s
A good fake is reusable across your whole test suite. Write it once next to the interface and every test that needs a gateway gets a fast, deterministic one.
A spy records calls so you can assert on them
Sometimes you do not want working behavior, you want to know how a collaborator was called. That is a spy: a minimal double that appends each call to a slice.
type spyGateway struct {
charges []chargeCall
err error
}
type chargeCall struct {
customerID string
cents int
}
func (g *spyGateway) Charge(customerID string, cents int) (string, error) {
g.charges = append(g.charges, chargeCall{customerID, cents})
if g.err != nil {
return "", g.err
}
return "tx_123", nil
}
The err field lets the same type double as a stub: set it and every Charge returns that error, which is how you test the failure path without a real declined card. After exercising the code, you assert on the recorded slice:
if len(gateway.charges) != 1 {
t.Fatalf("got %d charges, want 1", len(gateway.charges))
}
got := gateway.charges[0]
if got.customerID != "cus_9" || got.cents != 1500 {
t.Errorf("charge = %+v, want {cus_9 1500}", got)
}
Recording the arguments as a slice of structs, rather than tracking a boolean wasCalled, is the pattern that scales. You get call count and every argument for free, and the failure message prints the actual calls.
Faking time and randomness by injecting them
Time and randomness are the two dependencies people forget are dependencies. Both are non-deterministic global state, and both become testable the moment you inject them.
Randomness follows the same rule as time: take it as a parameter. A password reset code generator should not reach for a package-level random source. It should accept one:
// IntSource is anything that can produce a non-negative int below n.
// *math/rand.Rand satisfies it, so production code passes a real one.
type IntSource interface {
Intn(n int) int
}
func GenerateCode(src IntSource) string {
const digits = 6
code := make([]byte, digits)
for i := range code {
code[i] = byte('0' + src.Intn(10))
}
return string(code)
}
In production you pass a real *rand.Rand. In the test you pass one seeded with a constant, which makes the sequence reproducible, so you can assert on the exact output:
func TestGenerateCode_IsDeterministicWithSeededSource(t *testing.T) {
src := rand.New(rand.NewSource(1)) // fixed seed -> reproducible
got := GenerateCode(src)
const want = "177918"
if got != want {
t.Errorf("GenerateCode() = %q, want %q", got, want)
}
}
=== RUN TestGenerateCode_IsDeterministicWithSeededSource
--- PASS: TestGenerateCode_IsDeterministicWithSeededSource (0.00s)
PASS
ok resetcode 0.004s
The seed 1 produces 177918 every run on every machine. That is the payoff of injection: the “random” code is fixed in the test but genuinely random in production. Note you did not mock math/rand. You depended on a one-method interface it happens to satisfy, which is the idiomatic move and avoids the “do not mock what you do not own” trap covered below.
Faking an HTTP dependency with httptest.Server, not a mock client
When your code calls an external API, the instinct is to mock the HTTP client. Do not. Go ships net/http/httptest, which starts a real HTTP server on a loopback port. You point your client at it and control exactly what it returns. You test the real request path, real serialization, real status-code handling, with no network.
The key design choice is making the base URL injectable so the test can redirect it:
type WeatherClient struct {
baseURL string
http *http.Client
}
func NewWeatherClient(baseURL string) *WeatherClient {
return &WeatherClient{
baseURL: baseURL,
http: &http.Client{Timeout: 5 * time.Second},
}
}
func (c *WeatherClient) CurrentTempC(city string) (float64, error) {
url := fmt.Sprintf("%s/v1/current?city=%s", c.baseURL, city)
resp, err := c.http.Get(url)
if err != nil {
return 0, fmt.Errorf("weather request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("weather API returned %s", resp.Status)
}
var f forecast
if err := json.NewDecoder(resp.Body).Decode(&f); err != nil {
return 0, fmt.Errorf("decode forecast: %w", err)
}
return f.TempC, nil
}
The test stands up a server, hands its URL to the client, and asserts on the parsed result. It even inspects the incoming query string, so the server doubles as a spy on the request:
func TestCurrentTempC_ParsesResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("city"); got != "Berlin" {
t.Errorf("city query = %q, want Berlin", got)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"city":"Berlin","temp_c":19.5}`)
}))
defer server.Close()
client := NewWeatherClient(server.URL)
temp, err := client.CurrentTempC("Berlin")
if err != nil {
t.Fatalf("CurrentTempC() error: %v", err)
}
if temp != 19.5 {
t.Errorf("temp = %v, want 19.5", temp)
}
}
=== RUN TestCurrentTempC_ParsesResponse
--- PASS: TestCurrentTempC_ParsesResponse (0.00s)
=== RUN TestCurrentTempC_ServerError
--- PASS: TestCurrentTempC_ServerError (0.00s)
PASS
ok resetcode 0.007s
For the full picture of building and testing clients this way, see the Go HTTP client guide. httptest.Server is almost always a better choice than a mocked http.Client, because it exercises code a mock would skip.
Layer 3: unit-testing a service that depends on a repository and a clock
Now the real shape of production code. A billing service depends on three collaborators: a repository, a payment gateway, and a clock. Each is a small interface defined next to the service that uses it:
type Clock interface {
Now() time.Time
}
type PaymentGateway interface {
Charge(customerID string, cents int) (txID string, err error)
}
type SubscriptionRepo interface {
Find(id string) (Subscription, error)
Save(sub Subscription) error
}
func (s *Service) Renew(subID string) error {
sub, err := s.repo.Find(subID)
if err != nil {
return fmt.Errorf("renew %s: %w", subID, err)
}
if !sub.Active {
return fmt.Errorf("renew %s: subscription is inactive", subID)
}
if _, err := s.gateway.Charge(sub.CustomerID, sub.PriceCents); err != nil {
return fmt.Errorf("renew %s: charge failed: %w", subID, err)
}
sub.LastRenewed = s.clock.Now()
if err := s.repo.Save(sub); err != nil {
return fmt.Errorf("renew %s: save failed: %w", subID, err)
}
return nil
}
The test wires up three hand-written doubles, one of each kind: fixedClock (stub), inMemoryRepo (fake, a map-backed repository), and spyGateway (spy). One test checks the success path end to end, asserting on both the spy and the fake. A second checks that a declined charge never writes the renewal timestamp:
func TestRenew_ChargesAndStampsTime(t *testing.T) {
repo := newInMemoryRepo()
repo.Save(Subscription{ID: "sub_1", CustomerID: "cus_9", PriceCents: 1500, Active: true})
gateway := &spyGateway{}
renewedAt := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
svc := NewService(repo, gateway, fixedClock{t: renewedAt})
if err := svc.Renew("sub_1"); err != nil {
t.Fatalf("Renew() returned error: %v", err)
}
if len(gateway.charges) != 1 {
t.Fatalf("got %d charges, want 1", len(gateway.charges))
}
saved, _ := repo.Find("sub_1")
if !saved.LastRenewed.Equal(renewedAt) {
t.Errorf("LastRenewed = %v, want %v", saved.LastRenewed, renewedAt)
}
}
func TestRenew_ChargeFailure_IsNotSaved(t *testing.T) {
repo := newInMemoryRepo()
repo.Save(Subscription{ID: "sub_1", CustomerID: "cus_9", PriceCents: 1500, Active: true})
declined := errors.New("card declined")
gateway := &spyGateway{err: declined}
svc := NewService(repo, gateway, fixedClock{t: time.Now()})
if err := svc.Renew("sub_1"); !errors.Is(err, declined) {
t.Fatalf("Renew() error = %v, want it to wrap %v", err, declined)
}
saved, _ := repo.Find("sub_1")
if !saved.LastRenewed.IsZero() {
t.Errorf("LastRenewed = %v, want zero (charge failed)", saved.LastRenewed)
}
}
=== RUN TestRenew_ChargesAndStampsTime
--- PASS: TestRenew_ChargesAndStampsTime (0.00s)
=== RUN TestRenew_ChargeFailure_IsNotSaved
--- PASS: TestRenew_ChargeFailure_IsNotSaved (0.00s)
PASS
ok billing 0.002s
Note errors.Is in the failure test: because Renew wraps errors with %w, the test asserts the declined error propagates without matching on the exact string. That pattern is worth its own read in the error handling guide. This is a complete unit test of business logic with zero infrastructure, running in two milliseconds, using doubles you can read top to bottom.
When a generated mock or testify/mock earns its place
Hand-written fakes cover the large majority of cases. Two situations justify reaching for a framework.
Large interfaces. If an interface has fifteen methods and a test only cares about two, hand-writing a fake means stubbing thirteen methods that just panic. A generator does that for you. Strict call-order or exact-argument assertions. When the test genuinely needs “method A must be called before method B, exactly once, with these arguments, and nothing else,” a self-verifying mock expresses that directly.
The maintained generator is go.uber.org/mock (v0.5.2, the actively maintained successor to the now-archived github.com/golang/mock). You generate a mock from an interface with mockgen:
# not executed here
go run go.uber.org/mock/mockgen@latest -source=billing.go -destination=mock_gateway_test.go -package=billing
Then drive it with expectations. The following is idiomatic gomock, verified against the current docs, not executed in this sandbox because the module cannot be fetched here:
// gomock (go.uber.org/mock v0.5.2). Not executed.
func TestRenew_WithGomock(t *testing.T) {
ctrl := gomock.NewController(t)
gateway := NewMockPaymentGateway(ctrl)
gateway.EXPECT().
Charge("cus_9", 1500).
Return("tx_123", nil).
Times(1)
// ... wire gateway into the service and call Renew ...
}
ctrl verifies every expectation when the test ends. stretchr/testify (v1.10.0) takes a different shape: you embed mock.Mock in a hand-written type and program it inline, which reads well when you want assertions and doubles in one toolkit:
// testify/mock (v1.10.0). Not executed.
type MockGateway struct {
mock.Mock
}
func (m *MockGateway) Charge(customerID string, cents int) (string, error) {
args := m.Called(customerID, cents)
return args.String(0), args.Error(1)
}
func TestRenew_WithTestify(t *testing.T) {
gateway := new(MockGateway)
gateway.On("Charge", "cus_9", 1500).Return("tx_123", nil).Once()
// ... call Renew ...
gateway.AssertExpectations(t)
}
Both are correct and widely used. Reach for them when the interface is large or the ordering assertions are strict. For the billing service above, the hand-written doubles are shorter, faster to read, and do not add a dependency, so they win.
Common mistakes
Mocking types you do not own. Do not build a double for *sql.DB, *redis.Client, or a vendor SDK. You do not control their contracts, your double will drift from real behavior, and the test passes while production breaks. Wrap the dependency behind a small interface of your own and fake that, or use a real instance in an integration test.
Over-specifying call order. A test that asserts “Charge then Save then Log, in exactly that sequence” fails the moment you reorder two harmless lines, even though behavior is unchanged. Assert on outcomes (the charge happened, the row was saved) rather than the precise choreography, unless order is genuinely part of the contract.
Mocking the standard library. You saw the fix already: inject a Clock or an IntSource interface instead of trying to intercept time.Now() or rand.Intn. The stdlib is a dependency you wrap, not one you mock.
Producer-side interfaces defined only to enable mocking. A common anti-pattern is the package that provides PostgresRepo also exporting a Repository interface listing all its methods, purely so consumers can mock it. Define the interface where it is used, listing only the methods that caller needs. The interfaces guide covers why consumer-side interfaces stay small and stable while producer-side ones bloat.
Fakes that drift from real behavior. A fake is only useful if it obeys the same contract as production. If the real gateway rejects zero-amount charges, your fake must too, or your tests certify behavior that does not exist. When you find such a gap, add a shared contract test that runs against both the fake and, in an integration suite, the real implementation.
What next
You now have the idiomatic Go approach to test doubles: small consumer-side interfaces, hand-written stubs, fakes, and spies, injected time and randomness, and httptest for HTTP dependencies, with generated mocks held in reserve for large interfaces and strict ordering.
- Ground this in the full Go testing guide, the pillar for the
testingpackage, coverage, and test layout. - Combine doubles with table-driven tests to cover many cases with one test function.
- Revisit Go interfaces to sharpen the consumer-side design that makes all of this possible.
- If you are still finding your footing, the complete Go tutorial covers the structs and methods these examples assume.