When interfaces meet tests
You are testing a function that sends a notification. The real implementation hits an API, charges a credit card, or writes to a database. You want to verify the logic without triggering side effects. You extract an interface. Now you need a fake implementation that records calls and returns controlled values.
Go does not include a mocking framework in the standard library. The standard library gives you interfaces, but it does not give you a magic @Mock annotation or a runtime reflection-based mock factory. You bring in gomock. It is an external tool that generates mock structs from your interfaces. The mocks are real Go code. They compile. They run fast. They enforce contracts at test time.
Mocks are generated code, not magic
gomock works by reading your interface definition and writing a .go file containing a struct that implements that interface. The generated struct holds a controller and a list of expectations. When your code calls a method on the mock, the mock checks the expectations. If the call matches, the mock returns the configured value. If the call is unexpected, the mock fails the test.
This approach fits Go's static typing. The mock is a struct. The compiler checks that the mock implements the interface. If you add a method to the interface and forget to regenerate the mock, the build fails. You get compile-time safety for the contract and runtime verification for the behavior.
Go follows the convention "accept interfaces, return structs." Your production code accepts the interface. Your test returns a mock struct. This keeps dependency injection clean and explicit. The mock is just another struct that satisfies the interface.
Minimal example
Here is the interface you want to mock. It defines a simple notifier.
// Notifier defines the contract for sending messages.
type Notifier interface {
// Send delivers a message and returns an error if delivery fails.
Send(msg string) error
}
You run mockgen to generate the mock. The tool reads the interface and writes the mock code.
# Install mockgen if you haven't already.
go install go.uber.org/mock/mockgen@latest
# Generate the mock file.
mockgen -source=notifier.go -destination=mock_notifier.go -package=main
The generated file contains a MockNotifier struct and a NewMockNotifier constructor. The mock implements Notifier. You use it in a test by creating a controller, setting expectations, and verifying the result.
package main
import (
"testing"
"go.uber.org/mock/gomock"
)
func TestNotifierUsage(t *testing.T) {
// NewController creates a mock controller tied to the test lifecycle.
ctrl := gomock.NewController(t)
// Finish checks that all expectations were met when the test ends.
defer ctrl.Finish()
// NewMockNotifier creates the mock struct linked to the controller.
mock := NewMockNotifier(ctrl)
// EXPECT registers a call that must happen.
// Return defines the value the mock returns when the call occurs.
mock.EXPECT().Send("hello").Return(nil)
// Call the mock. This satisfies the expectation.
err := mock.Send("hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
The controller tracks the state. EXPECT adds an expectation to the queue. Return configures the result. When Send is called, the mock matches the arguments against the expectation. If they match, the mock returns nil. If the call never happens, ctrl.Finish() fails the test.
Mocks are code. Treat them like code.
How the mock lifecycle works
The mock lifecycle has three phases: setup, execution, and verification.
In the setup phase, you create the controller and the mock. You register expectations using EXPECT. You can chain matchers to specify argument constraints. gomock.Any() matches any value. gomock.Eq(x) matches a specific value. You can configure multiple returns or errors.
In the execution phase, your code under test calls the mock. The mock intercepts the call. It searches for a matching expectation. If found, it returns the configured value and marks the expectation as satisfied. If no expectation matches, the mock panics with an error message like unexpected call to Notifier.Send(). This stops the test immediately.
In the verification phase, ctrl.Finish() runs. It checks that all expectations were satisfied. If an expectation was registered but never called, Finish fails the test with a message like missing call(s). This catches cases where your code skipped a path.
The controller is tied to the test via t. If the mock fails, the controller calls t.Fatal or t.FailNow. This integrates with Go's testing framework. You do not need to check return values from the mock calls. The mock handles the failure.
Generate mocks early. Regenerate often.
Realistic service test
Real code often involves services that depend on multiple interfaces. Here is a service that uses a notifier and a repository. The test mocks both dependencies.
// Repository defines data access.
type Repository interface {
// GetItem retrieves an item by ID.
GetItem(id int) (string, error)
}
// Service coordinates business logic.
type Service struct {
Repo Repository
Notifier Notifier
}
// Process fetches an item and sends a notification.
func (s *Service) Process(id int) error {
item, err := s.Repo.GetItem(id)
if err != nil {
return err
}
return s.Notifier.Send(item)
}
The test creates mocks for both interfaces. It sets up the repository to return a value, and the notifier to accept that value. It verifies the flow end-to-end.
func TestServiceProcess(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := NewMockRepository(ctrl)
mockNotif := NewMockNotifier(ctrl)
svc := &Service{
Repo: mockRepo,
Notifier: mockNotif,
}
// Repo returns "item-123" for ID 1.
mockRepo.EXPECT().GetItem(1).Return("item-123", nil)
// Notifier receives "item-123".
mockNotif.EXPECT().Send("item-123").Return(nil)
err := svc.Process(1)
if err != nil {
t.Fatalf("Process failed: %v", err)
}
}
The test isolates the service logic. The repository mock simulates a database hit. The notifier mock simulates an API call. The service code runs against the mocks. The controller verifies that GetItem and Send were called with the correct arguments.
You can also test error paths. Configure the repository to return an error. Verify that the service returns the error and does not call the notifier.
func TestServiceProcessRepoError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := NewMockRepository(ctrl)
mockNotif := NewMockNotifier(ctrl)
svc := &Service{
Repo: mockRepo,
Notifier: mockNotif,
}
// Repo returns an error.
mockRepo.EXPECT().GetItem(1).Return("", errors.New("not found"))
// Notifier must not be called. No EXPECT for Send.
err := svc.Process(1)
if err == nil {
t.Fatal("expected error, got nil")
}
}
The absence of an expectation for Send is significant. If the service calls Send despite the error, Finish will fail with unexpected call. This verifies the error handling path without extra assertions.
The compiler catches missing methods. The mock catches missing calls.
Pitfalls and errors
gomock is powerful, but it introduces workflow friction and runtime failures.
Interface drift is the most common issue. If you add a method to an interface, the generated mock becomes stale. The mock struct no longer implements the interface. The compiler rejects the code with an error like MockNotifier does not implement Notifier (missing NewMethod). You must run mockgen again to regenerate the mock. Add a build step or a pre-commit hook to keep mocks up to date.
Over-mocking makes tests brittle. If you mock every dependency, your tests verify the mocks more than the logic. Mocks are expensive to maintain. Use them for external dependencies or slow operations. Use real structs for fast, pure logic.
gomock errors are runtime errors. The compiler cannot check expectations. If you register an expectation with the wrong arguments, the test fails at runtime. The error message is usually clear. unexpected call means the code called the mock with arguments that do not match any expectation. missing call means an expectation was never satisfied. Read the error message carefully. It shows the expected arguments and the actual arguments.
Generated code can be large. Complex interfaces produce large mock files. This is normal. The mock file is just code. Run gofmt on it if your editor does not do so automatically. mockgen outputs formatted code, but some editors may reformat it on save. Trust gofmt. Argue logic, not formatting.
Do not mock value types. Mock interfaces. If you try to mock a struct, gomock cannot generate the mock. Extract an interface first. This reinforces the "accept interfaces" convention.
The worst mock bug is the one that never logs.
Decision matrix
Use gomock when you have complex interfaces and need strict verification of call counts, arguments, and order. Use gomock when you want type-safe mocks that compile and integrate with the build process. Use gomock when your team is comfortable with code generation and managing generated files.
Use a hand-written mock when the interface has one or two methods and you want to avoid the generation step. A hand-written mock is just a struct with fields to record calls. It is simple and dependency-free.
Use testify/mock when you prefer a fluent API and want to define expectations inline without a separate generation command. testify uses reflection to create mocks at runtime. It is more flexible but loses compile-time type safety for the mock structure.
Use the real dependency when the external service is fast, reliable, and the integration behavior is what you actually need to verify. Mocking hides integration bugs. If the cost of setup is low, test against the real thing.