| name | testify-tdd |
| description | Use this skill when writing Go tests with stretchr/testify, implementing TDD workflows, creating mocks, or organizing test suites. Covers assert vs require patterns, interface mocking, table-driven tests, and the red-green-refactor cycle. |
| keywords | ["go","testing","tdd","testify","unit-tests","mocking"] |
| disable-model-invocation | false |
| user-invocable | true |
Go Testing with Testify and TDD
Comprehensive guide for writing Go tests using stretchr/testify and following Test-Driven Development methodology.
Installation
go get github.com/stretchr/testify
Testify Packages Overview
| Package | Purpose | When to Use |
|---|
testify/assert | Assertions that continue on failure | Multiple independent checks in one test |
testify/require | Assertions that halt on failure | Prerequisites that must pass to continue |
testify/mock | Interface mocking | Isolating dependencies in unit tests |
testify/suite | Test organization | Related tests sharing setup/teardown |
Assert vs Require
Critical distinction: Choose based on whether the test should continue after failure.
Use require for Prerequisites
func TestUserService_GetByID(t *testing.T) {
db, err := setupTestDB()
require.NoError(t, err, "database setup must succeed")
require.NotNil(t, db, "database connection required")
user, err := service.GetByID(ctx, userID)
require.NoError(t, err)
assert.Equal(t, expectedName, user.Name)
assert.Equal(t, expectedEmail, user.Email)
assert.True(t, user.IsActive)
}
Use assert for Multiple Independent Checks
func TestOrderValidation(t *testing.T) {
order := createTestOrder()
errors := order.Validate()
assert.NotEmpty(t, order.ID, "order should have ID")
assert.Greater(t, order.Total, 0.0, "total should be positive")
assert.NotEmpty(t, order.Items, "order should have items")
assert.Empty(t, errors, "validation should pass")
}
Common Assertion Methods
assert.Equal(t, expected, actual)
assert.NotEqual(t, expected, actual)
assert.EqualValues(t, expected, actual)
assert.Nil(t, value)
assert.NotNil(t, value)
assert.True(t, condition)
assert.False(t, condition)
assert.Empty(t, collection)
assert.NotEmpty(t, collection)
assert.Len(t, collection, expectedLen)
assert.Contains(t, collection, element)
assert.ElementsMatch(t, expected, actual)
assert.NoError(t, err)
assert.Error(t, err)
assert.ErrorIs(t, err, expectedErr)
assert.ErrorContains(t, err, "substring")
assert.Greater(t, a, b)
assert.GreaterOrEqual(t, a, b)
assert.Less(t, a, b)
assert.LessOrEqual(t, a, b)
assert.Contains(t, str, substring)
assert.Regexp(t, pattern, str)
assert.JSONEq(t, expectedJSON, actualJSON)
assert.WithinDuration(t, expected, actual, delta)
Table-Driven Tests
The idiomatic Go pattern for testing multiple scenarios.
func TestCalculateDiscount(t *testing.T) {
tests := []struct {
name string
orderTotal float64
customerTier string
expectedDiscount float64
expectError bool
}{
{
name: "no discount for small orders",
orderTotal: 50.00,
customerTier: "standard",
expectedDiscount: 0,
},
{
name: "10% discount for gold tier",
orderTotal: 100.00,
customerTier: "gold",
expectedDiscount: 10.00,
},
{
name: "20% discount for platinum over $500",
orderTotal: 600.00,
customerTier: "platinum",
expectedDiscount: 120.00,
},
{
name: "error for negative total",
orderTotal: -10.00,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
discount, err := CalculateDiscount(tc.orderTotal, tc.customerTier)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.expectedDiscount, discount)
})
}
}
Interface Mocking
Define the Mock
import "github.com/stretchr/testify/mock"
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
args := m.Called(ctx, id)
return args.Error(0)
}
Use the Mock in Tests
func TestUserService_UpdateEmail(t *testing.T) {
ctx := context.Background()
userID := "user-123"
newEmail := "new@example.com"
existingUser := &User{
ID: userID,
Email: "old@example.com",
Name: "Test User",
}
mockRepo := new(MockUserRepository)
mockRepo.On("GetByID", ctx, userID).Return(existingUser, nil)
mockRepo.On("Save", ctx, mock.MatchedBy(func(u *User) bool {
return u.ID == userID && u.Email == newEmail
})).Return(nil)
service := NewUserService(mockRepo)
err := service.UpdateEmail(ctx, userID, newEmail)
require.NoError(t, err)
mockRepo.AssertExpectations(t)
}
Mock Argument Matchers
mockRepo.On("GetByID", ctx, "user-123")
mockRepo.On("GetByID", mock.Anything, mock.AnythingOfType("string"))
mockRepo.On("Save", ctx, mock.MatchedBy(func(u *User) bool {
return u.Email != "" && u.ID != ""
}))
mockRepo.AssertNumberOfCalls(t, "GetByID", 2)
mockRepo.AssertCalled(t, "Save", ctx, mock.Anything)
mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything)
Mock Return Values
mockRepo.On("GetByID", ctx, "exists").Return(&User{ID: "exists"}, nil)
mockRepo.On("GetByID", ctx, "not-found").Return(nil, ErrNotFound)
mockRepo.On("Save", ctx, mock.Anything).Return(nil).Run(func(args mock.Arguments) {
user := args.Get(1).(*User)
user.ID = "generated-id"
})
mockRepo.On("GetByID", ctx, "user-1").Return(&User{}, nil).Once()
mockRepo.On("GetByID", ctx, "user-1").Return(nil, ErrNotFound).Once()
Test Suites
Organize related tests with shared setup and teardown.
import (
"testing"
"github.com/stretchr/testify/suite"
)
type UserServiceTestSuite struct {
suite.Suite
service *UserService
mockRepo *MockUserRepository
ctx context.Context
}
func (s *UserServiceTestSuite) SetupSuite() {
s.ctx = context.Background()
}
func (s *UserServiceTestSuite) SetupTest() {
s.mockRepo = new(MockUserRepository)
s.service = NewUserService(s.mockRepo)
}
func (s *UserServiceTestSuite) TearDownTest() {
s.mockRepo.AssertExpectations(s.T())
}
func (s *UserServiceTestSuite) TestGetByID_Success() {
expected := &User{ID: "123", Name: "Test"}
s.mockRepo.On("GetByID", s.ctx, "123").Return(expected, nil)
user, err := s.service.GetByID(s.ctx, "123")
s.Require().NoError(err)
s.Equal(expected.Name, user.Name)
}
func (s *UserServiceTestSuite) TestGetByID_NotFound() {
s.mockRepo.On("GetByID", s.ctx, "999").Return(nil, ErrNotFound)
user, err := s.service.GetByID(s.ctx, "999")
s.Nil(user)
s.ErrorIs(err, ErrNotFound)
}
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}
TDD Workflow: Red-Green-Refactor
1. Red: Write a Failing Test First
func TestShippingCalculator_CalculateCost(t *testing.T) {
calc := NewShippingCalculator()
cost, err := calc.CalculateCost(Weight(5.0), Zone("US-WEST"))
require.NoError(t, err)
assert.Equal(t, Money(12.50), cost)
}
Run the test - it should fail (function doesn't exist or returns wrong value).
2. Green: Write Minimal Code to Pass
func (c *ShippingCalculator) CalculateCost(weight Weight, zone Zone) (Money, error) {
return Money(12.50), nil
}
Run the test - it should pass.
3. Refactor: Improve While Keeping Tests Green
func (c *ShippingCalculator) CalculateCost(weight Weight, zone Zone) (Money, error) {
baseRate := c.getBaseRate(zone)
weightCharge := weight.Kilograms() * c.ratePerKg
return Money(baseRate + weightCharge), nil
}
Run tests after each refactor to ensure they still pass.
4. Add More Test Cases
func TestShippingCalculator_CalculateCost(t *testing.T) {
tests := []struct {
name string
weight Weight
zone Zone
expectedCost Money
expectError bool
}{
{"small package US-WEST", Weight(1.0), Zone("US-WEST"), Money(5.00), false},
{"medium package US-WEST", Weight(5.0), Zone("US-WEST"), Money(12.50), false},
{"large package US-EAST", Weight(10.0), Zone("US-EAST"), Money(22.00), false},
{"zero weight error", Weight(0), Zone("US-WEST"), Money(0), true},
{"negative weight error", Weight(-1), Zone("US-WEST"), Money(0), true},
}
calc := NewShippingCalculator()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cost, err := calc.CalculateCost(tc.weight, tc.zone)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.expectedCost, cost)
})
}
}
Test File Organization
Same-Package Tests (Unit Tests)
package users
type UserService struct { ... }
package users
func TestUserService_validateEmail(t *testing.T) {
}
Black-Box Tests (Integration Tests)
package users_test
import "myapp/internal/users"
func TestUserRepository_Create(t *testing.T) {
repo := users.NewRepository(db)
}
Test Naming Conventions
Test[Unit]_[Scenario]_[ExpectedBehavior]
Examples:
- TestUserService_GetByID_ReturnsUser
- TestUserService_GetByID_ReturnsErrorWhenNotFound
- TestCalculateDiscount_GoldTier_Returns10Percent
- TestOrderValidator_EmptyItems_ReturnsValidationError
Running Tests
go test ./...
go test -v ./...
go test -count=1 ./...
go test -v -run TestUserService ./...
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
Best Practices
- One logical assertion per test - Test one behavior, though you may have multiple assert calls
- Descriptive test names - The name should explain what's being tested
- Arrange-Act-Assert pattern - Structure tests clearly
- Use
require for setup, assert for verification
- Table-driven tests for multiple scenarios - Avoid copy-paste test code
- Mock at interface boundaries - Don't mock what you don't own
- Keep tests independent - No shared state between tests
- Test edge cases - Empty inputs, nil, zero values, boundaries
- Don't test implementation details - Test behavior, not internal structure
- Verify mock expectations - Always call
AssertExpectations(t)