Go Test Helpers: testify and gomock
Go's standard library testing package is deliberately minimal. You get `t.Error()`, `t.Fatal()`, and not much else. This philosophy works for simple cases, but real-world tests quickly become verbose:
Key Insights
- testify’s
assertvsrequiredistinction is critical: userequirefor preconditions that must pass,assertfor validations where you want to see all failures at once - gomock’s
mockgengenerates type-safe mocks from interfaces, but you must design your code around interfaces for this to work—dependency injection isn’t optional - Combining both libraries creates a powerful testing workflow: testify for readable assertions and test organization, gomock for isolating units from their dependencies
The Case for Test Helpers
Go’s standard library testing package is deliberately minimal. You get t.Error(), t.Fatal(), and not much else. This philosophy works for simple cases, but real-world tests quickly become verbose:
// Standard library approach
func TestUserAge(t *testing.T) {
user := NewUser("Alice", 30)
if user.Age != 30 {
t.Errorf("expected age to be 30, got %d", user.Age)
}
if user.Name != "Alice" {
t.Errorf("expected name to be Alice, got %s", user.Name)
}
}
This works, but you’re writing the same conditional-and-error pattern repeatedly. testify and gomock solve different problems: testify makes assertions readable and provides test organization, while gomock generates mock implementations of interfaces for isolation testing.
Getting Started with testify
Install testify with:
go get github.com/stretchr/testify
testify provides three main packages. assert contains assertion functions that record failures but continue execution. require contains the same functions but stops the test immediately on failure. suite provides test suite functionality with setup and teardown hooks.
Here’s the same test with testify:
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUserAge(t *testing.T) {
user := NewUser("Alice", 30)
assert.Equal(t, 30, user.Age)
assert.Equal(t, "Alice", user.Name)
}
The improvement is obvious. Assertions read naturally, expected values come first (matching the “expected, actual” convention), and failure messages are generated automatically.
testify Assertions in Practice
The distinction between assert and require matters more than it first appears. Consider testing a user validation function:
type User struct {
ID string
Email string
Age int
}
func ValidateUser(u *User) error {
if u.Email == "" {
return errors.New("email required")
}
if u.Age < 0 || u.Age > 150 {
return errors.New("invalid age")
}
return nil
}
When testing multiple validation scenarios, you want to see all failures:
func TestValidateUser(t *testing.T) {
user := &User{
ID: "123",
Email: "alice@example.com",
Age: 30,
}
err := ValidateUser(user)
assert.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Contains(t, user.Email, "@")
assert.Greater(t, user.Age, 0)
assert.Less(t, user.Age, 150)
}
If the email validation fails, you still see whether the age assertions pass. But in table-driven tests, require makes more sense:
func TestValidateUser_TableDriven(t *testing.T) {
tests := []struct {
name string
user *User
wantErr string
}{
{
name: "valid user",
user: &User{Email: "test@example.com", Age: 25},
wantErr: "",
},
{
name: "missing email",
user: &User{Age: 25},
wantErr: "email required",
},
{
name: "negative age",
user: &User{Email: "test@example.com", Age: -1},
wantErr: "invalid age",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.user)
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
Using require.NoError means we don’t continue checking error messages when we expected success. The test fails fast with a clear reason.
Test Suites with testify
For integration tests with shared setup, the suite package shines:
import (
"database/sql"
"testing"
"github.com/stretchr/testify/suite"
_ "github.com/lib/pq"
)
type UserRepositorySuite struct {
suite.Suite
db *sql.DB
repo *UserRepository
}
func (s *UserRepositorySuite) SetupSuite() {
// Runs once before all tests
db, err := sql.Open("postgres", "postgres://localhost/testdb?sslmode=disable")
s.Require().NoError(err)
s.db = db
s.repo = NewUserRepository(db)
}
func (s *UserRepositorySuite) TearDownSuite() {
// Runs once after all tests
s.db.Close()
}
func (s *UserRepositorySuite) SetupTest() {
// Runs before each test
_, err := s.db.Exec("DELETE FROM users")
s.Require().NoError(err)
}
func (s *UserRepositorySuite) TestCreateUser() {
user := &User{Email: "test@example.com", Age: 30}
err := s.repo.Create(user)
s.NoError(err)
s.NotEmpty(user.ID)
}
func (s *UserRepositorySuite) TestFindByEmail() {
// Setup
user := &User{Email: "find@example.com", Age: 25}
s.Require().NoError(s.repo.Create(user))
// Test
found, err := s.repo.FindByEmail("find@example.com")
s.NoError(err)
s.Equal(user.ID, found.ID)
s.Equal(25, found.Age)
}
func TestUserRepositorySuite(t *testing.T) {
suite.Run(t, new(UserRepositorySuite))
}
Notice that suite methods use s.NoError() instead of assert.NoError(t, ...). The suite embeds the testing context. Use s.Require() for assertions that should stop the test.
Introduction to gomock
gomock solves a different problem: mocking dependencies. It generates mock implementations from interfaces, letting you test units in isolation.
Install gomock and the code generator:
go install go.uber.org/mock/mockgen@latest
go get go.uber.org/mock/gomock
Define your interfaces clearly:
// repository.go
type UserRepository interface {
Create(user *User) error
FindByID(id string) (*User, error)
FindByEmail(email string) (*User, error)
}
type EmailService interface {
SendWelcome(email string) error
}
Generate mocks with mockgen:
mockgen -source=repository.go -destination=mocks/repository_mock.go -package=mocks
This creates a mocks package with MockUserRepository and MockEmailService types.
Writing Tests with gomock
The generated mocks use a controller that tracks expectations and verifies them. Here’s how to test a service that depends on the repository and email service:
type UserService struct {
repo UserRepository
email EmailService
}
func (s *UserService) Register(email string, age int) (*User, error) {
existing, err := s.repo.FindByEmail(email)
if err != nil {
return nil, fmt.Errorf("checking existing user: %w", err)
}
if existing != nil {
return nil, errors.New("user already exists")
}
user := &User{Email: email, Age: age}
if err := s.repo.Create(user); err != nil {
return nil, fmt.Errorf("creating user: %w", err)
}
if err := s.email.SendWelcome(email); err != nil {
// Log but don't fail registration
log.Printf("failed to send welcome email: %v", err)
}
return user, nil
}
Test this with mocked dependencies:
func TestUserService_Register(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockUserRepository(ctrl)
mockEmail := mocks.NewMockEmailService(ctrl)
service := &UserService{
repo: mockRepo,
email: mockEmail,
}
// Set expectations
mockRepo.EXPECT().
FindByEmail("new@example.com").
Return(nil, nil)
mockRepo.EXPECT().
Create(gomock.Any()).
DoAndReturn(func(u *User) error {
u.ID = "generated-id"
return nil
})
mockEmail.EXPECT().
SendWelcome("new@example.com").
Return(nil)
// Execute
user, err := service.Register("new@example.com", 30)
// Assert
require.NoError(t, err)
assert.Equal(t, "generated-id", user.ID)
assert.Equal(t, "new@example.com", user.Email)
}
The EXPECT() method sets up what calls the mock should receive. Return() specifies the return values. DoAndReturn() lets you execute custom logic, useful for simulating ID generation.
For more flexible matching, use gomock.Any() or custom matchers:
func TestUserService_Register_EmailFailure(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockUserRepository(ctrl)
mockEmail := mocks.NewMockEmailService(ctrl)
service := &UserService{repo: mockRepo, email: mockEmail}
mockRepo.EXPECT().FindByEmail(gomock.Any()).Return(nil, nil)
mockRepo.EXPECT().Create(gomock.Any()).Return(nil)
mockEmail.EXPECT().
SendWelcome(gomock.Any()).
Return(errors.New("smtp error"))
// Registration should succeed despite email failure
user, err := service.Register("test@example.com", 25)
require.NoError(t, err)
assert.NotNil(t, user)
}
Use Times() to specify how many times a method should be called:
mockRepo.EXPECT().
FindByID("123").
Return(&User{ID: "123"}, nil).
Times(2)
Combining testify and gomock
In real projects, you’ll use both libraries together. Here’s a complete test file demonstrating the pattern:
package service_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.uber.org/mock/gomock"
"myapp/mocks"
"myapp/service"
)
type UserServiceSuite struct {
suite.Suite
ctrl *gomock.Controller
mockRepo *mocks.MockUserRepository
mockEmail *mocks.MockEmailService
service *service.UserService
}
func (s *UserServiceSuite) SetupTest() {
s.ctrl = gomock.NewController(s.T())
s.mockRepo = mocks.NewMockUserRepository(s.ctrl)
s.mockEmail = mocks.NewMockEmailService(s.ctrl)
s.service = service.NewUserService(s.mockRepo, s.mockEmail)
}
func (s *UserServiceSuite) TearDownTest() {
s.ctrl.Finish()
}
func (s *UserServiceSuite) TestRegister_Success() {
s.mockRepo.EXPECT().FindByEmail("new@example.com").Return(nil, nil)
s.mockRepo.EXPECT().Create(gomock.Any()).Return(nil)
s.mockEmail.EXPECT().SendWelcome("new@example.com").Return(nil)
user, err := s.service.Register("new@example.com", 30)
s.Require().NoError(err)
s.Equal("new@example.com", user.Email)
}
func (s *UserServiceSuite) TestRegister_DuplicateEmail() {
existing := &service.User{ID: "existing", Email: "taken@example.com"}
s.mockRepo.EXPECT().FindByEmail("taken@example.com").Return(existing, nil)
_, err := s.service.Register("taken@example.com", 25)
s.Error(err)
s.Contains(err.Error(), "already exists")
}
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}
This pattern creates fresh mocks for each test while organizing related tests together. The suite’s SetupTest creates the controller, and TearDownTest verifies all expectations were met.
For simpler cases, mockery (which generates testify-compatible mocks) is worth considering. For comparing complex structs, go-cmp provides more powerful diffing than testify’s Equal. But testify and gomock remain the most widely adopted combination, and learning them well will serve you across most Go codebases.