The problem with real databases in tests
You write a function that fetches a user by ID. It works perfectly on your machine. You push to CI, and the test suite hangs for twenty seconds while spinning up a Docker container, then fails because the migration script ran out of order. Or worse, the test passes locally but flakes in CI because two tests race to update the same row. Testing against a real database feels like trying to test a car engine by driving the car off a cliff every time you change a spark plug. You need a way to isolate the logic from the infrastructure.
Interfaces as dependency boundaries
Go solves this with interfaces. An interface describes what a type can do, not what it is. If your code depends on an interface instead of a concrete database struct, you can swap the real database for a fake one during tests. Think of an interface like a power outlet. Your code plugs into the outlet. It doesn't care if the power comes from the grid, a generator, or a battery. In production, the outlet connects to the grid. In tests, you plug in a battery that gives you exactly the voltage you expect, every time.
Go uses structural typing. There is no implements keyword. A type satisfies an interface simply by having the methods defined in that interface. This keeps interfaces lightweight and encourages small contracts. The compiler checks satisfaction at the assignment site. If you pass a mock where an interface is expected, the compiler verifies the methods match. If they don't, the build fails. This mechanism forces your mocks to stay in sync with the contract without boilerplate declarations.
Minimal mock pattern
Here's the skeleton. Define the interface, implement it for real, implement it for fake, and inject the fake into your test. The mock uses a function field to let the test control the return value.
// UserRepository defines the contract for data access.
// Business logic depends on this interface, not a concrete struct.
type UserRepository interface {
GetByID(id int) (*User, error)
}
// MockRepo implements UserRepository for tests.
// The function field allows the test to control the return value dynamically.
type MockRepo struct {
GetByIDFunc func(id int) (*User, error)
}
// GetByID delegates to the injected function.
// The receiver name m follows convention: short and matching the type.
func (m *MockRepo) GetByID(id int) (*User, error) {
return m.GetByIDFunc(id)
}
The interface lives in the package that uses it. Go convention says "accept interfaces, return structs." Your service function accepts UserRepository, but returns a User. The interface defines the dependency boundary. The struct defines the data. This separation keeps your API stable and your internals flexible.
Walkthrough: compile and runtime behavior
When you compile, the compiler checks that MockRepo implements UserRepository. It does this by comparing the method sets. MockRepo has GetByID(int) (*User, error). UserRepository requires GetByID(int) (*User, error). The signatures match. The compiler allows the assignment.
If you add a method to the interface and forget to update the mock, the compiler rejects the build. The error appears at the call site, not the definition site. You get a message like cannot use mock (variable of type *MockRepo) as UserRepository value in argument. This is a feature. It catches drift between the contract and the implementation immediately. You don't need a separate build step to verify mocks.
At runtime, the interface value holds a pointer to the concrete type and a pointer to the method table. When you call GetByID on the interface, the runtime dispatches to the MockRepo method. The mock method calls the function field. The function field returns the value you set in the test. The chain is fast and predictable. No network calls. No disk I/O. Just memory operations.
Realistic example with context and error wrapping
Here's a service function that uses the repository. The function takes the interface as a parameter. This is dependency injection. The function also accepts a context for cancellation and deadlines.
import "context"
// UserService handles business logic.
// It accepts a UserRepository interface, allowing the caller to inject a mock.
type UserService struct {
repo UserRepository
}
// GetUser retrieves a user and applies business rules.
// Context is the first parameter, following Go convention.
func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("invalid id: %d", id)
}
user, err := s.repo.GetByID(ctx, id)
if err != nil {
// Wrap the error to preserve the chain.
return nil, fmt.Errorf("fetch user: %w", err)
}
return user, nil
}
The error handling uses fmt.Errorf with %w to wrap the error. This preserves the error chain so callers can inspect the root cause. The if err != nil block is verbose by design. The Go community accepts this boilerplate because it makes the error path explicit and visible. Don't hide errors behind a helper that swallows them. Visibility prevents silent failures.
Here's the test. It constructs the mock inline and verifies the behavior.
func TestUserService_GetUser(t *testing.T) {
// Create a mock with a predefined response.
mock := &MockRepo{
GetByIDFunc: func(ctx context.Context, id int) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
}
service := &UserService{repo: mock}
user, err := service.GetUser(context.Background(), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
The test creates a new mock for each test case. This prevents state leakage between tests. Tests should be independent. If one test modifies the mock, it shouldn't affect the next test. Creating a fresh mock guarantees isolation. The function field captures the behavior. You can change the return value per test without touching the mock struct definition.
Pitfalls and compiler errors
Fat interfaces break testability. If your interface has twenty methods, your mock becomes a nightmare. You have to implement all twenty methods even if the test only cares about one. Keep interfaces small. One method per interface is a good heuristic. If UserRepository grows to include Save, Delete, and List, consider splitting it into UserReader and UserWriter. Small interfaces scale. Fat interfaces break.
Mocking too much leads to brittle tests. If your test verifies that the mock was called with specific arguments, you are testing implementation details, not behavior. Focus on the output. If the function returns the correct result, the test passes. Verifying calls is useful only when the side effect matters, like sending an email or writing to a log. Otherwise, you risk rewriting the test every time you refactor the internal logic.
State leakage happens when the mock holds mutable state. If the mock tracks call counts or modifies internal data, reset it between tests. Or better, create a new mock for every test. The cost of allocation is negligible compared to the cost of debugging flaky tests. The worst test bug is the one that passes locally but fails in CI due to shared state.
If you forget to implement a method, the compiler catches it. The error message is plain text. You get cannot use mock (variable of type *MockRepo) as UserRepository value in argument. This error points to the assignment. It tells you exactly which type fails to satisfy the interface. You don't need to guess. The compiler is your safety net.
If you pass the wrong type to a function, the compiler rejects it. You get cannot use db (variable of type *sql.DB) as UserRepository value in argument. This happens when you try to pass a concrete type where an interface is expected, and the concrete type doesn't match. The fix is to wrap the concrete type or adjust the interface. Trust the type system. It prevents runtime panics.
Convention asides
Run gofmt on your code. The community expects standard formatting. Most editors run it on save. Don't argue about indentation or brace placement. Let the tool decide. Argue logic, not formatting.
The receiver name is usually one or two letters matching the type. Use (m *MockRepo), not (self *MockRepo) or (this *MockRepo). The compiler doesn't care, but the community does. Consistent naming makes code scannable.
If a method returns multiple values and you only care about one, use _ to discard the rest. result, _ := mock.GetByID(1) tells the reader you considered the error and chose to ignore it. Use this sparingly with errors. Ignoring an error in a test usually means the test is incomplete.
Public names start with a capital letter. Private names start lowercase. There are no keywords like public or private. Visibility is controlled by capitalization. This keeps the API surface clean.
Decision matrix
Use a hand-rolled mock struct when the interface is small and you need full control over behavior without external dependencies. Use gomock or mockgen when the interface has many methods and writing mocks by hand becomes tedious. Use a test database container when you need to verify SQL queries, migrations, or driver behavior, not just business logic. Use a fake in-memory store when the logic involves complex state transitions that a simple function mock cannot represent. Use integration tests against a real database for the critical path, and mocks for unit tests. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Mocks are fakes. Make them lie only when necessary.