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 assert vs require distinction is critical: use require for preconditions that must pass, assert for validations where you want to see all failures at once
  • gomock’s mockgen generates 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.