- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You sit down to write the first test for a new Go service. Reflex kicks in. You reach for mockgen, or mockery, or testify/mock. You wire up a code generator behind a //go:generate line and commit a mocks/ directory. Three weeks later, half your CI time is regenerating mocks, and the test files read like a DSL nobody on the team agreed to learn.
You did not need any of it.
Most Go test doubles fit into four patterns, and three of the four are plain Go code with zero dependencies. The fourth boots a real Postgres in Docker. Between them, they cover the unit, integration, and contract tests that almost every Go service needs. The mocking framework is the thing you reach for last, not first.
Here are the four patterns, with the kind of code you would actually ship.
1. Hand-rolled stubs
A stub is the smallest possible test double. A struct that implements an interface and returns canned values. No fluent API. No call recording. No setup ceremony.
The interface lives next to the consumer:
package checkout
type UserFinder interface {
Find(ctx context.Context, id int) (User, error)
}
The stub is six lines:
type stubUsers struct {
user User
err error
}
func (s stubUsers) Find(
_ context.Context, _ int,
) (User, error) {
return s.user, s.err
}
The test reads top-to-bottom with no setup phase:
func TestCharge_UserNotFound(t *testing.T) {
ctx := context.Background()
svc := checkout.NewService(
stubUsers{err: ErrNotFound},
stubPayments{},
)
_, err := svc.Charge(ctx, 42, 1000)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
}
That is the whole pattern. When the interface has one or two methods and you only care that some value comes back, write a stub. The test stops being about the framework and starts being about the behavior.
Watch for two failure modes. If you find yourself writing stubUsersA, stubUsersB, stubUsersC to vary the return value across tests, the stub wants to become a configurable fake (pattern 2). If your stub has fifteen methods of which ten panic with "not implemented", the consumer's interface is too wide. Shrink the interface, not the stub.
2. Interfaces with simple struct impls (configurable fakes)
A fake is a working in-memory implementation of the interface. It stores real data. It returns it back. It enforces the same contract the production adapter does, without the network or the disk.
The same UserFinder port, with a fake that holds a map:
type fakeUsers struct {
mu sync.Mutex
users map[int]User
}
func newFakeUsers() *fakeUsers {
return &fakeUsers{users: map[int]User{}}
}
func (f *fakeUsers) Add(u User) {
f.mu.Lock()
defer f.mu.Unlock()
f.users[u.ID] = u
}
func (f *fakeUsers) Find(
_ context.Context, id int,
) (User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.users[id]
if !ok {
return User{}, ErrNotFound
}
return u, nil
}
Now the test seeds data and asserts on outcome:
func TestCharge_HappyPath(t *testing.T) {
ctx := context.Background()
users := newFakeUsers()
users.Add(User{ID: 42, Email: "a@b.test"})
payments := newFakePayments()
svc := checkout.NewService(users, payments)
receipt, err := svc.Charge(ctx, 42, 1000)
if err != nil {
t.Fatalf("charge failed: %v", err)
}
if receipt.AmountCents != 1000 {
t.Errorf(
"amount = %d, want 1000",
receipt.AmountCents,
)
}
if got := payments.Count(); got != 1 {
t.Errorf("payments = %d, want 1", got)
}
}
This is the pattern that pays back the most over time. One fake, used by every test in the package, no per-test setup. When Charge gets refactored to call Find twice instead of once, the test does not budge. It only fails when behavior breaks.
A fake belongs in the same package as the consumer, in a _test.go file or behind a build tag. Keep it close to the port it implements, never under a mocks/ directory at the root. When the port's signature changes, the compiler points you straight at the fake, and you fix it once.
3. httptest.Server for outbound HTTP
Most Go services talk to at least one HTTP API. A payments gateway, a feature flag service, an internal microservice, an LLM provider. The temptation is to interface-wrap the HTTP client and mock that. You do not need to.
net/http/httptest ships in the standard library and gives you a real HTTP server bound to a real port on 127.0.0.1. You point your client at it. Your code path goes through the actual http.Client, the actual JSON encoder, the actual TCP socket. The only thing replaced is what is on the other end.
func TestPaymentClient_Charge_Success(t *testing.T) {
ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/charges" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("method = %s", r.Method)
}
w.Header().Set(
"Content-Type",
"application/json",
)
_, _ = w.Write([]byte(
`{"id":"ch_123","status":"ok"}`,
))
},
))
defer srv.Close()
client := payments.New(srv.URL, srv.Client())
receipt, err := client.Charge(ctx, 42, 1000)
if err != nil {
t.Fatalf("charge failed: %v", err)
}
if receipt.ID != "ch_123" {
t.Errorf("id = %s", receipt.ID)
}
}
A few details that matter. srv.URL is the base URL of the test server, ready for your client constructor. srv.Client() returns an *http.Client already configured to talk to srv. If you need TLS, swap NewServer for NewTLSServer and srv.Client() is already configured for the test cert. Your production code uses its real retry policy, real timeouts, real header handling. You exercise the bytes-on-the-wire boundary, not a synthetic interface that approximates it.
For testing failures (502, malformed JSON, slow responses), the server handler is just a function. Return what you want:
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
},
))
A retry test that asserts your client gives up after N attempts can count requests inside the handler. A timeout test can time.Sleep before responding. Anything an HTTP server can do, the test server can do, because it is an HTTP server.
The boundary you are testing is HTTP. Test it with HTTP.
4. testcontainers-go for backing services
Postgres, Redis, RabbitMQ, Kafka, Localstack. The minute the test needs to verify that a SQL query actually does what you think, in-memory fakes stop being enough. SQLite-in-place-of-Postgres lies about transactions, JSON columns, ON CONFLICT, and partial indexes. An in-memory map-as-Redis lies about TTLs and pipelining. The fakes are useful for the unit layer; the integration layer needs the real thing.
testcontainers-go boots a real container for the duration of a test. A real Postgres listens on a real port. Your code runs unmodified. The container is gone at the end of the test.
func TestUserRepository_FindByEmail(t *testing.T) {
ctx := context.Background()
pg, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog(
"database system is ready",
).WithOccurrence(2),
),
)
if err != nil {
t.Fatalf("start postgres: %v", err)
}
t.Cleanup(func() {
_ = pg.Terminate(ctx)
})
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if err := migrate(db); err != nil {
t.Fatal(err)
}
repo := userdb.New(db)
err = repo.Save(ctx, User{ID: 42, Email: "a@b.test"})
if err != nil {
t.Fatal(err)
}
got, err := repo.FindByEmail(ctx, "a@b.test")
if err != nil {
t.Fatalf("find: %v", err)
}
if got.ID != 42 {
t.Errorf("id = %d, want 42", got.ID)
}
}
The snippet pulls symbols from three import paths: postgres.Run and the postgres.With* options live under github.com/testcontainers/testcontainers-go/modules/postgres, testcontainers.WithWaitStrategy lives under github.com/testcontainers/testcontainers-go, and wait.ForLog lives under github.com/testcontainers/testcontainers-go/wait.
Practical rules that keep this fast. Run one container per package and share it across test functions inside that package via TestMain, not one per test function (containers take a few seconds to boot). The shared-container shape looks like this:
var pgDSN string
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine")
if err != nil {
log.Fatalf("start postgres: %v", err)
}
pgDSN, _ = pg.ConnectionString(ctx, "sslmode=disable")
code := m.Run()
_ = pg.Terminate(ctx)
os.Exit(code)
}
Use schema-per-test or transaction rollback for isolation between tests on the shared container. Tag these tests with //go:build integration and run them on a separate CI step from your unit tests, so the millisecond unit suite stays a millisecond unit suite.
The payoff is that your repository tests catch bugs the unit-level fake cannot see. Constraint violations. Migration errors. Timezone handling. Quirks of RETURNING clauses. The fake says "yes, it stored". The real Postgres tells you whether it stored what your migration thought it stored.
When gomock and mockery earn their keep
After all that, generated mocks still have a place. Two cases.
Wide third-party interfaces you do not own. If you need to verify error handling for a vendor SDK with a 30-method interface, hand-rolling a fake is busywork. mockgen reads the interface declaration and emits a stub of every method, with EXPECT() for the one or two you care about. The other 28 panic if called, which is what you want.
Strict interaction-protocol tests. Some boundaries genuinely care about call order, exact argument values, or call counts. Audit-log writes. Lock-then-write sequences. Idempotency-key assertions. gomock.InOrder and EXPECT().Times(n) are the right tool for that test. Do not use them for the other 95% of tests, where you only care about the outcome.
If your codebase is mostly small consumer-side interfaces and your tests assert on outcomes, you will reach for a generated mock once a quarter, not once a function. That is the right ratio.
The seam is the point
Every pattern here works because the consumer accepts an interface that the consumer itself declares. The hand-rolled stub, the configurable fake, the fake adapter that talks to the test HTTP server, the real adapter that talks to the testcontainers Postgres: all four satisfy the same port. Production picks one adapter; tests pick another. The framework, when one shows up, is at most a code-gen convenience for one of those adapters.
Hexagonal architecture gives this its name, but the underlying habit is older than the name. Small interfaces at the consumer, real structs at the producer, adapters in between. Once that shape is in place, the test double is a five-minute decision: stub, fake, httptest, or container, depending on what the test is actually about.
Try this on the next service you write in Go. Do not import a mocking library on day one. See how far you get with these four patterns. The answer, for almost every codebase, is all the way to production.
If you want the architectural arc
The four patterns above sit at the test boundary. The same shape sits at every other boundary in a Go service: small consumer-side interfaces, real adapters, fakes for tests, applied to the HTTP handler, the database adapter, the message broker, the cache. Get the seam right once, and the rest of the architecture falls out of it.
That is what Hexagonal Architecture in Go covers in long form: ports, adapters, the dependency rule, conformance tests that run the same suite against the fake and the real container so the fake stays honest, and what your cmd/, internal/, and pkg/ layout looks like when the architecture is doing the work.
If you write Go services and your test suite already feels like it weighs more than your service, the problem is rarely the tests. It is the boundary.

Top comments (0)