Introduction
After 39 days of building Go APIs, you've written tests, dealt with database connections, and probably spent time wondering how to test code that talks to external services. Mocking in Go: concepts, trade-offs and production lessons addresses exactly that problem—but the solution often surprises teams coming from languages like Java or C#. Rather than reaching for heavyweight mocking frameworks, Go's philosophy of small interfaces and hand-written fakes tends to lead to clearer, more maintainable test suites. We've learned this the hard way in production systems handling millions of requests daily.
Why Go's approach to mocking differs
Go doesn't ship with a built-in mocking framework in the standard library, and the language's designers intentionally kept it that way. This constraint forces us to think differently about test design. Instead of spy objects and verification counts, we write simple, explicit fakes that implement the same interface as the real dependency.
This approach has profound benefits: your fakes are readable code that teammates understand on first glance, they're easy to debug, and they're often small enough to live in your test file itself. The downside is discipline—you must design interfaces to be mockable in the first place, which means keeping them small and focused.
Small interfaces are easy to fake
The foundation of painless mocking in Go is the interface principle: small, focused interfaces are trivial to fake. When an interface has one or two methods, writing a hand-rolled implementation takes seconds.
Consider a payment processor:
type PaymentProcessor interface {
Charge(ctx context.Context, amountCents int64, cardToken string) (transactionID string, err error)
}A fake for this is a one-liner:
type fakePaymentProcessor struct {
chargeFunc func(ctx context.Context, amountCents int64, cardToken string) (string, error)
}
func (f *fakePaymentProcessor) Charge(ctx context.Context, amountCents int64, cardToken string) (string, error) {
return f.chargeFunc(ctx, amountCents, cardToken)
}In your test:
func TestOrderCheckout(t *testing.T) {
processor := &fakePaymentProcessor{
chargeFunc: func(ctx context.Context, amountCents int64, cardToken string) (string, error) {
return "txn_12345", nil
},
}
order := NewOrder(processor)
err := order.Checkout(context.Background(), 9999, "tok_visa")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}This is explicit, readable, and requires zero reflection magic. Any engineer on your team can follow exactly what the test is doing. The fake is defined inline, scoped to the test, and the behavior is obvious.
The principle here is simple: keep interfaces small (1–3 methods). When an interface grows beyond that, it's usually a sign that you're mixing concerns and should split it.
Hand-written fakes are readable
We've seen teams jump straight to code generation or reflection-based mocking frameworks when a hand-written fake would be faster and clearer. The cognitive load of understanding a hand-written fake is near zero; the cognitive load of understanding what a generated mock does, how to configure it, and why tests fail is often higher.
Real example from a ride-hailing system: a notifier interface.
type Notifier interface {
SendSMS(ctx context.Context, phoneNumber, message string) error
}
// In your test file
type recordingSMS struct {
sent []string
}
func (r *recordingSMS) SendSMS(ctx context.Context, phoneNumber, message string) error {
r.sent = append(r.sent, fmt.Sprintf("%s: %s", phoneNumber, message))
return nil
}
// Test
func TestDriverNotifiedOnNewRide(t *testing.T) {
notifier := &recordingSMS{}
service := NewRideService(notifier)
err := service.AssignRide(context.Background(), "driver_1", rideID)
if err != nil {
t.Fatal(err)
}
if len(notifier.sent) != 1 {
t.Errorf("expected 1 SMS, got %d", len(notifier.sent))
}
}The recordingSMS is a real struct that captures calls. It's testable, debuggable, and if it fails, you can print notifier.sent and see exactly what was sent. No magic. No reflection. No learning curve.
Hand-written fakes shine when you need to simulate failures too:
type flakySMS struct {
failOnCount int
callCount int
}
func (f *flakySMS) SendSMS(ctx context.Context, phoneNumber, message string) error {
f.callCount++
if f.callCount == f.failOnCount {
return errors.New("service unavailable")
}
return nil
}Writing this takes two minutes. It's obvious what it does. You can reuse it across multiple tests. No surprise behavior, no hidden state, no version incompatibilities.
gomock or mockery for large interfaces
There's a line beyond which hand-written fakes become tedious. Once an interface has 8+ methods (often a sign of design problems, but sometimes unavoidable), code generation makes sense.
The Go community's two main tools are gomock (from the Uber ecosystem) and mockery. Both use go generate to produce mock implementations.
Consider a database interface that's unavoidable in size:
type Store interface {
GetUser(ctx context.Context, id string) (*User, error)
SaveUser(ctx context.Context, user *User) error
DeleteUser(ctx context.Context, id string) error
ListUsers(ctx context.Context, limit int) ([]*User, error)
GetUserByEmail(ctx context.Context, email string) (*User, error)
UpdateUserStatus(ctx context.Context, id, status string) error
GetUserOrders(ctx context.Context, userID string) ([]*Order, error)
SaveOrder(ctx context.Context, order *Order) error
}With mockery:
//go:generate mockery --name=Store --output=mocks
// Then in tests:
func TestCreateOrder(t *testing.T) {
storeMock := mocks.NewStore(t)
storeMock.On("GetUser", mock.Anything, "user_1").Return(&User{ID: "user_1"}, nil)
storeMock.On("SaveOrder", mock.Anything, mock.MatchedBy(func(o *Order) bool {
return o.UserID == "user_1"
})).Return(nil)
service := NewOrderService(storeMock)
err := service.CreateOrder(context.Background(), "user_1", items)
if err != nil {
t.Fatal(err)
}
storeMock.AssertExpectations(t)
}The generated mock gives you call verification and assertion methods. It's powerful, but notice the cost: you're now asserting on calls, not just on the behavior your code produces. The test becomes about implementation details, not outcomes.
Use code-generated mocks when:
- The interface has many methods (8+).
- You genuinely need to verify that specific calls were made in a specific order.
- Replacing it with a real implementation (like Testcontainers) is impractical.
Otherwise, stick with hand-written fakes.
Prefer real dependencies with Testcontainers
The best mock is no mock. If you can run the real dependency (a database, Redis cache, message queue), your test confidence increases dramatically. This is where Testcontainers for Go shines.
Instead of mocking a database, spin up a real PostgreSQL container:
import "github.com/testcontainers/testcontainers-go"
func TestUserRepository(t *testing.T) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:15",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "password",
"POSTGRES_DB": "testdb",
},
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatal(err)
}
defer container.Terminate(ctx)
// Get real connection string and run actual queries
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432/tcp")
db, _ := sql.Open("postgres", fmt.Sprintf("postgres://postgres:password@%s:%s/testdb?sslmode=disable", host, port.Port()))
repo := NewUserRepository(db)
user, err := repo.GetUser(ctx, "user_1")
// Now you're testing against real SQL, real indexes, real constraints
}This approach is slower than mocks (containers take time to start), but the confidence gain is enormous. You catch SQL errors, transaction bugs, constraint violations, and connection pool issues that mocks would hide.
Strategy for teams:
- Unit tests (fast feedback): Use hand-written fakes for external services (payment, email, SMS).
- Integration tests (moderate speed): Use Testcontainers for databases and caches.
- E2E tests (slow but complete): Test against real services in a staging environment.
In production systems we've shipped, the combination of small hand-written fakes for business logic plus Testcontainers for infrastructure gave us fast feedback loops without sacrificing confidence.
Common mistakes
Mistake 1: Interfaces that are too fat. If you have an interface with 15 methods, you're either testing a God object or you're not splitting concerns properly. Refactor first, then mock.
Mistake 2: Over-asserting on calls. When you use mocking frameworks, it's tempting to assert that Save was called exactly once with exactly this data. But that's testing implementation, not behavior. Assert on outcomes instead.
Mistake 3: Treating fakes as simple stubs. A fake can be sophisticated—it can track calls, simulate failures, track state. Use that capability to make tests more thorough.
Mistake 4: Avoiding Testcontainers because "they're too slow." Yes, starting a database container takes seconds. But you catch real bugs. Reserve container-based tests for genuinely uncertain code paths, not every test.
Mistake 5: Mocking too early in development. If you're still learning your domain, write tests against real dependencies first. You'll understand the shape of your interfaces better before you try to fake them.
Key takeaways
- Keep interfaces small (1–3 methods). Small interfaces are trivial to fake by hand.
- Write fakes as regular structs in your test files. They're readable, debuggable, and require zero magic.
- Reach for code-generated mocks (gomock, mockery) only when interfaces are large or you need strict call verification.
- Prefer real dependencies via Testcontainers when testing code that touches databases or message queues. The confidence gain outweighs the slower test execution.
- Assert on behavior and outcomes, not on call counts or method invocation order.
- Use hand-written fakes for external services (payments, email, third-party APIs) to keep tests fast and isolated.
- Remember: mocking is a design signal. If an interface is hard to mock, it probably needs redesign.






Comments
Loading comments…