Mock Without a Framework Using Small Interfaces in Go
Test Go code without a mocking framework: a one-method interface at the point of use, a function-field stub, a func adapter and a partial double by embedding.

Go does not need a mocking framework for most tests. The language already has what makes mocks cheap: interfaces satisfied implicitly, struct literals, closures and embedding. A handful of patterns cover nearly everything people reach for gomock for, and the resulting doubles are plain Go that a teammate can read without learning a tool.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article shows those patterns on a small weather client, with tests that ran on Go 1.25.6. It complements the vocabulary of fakes, stubs and mocks and the comparison of generators in gomock and mockery, and uses the same idea as testing HTTP clients with a fake RoundTripper.
Define the interface where it is used
The code under test, Weather, needs to fetch a URL. In production that is an HTTP client; in a test it should be a stand-in. The interface goes in the consuming package and contains only what the code calls:
package main
import (
"context"
"encoding/json"
"fmt"
)
// Fetcher is defined here, in the package that uses it, with the one method this code needs.
type Fetcher interface {
Fetch(ctx context.Context, url string) ([]byte, error)
}
// FetcherFunc lets any function be a Fetcher, the way http.HandlerFunc lets a function be a Handler.
type FetcherFunc func(ctx context.Context, url string) ([]byte, error)
func (f FetcherFunc) Fetch(ctx context.Context, url string) ([]byte, error) { return f(ctx, url) }
type Weather struct {
Fetcher Fetcher
BaseURL string
}
type Report struct {
City string `json:"city"`
TempC float64 `json:"temp_c"`
}
func (w *Weather) Temperature(ctx context.Context, city string) (float64, error) {
body, err := w.Fetcher.Fetch(ctx, w.BaseURL+"/v1/weather?city="+city)
if err != nil {
return 0, fmt.Errorf("fetch weather for %q: %w", city, err)
}
var r Report
if err := json.Unmarshal(body, &r); err != nil {
return 0, fmt.Errorf("decode weather for %q: %w", city, err)
}
return r.TempC, nil
}
func main() {}Fetcher has one method. Not the twenty methods of an SDK client, not the whole http.Client: one. That is the Go proverb "the bigger the interface, the weaker the abstraction" turned into a testing technique. A one-method interface is easy to satisfy in production (wrap the real client in a tiny adapter), and trivial to double in a test.
A stub with a function field
The first double is a struct with a field holding the behaviour:
package main
import (
"context"
"errors"
"testing"
)
// A hand-written stub with a function field: each test sets only the behaviour it needs.
type stubFetcher struct {
FetchFunc func(ctx context.Context, url string) ([]byte, error)
calls []string
}
func (s *stubFetcher) Fetch(ctx context.Context, url string) ([]byte, error) {
s.calls = append(s.calls, url)
return s.FetchFunc(ctx, url)
}
var _ Fetcher = (*stubFetcher)(nil) // compile-time proof that the double satisfies the interface
func TestTemperature(t *testing.T) {
boom := errors.New("connection refused")
tests := []struct {
name string
fetch func(context.Context, string) ([]byte, error)
want float64
wantErr error
wantMsg bool
}{
{name: "success", fetch: func(context.Context, string) ([]byte, error) { return []byte(`{"city":"Oslo","temp_c":-3.5}`), nil }, want: -3.5},
{name: "upstream error is wrapped", fetch: func(context.Context, string) ([]byte, error) { return nil, boom }, wantErr: boom},
{name: "garbage body", fetch: func(context.Context, string) ([]byte, error) { return []byte("<html>"), nil }, wantMsg: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &stubFetcher{FetchFunc: tc.fetch}
w := &Weather{Fetcher: s, BaseURL: "https://api.example.test"}
got, err := w.Temperature(context.Background(), "Oslo")
switch {
case tc.wantErr != nil:
if !errors.Is(err, tc.wantErr) {
t.Fatalf("error = %v, want one wrapping %v", err, tc.wantErr)
}
case tc.wantMsg:
if err == nil {
t.Fatal("want a decode error")
}
default:
if err != nil || got != tc.want {
t.Fatalf("Temperature = %v, %v; want %v", got, err, tc.want)
}
}
if len(s.calls) != 1 || s.calls[0] != "https://api.example.test/v1/weather?city=Oslo" {
t.Errorf("calls = %v, want one call to the weather URL", s.calls)
}
})
}
}
// The adapter type removes even the struct: a closure is a Fetcher.
func TestWithFunctionAdapter(t *testing.T) {
var sawContext bool
type key struct{}
ctx := context.WithValue(context.Background(), key{}, "marker")
w := &Weather{BaseURL: "x", Fetcher: FetcherFunc(func(ctx context.Context, url string) ([]byte, error) {
sawContext = ctx.Value(key{}) == "marker" // the caller's context must reach the fetcher
return []byte(`{"temp_c": 21}`), nil
})}
got, err := w.Temperature(ctx, "Rome")
if err != nil || got != 21 || !sawContext {
t.Fatalf("got %v, %v, context passed through: %v", got, err, sawContext)
}
}
// A bigger interface in the same package, to show embedding for partial doubles.
type Cache interface {
Get(key string) ([]byte, bool)
Set(key string, val []byte)
Delete(key string)
Len() int
}
// onlyGet implements the whole interface by embedding it; calling anything but Get panics on the nil embedded value.
type onlyGet struct{ Cache }
func (onlyGet) Get(string) ([]byte, bool) { return []byte("v"), true }
func lookup(c Cache, k string) []byte { v, _ := c.Get(k); return v }
func TestPartialDouble(t *testing.T) {
if got := lookup(onlyGet{}, "k"); string(got) != "v" {
t.Fatalf("got %q", got)
}
defer func() {
if recover() == nil {
t.Fatal("expected a panic when the code touched a method the test did not provide")
}
t.Log("calling Delete on the partial double panicked, as intended: the code under test must not call it")
}()
onlyGet{}.Delete("k")
}stubFetcher has a FetchFunc field and a Fetch method that calls it and records the URL. Each test case supplies the function it needs, so one double type covers every scenario, and nothing is generated. Three details:
var _ Fetcher = (*stubFetcher)(nil)is a compile-time assertion that the double implements the interface. If the interface gains a method, the test file stops compiling, which is better than a runtime surprise.- Recording calls (
calls) lets every case assert on the request that was made without a separate mock expectation. This is a spy built into the stub (the four kinds of double). - The table covers error paths. The second case returns
connection refused, and the test asserts the error wraps it witherrors.Is, which checks the%win the code under test. The third returns garbage and expects a decode error. Error paths are where hand-written doubles pay off most: a function field returning an error is a one-line setup.
A function type that implements the interface
For tests that need only a closure, add a function type with a method:
type FetcherFunc func(ctx context.Context, url string) ([]byte, error)
func (f FetcherFunc) Fetch(ctx context.Context, url string) ([]byte, error) { return f(ctx, url) }It is the pattern from http.HandlerFunc. A test can then write Fetcher: FetcherFunc(func(ctx context.Context, url string) ([]byte, error) { ... }) without declaring a struct. TestWithFunctionAdapter uses it to check something a call counter cannot: the caller's context.Context reached the dependency. The test puts a value in the context, and the closure checks that the value is visible, which proves the code passes ctx through instead of creating a new one. That check catches the lost context bug that causes real outages.
The adapter is worth keeping in production code too if other code wants to pass functions as dependencies, since it lets callers choose between a struct and a closure.
A partial double by embedding
Sometimes the interface is larger than the code uses and you cannot shrink it (a type from another package, a legacy interface). Embedding the interface in a struct gives you a double with a method for every method of the interface and lets you override only the ones you need:
type onlyGet struct{ Cache }
func (onlyGet) Get(string) ([]byte, bool) { return []byte("v"), true }onlyGet{} satisfies Cache (four methods) while implementing one. The embedded Cache is nil, so calling any other method panics with a nil pointer dereference. That is useful: the test passes only if the code under test never touches the others. The lab's TestPartialDouble calls Delete and checks that it panics, with the log line calling Delete on the partial double panicked, as intended. Use this when the panic is the point (a read-only function that must not write), and add real overrides for the methods you do expect.
The better long-term fix is a smaller interface in the consumer, but embedding gets you through.
Running it
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//'--- PASS: TestTemperature
--- PASS: TestTemperature/success
--- PASS: TestTemperature/upstream_error_is_wrapped
--- PASS: TestTemperature/garbage_body
--- PASS: TestWithFunctionAdapter
weather_test.go:99: calling Delete on the partial double panicked, as intended: the code under test must not call it
--- PASS: TestPartialDoubleAll three table cases pass as subtests, the adapter test passes, and the partial double test passes with its log line.
Adapting a third-party client
When the real dependency is an SDK type with a big method set, wrap it once in a small adapter that implements your interface, and keep the adapter thin enough that it needs little testing of its own:
type httpFetcher struct{ client *http.Client }
func (h httpFetcher) Fetch(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := h.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}The adapter is the only place that knows about net/http. Your business code depends on Fetcher, tests replace it with the stub, and one integration test points the adapter at an httptest server to prove it works. This is the arrangement that keeps SDK changes from rippling through your tests: when the SDK's API changes, you fix one adapter.
When a generator is the better tool
Hand-written doubles stop being pleasant when:
- the interface has many methods and several tests need different subsets;
- you want strict verification of call counts and argument matching across many tests;
- the interface is generated or changes often.
A generator (mockery, gomock, moq) removes the boilerplate in those cases. moq in particular generates exactly the function-field stub shown above, so it keeps the readable style. For everything else, the hand-written version is a dozen lines and has no build step.
Habits
- One interface, defined by the consumer, one to three methods.
- Name the double for what it does (
stubFetcher,failingStore,recordingMailer), notmock. - Give doubles a compile-time interface check.
- Keep doubles in
_test.gofiles unless several packages share them, in which case put them in atestutilorinternal/fakepackage. - Build fresh doubles per test, to keep tests independent and parallel-safe.
- Test through the interface, not around it. If a test needs to reach into the double to make it work, the interface is probably wrong.
- Pair doubles with a few integration tests that use the real dependency, so the interface you invented matches reality (integration tests).
Go's own guidance is in the proverbs (Go Proverbs) and the wiki's code review comments on interfaces, which say to define interfaces on the consumer side.


