There isn't a single best choice. The right approach depends on what you're testing and how much behavior you need to simulate.
Here's a practical comparison for Go.
| Approach | Best for | Pros | Cons |
|---|
| gomock | Interface-heavy codebases, large teams | Compile-time safety, generated mocks, strong expectation API | Code generation adds maintenance |
| testify/mock | Small to medium projects, quick tests | Easy to write, no generation, flexible | Less type-safe, expectations checked at runtime |
| Manual test doubles | Simple interfaces, business logic | Readable, no dependencies, behavior-focused | Can become tedious for many interfaces |
1. gomock
Example:
type UserService interface {
Get(ctx context.Context, id string) (*User, error)
}
Generated mock:
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockSvc := mocks.NewMockUserService(ctrl)
mockSvc.
EXPECT().
Get(gomock.Any(), "123").
Return(&User{Name: "Alice"}, nil)
handler := NewHandler(mockSvc)
Strengths
- Excellent for large codebases.
- Generated mocks stay synchronized with interfaces.
- Rich argument matching.
- Strict verification catches unexpected calls.
Weaknesses
- Requires
mockgen.
- Generated files increase repository size.
- Expectations can become verbose.
Good when:
- Many interfaces exist.
- APIs change frequently.
- Teams value strictness.
2. testify/mock
Example:
type MockUserService struct {
mock.Mock
}
func (m *MockUserService) Get(ctx context.Context, id string) (*User, error) {
args := m.Called(ctx, id)
return args.Get(0).(*User), args.Error(1)
}
Usage:
svc := new(MockUserService)
svc.
On("Get", mock.Anything, "123").
Return(&User{Name: "Alice"}, nil)
handler := NewHandler(svc)
svc.AssertExpectations(t)
Strengths
- Quick to start.
- Familiar if already using Testify assertions.
- Flexible behavior configuration.
Weaknesses
- Runtime string method names.
- Refactoring interface methods won't automatically update mocks.
- Less compiler assistance.
Good when:
- Small or medium projects.
- Speed matters more than strict typing.
3. Manual test doubles (often underrated)
Example:
type fakeUserService struct {
user *User
err error
called bool
}
func (f *fakeUserService) Get(ctx context.Context, id string) (*User, error) {
f.called = true
return f.user, f.err
}
Test:
svc := &fakeUserService{
user: &User{Name: "Alice"},
}
handler := NewHandler(svc)
// exercise
if !svc.called {
t.Fatal("expected Get to be called")
}
Strengths
- Extremely readable.
- No framework dependency.
- Behavior is explicit.
- Refactoring is easy.
Weaknesses
- Repetitive for many interfaces.
- Harder to model complex interaction expectations.
Ideal when interfaces have only a few methods.
What about HTTP APIs?
Instead of mocking an HTTP client, prefer using:
httptest.NewServer(...)
This tests your HTTP layer realistically:
- request serialization
- headers
- middleware
- status codes
- JSON parsing
Likewise, for database code, prefer test databases or lightweight in-memory databases over mocking SQL unless you're specifically testing error paths.
Recommendation
A pattern that works well in many Go projects is:
- Small interfaces (1–3 methods): write manual fakes.
- Complex interfaces or many collaborators: use
gomock.
- Quick prototypes or smaller applications:
testify/mock is perfectly reasonable.
- HTTP services: prefer
httptest.Server over mocking HTTP clients.
- External systems (S3, Redis, databases): when feasible, favor lightweight integration tests over extensive mocking, because they tend to catch issues that interaction-based mocks can miss.
In general, Go developers often lean toward manual test doubles first because they keep tests simple and idiomatic. Introduce a mocking framework when maintaining those manual doubles becomes more work than the framework itself.