testify-tdd
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGo Testing with Testify and TDD
使用Testify与TDD进行Go测试
Comprehensive guide for writing Go tests using stretchr/testify and following Test-Driven Development methodology.
本指南全面介绍如何使用stretchr/testify编写Go测试,并遵循测试驱动开发(TDD)方法论。
Installation
安装
bash
go get github.com/stretchr/testifybash
go get github.com/stretchr/testifyTestify Packages Overview
Testify包概览
| Package | Purpose | When to Use |
|---|---|---|
| Assertions that continue on failure | Multiple independent checks in one test |
| Assertions that halt on failure | Prerequisites that must pass to continue |
| Interface mocking | Isolating dependencies in unit tests |
| Test organization | Related tests sharing setup/teardown |
| 包 | 用途 | 使用场景 |
|---|---|---|
| 断言失败后仍继续执行 | 单个测试中包含多个独立检查 |
| 断言失败后立即终止测试 | 必须通过的前置条件 |
| 接口Mock | 单元测试中隔离依赖 |
| 测试用例组织 | 共享初始化/清理逻辑的相关测试 |
Assert vs Require
Assert与Require的区别
Critical distinction: Choose based on whether the test should continue after failure.
关键区别:根据测试失败后是否需要继续执行来选择。
Use require
for Prerequisites
require前置条件使用require
requirego
func TestUserService_GetByID(t *testing.T) {
// Prerequisites - if these fail, nothing else matters
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) // Can't verify user if this fails
// Verifications - these can use assert
assert.Equal(t, expectedName, user.Name)
assert.Equal(t, expectedEmail, user.Email)
assert.True(t, user.IsActive)
}go
func TestUserService_GetByID(t *testing.T) {
// 前置条件 - 如果这些失败,后续测试无意义
db, err := setupTestDB()
require.NoError(t, err, "数据库初始化必须成功")
require.NotNil(t, db, "需要数据库连接")
user, err := service.GetByID(ctx, userID)
require.NoError(t, err) // 此步骤失败则无法验证用户信息
// 验证环节 - 可使用assert
assert.Equal(t, expectedName, user.Name)
assert.Equal(t, expectedEmail, user.Email)
assert.True(t, user.IsActive)
}Use assert
for Multiple Independent Checks
assert多个独立检查使用assert
assertgo
func TestOrderValidation(t *testing.T) {
order := createTestOrder()
errors := order.Validate()
// All checks run even if earlier ones fail
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")
}go
func TestOrderValidation(t *testing.T) {
order := createTestOrder()
errors := order.Validate()
// 即使前面的检查失败,所有检查仍会执行
assert.NotEmpty(t, order.ID, "订单必须包含ID")
assert.Greater(t, order.Total, 0.0, "订单总额必须为正数")
assert.NotEmpty(t, order.Items, "订单必须包含商品")
assert.Empty(t, errors, "验证必须通过")
}Common Assertion Methods
常用断言方法
go
// Equality
assert.Equal(t, expected, actual)
assert.NotEqual(t, expected, actual)
assert.EqualValues(t, expected, actual) // Type-coerced comparison
// Nil checks
assert.Nil(t, value)
assert.NotNil(t, value)
// Boolean
assert.True(t, condition)
assert.False(t, condition)
// Collections
assert.Empty(t, collection)
assert.NotEmpty(t, collection)
assert.Len(t, collection, expectedLen)
assert.Contains(t, collection, element)
assert.ElementsMatch(t, expected, actual) // Order-independent
// Errors
assert.NoError(t, err)
assert.Error(t, err)
assert.ErrorIs(t, err, expectedErr)
assert.ErrorContains(t, err, "substring")
// Comparisons
assert.Greater(t, a, b)
assert.GreaterOrEqual(t, a, b)
assert.Less(t, a, b)
assert.LessOrEqual(t, a, b)
// Strings
assert.Contains(t, str, substring)
assert.Regexp(t, pattern, str)
// JSON
assert.JSONEq(t, expectedJSON, actualJSON)
// Time
assert.WithinDuration(t, expected, actual, delta)go
// 相等性检查
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, "子字符串")
// 数值比较
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)
// JSON检查
assert.JSONEq(t, expectedJSON, actualJSON)
// 时间检查
assert.WithinDuration(t, expected, actual, delta)Table-Driven Tests
表驱动测试
The idiomatic Go pattern for testing multiple scenarios.
go
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)
})
}
}Go语言中用于测试多场景的惯用模式。
go
func TestCalculateDiscount(t *testing.T) {
tests := []struct {
name string
orderTotal float64
customerTier string
expectedDiscount float64
expectError bool
}{
{
name: "小额订单无折扣",
orderTotal: 50.00,
customerTier: "standard",
expectedDiscount: 0,
},
{
name: "黄金等级享10%折扣",
orderTotal: 100.00,
customerTier: "gold",
expectedDiscount: 10.00,
},
{
name: "白金等级订单超500享20%折扣",
orderTotal: 600.00,
customerTier: "platinum",
expectedDiscount: 120.00,
},
{
name: "订单总额为负时返回错误",
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
接口Mock
Define the Mock
定义Mock
go
import "github.com/stretchr/testify/mock"
// UserRepository is the interface to 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
}
// MockUserRepository implements UserRepository for testing
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)
}go
import "github.com/stretchr/testify/mock"
// UserRepository是需要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
}
// MockUserRepository实现了用于测试的UserRepository接口
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
在测试中使用Mock
go
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",
}
// Create mock
mockRepo := new(MockUserRepository)
// Set expectations
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)
// Create service with mock
service := NewUserService(mockRepo)
// Execute
err := service.UpdateEmail(ctx, userID, newEmail)
// Verify
require.NoError(t, err)
mockRepo.AssertExpectations(t)
}go
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",
}
// 创建Mock实例
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)
// 使用Mock创建服务实例
service := NewUserService(mockRepo)
// 执行测试逻辑
err := service.UpdateEmail(ctx, userID, newEmail)
// 验证结果
require.NoError(t, err)
mockRepo.AssertExpectations(t)
}Mock Argument Matchers
Mock参数匹配器
go
// Exact match
mockRepo.On("GetByID", ctx, "user-123")
// Any value of type
mockRepo.On("GetByID", mock.Anything, mock.AnythingOfType("string"))
// Custom matcher
mockRepo.On("Save", ctx, mock.MatchedBy(func(u *User) bool {
return u.Email != "" && u.ID != ""
}))
// Verify call count
mockRepo.AssertNumberOfCalls(t, "GetByID", 2)
mockRepo.AssertCalled(t, "Save", ctx, mock.Anything)
mockRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything)go
// 精确匹配
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
Mock返回值
go
// Return specific values
mockRepo.On("GetByID", ctx, "exists").Return(&User{ID: "exists"}, nil)
mockRepo.On("GetByID", ctx, "not-found").Return(nil, ErrNotFound)
// Return dynamically
mockRepo.On("Save", ctx, mock.Anything).Return(nil).Run(func(args mock.Arguments) {
user := args.Get(1).(*User)
user.ID = "generated-id" // Modify the argument
})
// Return different values on successive calls
mockRepo.On("GetByID", ctx, "user-1").Return(&User{}, nil).Once()
mockRepo.On("GetByID", ctx, "user-1").Return(nil, ErrNotFound).Once()go
// 返回指定值
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.
go
import (
"testing"
"github.com/stretchr/testify/suite"
)
type UserServiceTestSuite struct {
suite.Suite
service *UserService
mockRepo *MockUserRepository
ctx context.Context
}
// SetupSuite runs once before all tests
func (s *UserServiceTestSuite) SetupSuite() {
s.ctx = context.Background()
}
// SetupTest runs before each test
func (s *UserServiceTestSuite) SetupTest() {
s.mockRepo = new(MockUserRepository)
s.service = NewUserService(s.mockRepo)
}
// TearDownTest runs after each test
func (s *UserServiceTestSuite) TearDownTest() {
s.mockRepo.AssertExpectations(s.T())
}
// Test methods must start with "Test"
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)
}
// Run the suite
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}使用共享的初始化和清理逻辑组织相关测试。
go
import (
"testing"
"github.com/stretchr/testify/suite"
)
type UserServiceTestSuite struct {
suite.Suite
service *UserService
mockRepo *MockUserRepository
ctx context.Context
}
// SetupSuite在所有测试前执行一次
func (s *UserServiceTestSuite) SetupSuite() {
s.ctx = context.Background()
}
// SetupTest在每个测试前执行
func (s *UserServiceTestSuite) SetupTest() {
s.mockRepo = new(MockUserRepository)
s.service = NewUserService(s.mockRepo)
}
// TearDownTest在每个测试后执行
func (s *UserServiceTestSuite) TearDownTest() {
s.mockRepo.AssertExpectations(s.T())
}
// 测试方法必须以"Test"开头
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
TDD工作流:红-绿-重构
1. Red: Write a Failing Test First
1. 红:先编写失败的测试
go
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).
go
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)
}运行测试 - 应该失败(函数不存在或返回错误值)。
2. Green: Write Minimal Code to Pass
2. 绿:编写最少代码使测试通过
go
func (c *ShippingCalculator) CalculateCost(weight Weight, zone Zone) (Money, error) {
// Minimal implementation to make the test pass
return Money(12.50), nil
}Run the test - it should pass.
go
func (c *ShippingCalculator) CalculateCost(weight Weight, zone Zone) (Money, error) {
// 使测试通过的最简实现
return Money(12.50), nil
}运行测试 - 应该通过。
3. Refactor: Improve While Keeping Tests Green
3. 重构:在保持测试通过的同时优化代码
go
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.
go
func (c *ShippingCalculator) CalculateCost(weight Weight, zone Zone) (Money, error) {
baseRate := c.getBaseRate(zone)
weightCharge := weight.Kilograms() * c.ratePerKg
return Money(baseRate + weightCharge), nil
}每次重构后运行测试,确保仍然通过。
4. Add More Test Cases
4. 添加更多测试用例
go
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)
})
}
}go
func TestShippingCalculator_CalculateCost(t *testing.T) {
tests := []struct {
name string
weight Weight
zone Zone
expectedCost Money
expectError bool
}{
{"美国西部小包裹", Weight(1.0), Zone("US-WEST"), Money(5.00), false},
{"美国西部中等包裹", Weight(5.0), Zone("US-WEST"), Money(12.50), false},
{"美国东部大包裹", Weight(10.0), Zone("US-EAST"), Money(22.00), false},
{"重量为零返回错误", Weight(0), Zone("US-WEST"), Money(0), true},
{"重量为负返回错误", 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)
同包测试(单元测试)
go
// user_service.go
package users
type UserService struct { ... }
// user_service_test.go
package users // Same package - can test private functions
func TestUserService_validateEmail(t *testing.T) {
// Can access private method validateEmail
}go
// user_service.go
package users
type UserService struct { ... }
// user_service_test.go
package users // 同包 - 可测试私有函数
func TestUserService_validateEmail(t *testing.T) {
// 可访问私有方法validateEmail
}Black-Box Tests (Integration Tests)
黑盒测试(集成测试)
go
// user_repository_integration_test.go
package users_test // Different package - tests public API only
import "myapp/internal/users"
func TestUserRepository_Create(t *testing.T) {
// Can only access public API
repo := users.NewRepository(db)
}go
// user_repository_integration_test.go
package users_test // 不同包 - 仅测试公开API
import "myapp/internal/users"
func TestUserRepository_Create(t *testing.T) {
// 仅能访问公开API
repo := users.NewRepository(db)
}Test Naming Conventions
测试命名规范
Test[Unit]_[Scenario]_[ExpectedBehavior]
Examples:
- TestUserService_GetByID_ReturnsUser
- TestUserService_GetByID_ReturnsErrorWhenNotFound
- TestCalculateDiscount_GoldTier_Returns10Percent
- TestOrderValidator_EmptyItems_ReturnsValidationErrorTest[测试单元]_[场景]_[预期行为]
示例:
- TestUserService_GetByID_ReturnsUser
- TestUserService_GetByID_ReturnsErrorWhenNotFound
- TestCalculateDiscount_GoldTier_Returns10Percent
- TestOrderValidator_EmptyItems_ReturnsValidationErrorRunning Tests
运行测试
bash
undefinedbash
undefinedRun all tests
运行所有测试
go test ./...
go test ./...
Verbose output
详细输出
go test -v ./...
go test -v ./...
Disable test caching
禁用测试缓存
go test -count=1 ./...
go test -count=1 ./...
Run specific test
运行指定测试
go test -v -run TestUserService ./...
go test -v -run TestUserService ./...
Run with coverage
运行测试并查看覆盖率
go test -cover ./...
go test -cover ./...
Generate coverage report
生成覆盖率报告
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
undefinedgo test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
undefinedBest 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 for setup,
requirefor verificationassert - 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)
- 每个测试验证一个逻辑行为 - 测试一个行为,可包含多个assert调用
- 测试名称具有描述性 - 名称应说明测试内容
- 遵循Arrange-Act-Assert模式 - 清晰组织测试结构
- 初始化用require,验证用assert
- 多场景使用表驱动测试 - 避免复制粘贴测试代码
- 在接口边界处Mock - 不要Mock不属于你的代码
- 保持测试独立性 - 测试间无共享状态
- 测试边缘情况 - 空输入、nil、零值、边界值
- 不测试实现细节 - 测试行为而非内部结构
- 验证Mock预期 - 务必调用
AssertExpectations(t)