mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1426 字
4 分钟
Go 测试与性能测试
2022-09-15

1. testing 包核心 API#

1.1 testing 包的设计哲学#

Go 的测试框架遵循「约定优于配置」的设计理念:

  • 零配置:无需任何配置文件,go test 自动发现并运行测试
  • 约定命名:以 _test.go 结尾的文件自动被识别为测试文件
  • 内置工具链:测试、基准、覆盖率、模糊测试统一在 go test 命令下
// src/testing/testing.go 核心类型 — https://github.com/golang/go/blob/go1.25.0/src/testing/testing.go
type T struct {
common // 公共方法
isEnvSet bool
context *testContext
}
type B struct {
common
N int // 基准测试迭代次数
previousN int
previousDur time.Duration
}

1.2 testing.T 核心 API#

testing.T 提供了丰富的测试控制方法:

func TestExample(t *testing.T) {
// 日志输出
t.Log("普通日志") // 仅在失败或 -v 时显示
t.Logf("格式化日志: %d", 1)
// 标记失败(继续执行)
t.Error("错误信息") // 等价于 Log + Fail
t.Errorf("格式化错误: %s", "err")
// 标记失败并立即终止
t.Fatal("致命错误") // 等价于 Log + FailNow
t.Fatalf("格式化致命错误: %s", "err")
// 跳过测试
t.Skip("跳过原因")
t.Skipf("跳过: %s", "reason")
t.SkipNow() // 立即跳过
// 并行测试
t.Parallel() // 标记为可并行执行
// 子测试
t.Run("子测试名", func(t *testing.T) {
// 子测试逻辑
})
// 状态检查
t.Failed() // 是否失败
t.Skipped() // 是否跳过
// 清理函数
t.Cleanup(func() {
// 测试结束后执行
})
// 临时目录
t.TempDir() // 自动清理的临时目录
// 上下文
t.Context() // 返回 context.Context
}

1.3 testing.B 核心 API#

testing.B 用于性能基准测试:

func BenchmarkExample(b *testing.B) {
// 重置计时器(忽略准备阶段)
b.ResetTimer()
// 暂停/恢复计时
b.StopTimer()
// 准备工作...
b.StartTimer()
// 报告内存分配
b.ReportAllocs()
// 设置吞吐量指标
b.SetBytes(1024) // 每次操作处理 1KB
// 子基准测试
b.Run("子基准", func(b *testing.B) {
// ...
})
// 基准循环
for i := 0; i < b.N; i++ {
// 被测代码
}
}

2. 单元测试编写规范#

2.1 测试文件组织#

myapp/
├── calculator.go
├── calculator_test.go # 单元测试
├── calculator_bench_test.go # 基准测试(可选分离)
└── calculator_fuzz_test.go # 模糊测试(Go 1.18+)

测试类型与工具关系图

flowchart TB subgraph 测试类型 UT["单元测试<br/>TestXxx"] BT["基准测试<br/>BenchmarkXxx"] FT["模糊测试<br/>FuzzXxx"] end subgraph 工具链 CMD["go test 命令"] COV["覆盖率工具<br/>go tool cover"] PROF["性能分析<br/>pprof"] TRACE["执行追踪<br/>go tool trace"] end subgraph 输出 RES["测试结果"] COV_R["覆盖率报告"] PERF["性能数据"] FLAME["火焰图"] end UT --> CMD --> RES UT --> COV --> COV_R BT --> CMD --> PERF BT --> PROF --> FLAME FT --> CMD --> RES CMD --> TRACE style UT fill:#6bcb77 style BT fill:#4d96ff style FT fill:#ffd93d

命名约定

  • 测试文件:<原文件名>_test.go
  • 测试函数:Test<功能描述>,如 TestAddTestParseJSON
  • 基准函数:Benchmark<功能描述>
  • 模糊函数:Fuzz<功能描述>

2.2 测试函数结构#

一个优秀的测试函数遵循 AAA 模式(Arrange-Act-Assert):

func TestAdd(t *testing.T) {
// Arrange(准备)
calc := NewCalculator()
a, b := 2, 3
expected := 5
// Act(执行)
result := calc.Add(a, b)
// Assert(断言)
if result != expected {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, expected)
}
}

2.3 测试辅助函数#

编写可复用的测试辅助函数:

// 辅助函数不以 Test 开头
func assertEqual(t *testing.T, got, want any, msg ...string) {
t.Helper() // 标记为辅助函数,错误报告跳过此函数
if got != want {
message := ""
if len(msg) > 0 {
message = ": " + msg[0]
}
t.Errorf("got %v, want %v%s", got, want, message)
}
}
func assertError(t *testing.T, err error, wantErr bool) {
t.Helper()
if (err != nil) != wantErr {
t.Errorf("error = %v, wantErr %v", err, wantErr)
}
}
// 使用示例
func TestDivide(t *testing.T) {
calc := NewCalculator()
// 正常情况
result, err := calc.Divide(10, 2)
assertEqual(t, result, 5)
assertError(t, err, false)
// 除零错误
_, err = calc.Divide(10, 0)
assertError(t, err, true)
}

t.Helper() 的作用是让测试框架在报告错误时跳过辅助函数,直接定位到调用位置:

// 没有 t.Helper() 时的错误报告:
// calculator_test.go:15: got 3, want 5
// 有 t.Helper() 时的错误报告:
// calculator_test.go:25: got 3, want 5
// (指向实际测试代码,而非辅助函数)

2.4 测试隔离与清理#

使用 t.Cleanup() 确保资源清理:

func TestFileProcessing(t *testing.T) {
// 创建临时文件
tmpFile, err := os.CreateTemp("", "test_*.txt")
if err != nil {
t.Fatal(err)
}
// 注册清理函数
t.Cleanup(func() {
os.Remove(tmpFile.Name())
})
// 使用 t.TempDir() 更简洁
// tmpDir := t.TempDir() // 自动清理
// 测试逻辑...
}

2.5 测试中的并发安全#

func TestConcurrentMap(t *testing.T) {
var m sync.Map
var wg sync.WaitGroup
// 并发写入
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m.Store(n, n*2)
}(i)
}
// 并发读取
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
if v, ok := m.Load(n); ok {
if v != n*2 {
t.Errorf("m[%d] = %v; want %d", n, v, n*2)
}
}
}(i)
}
wg.Wait()
}

3. 表格驱动测试#

3.1 为什么选择表格驱动测试?#

表格驱动测试是 Go 社区最推荐的测试模式,它的优势:

  • 高覆盖率:轻松添加边界用例
  • 可读性强:测试数据和逻辑分离
  • 易于维护:修改测试用例不影响测试代码
  • 减少重复:一个测试函数覆盖多种场景

3.2 基本结构#

func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
wantErr bool
}{
{"positive numbers", 2, 3, 5, false},
{"negative numbers", -1, -2, -3, false},
{"mixed signs", -1, 2, 1, false},
{"zeros", 0, 0, 0, false},
{"overflow", math.MaxInt, 1, 0, true}, // 假设溢出返回错误
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Add(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Errorf("Add(%d, %d) error = %v, wantErr %v", tt.a, tt.b, err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}

3.3 复杂表格测试#

处理复杂输入和输出的表格测试:

func TestParseUser(t *testing.T) {
tests := []struct {
name string
input string
want *User
wantErr error
}{
{
name: "valid user",
input: `{"name": "Alice", "age": 30}`,
want: &User{Name: "Alice", Age: 30},
wantErr: nil,
},
{
name: "empty name",
input: `{"name": "", "age": 30}`,
want: nil,
wantErr: ErrEmptyName,
},
{
name: "invalid age",
input: `{"name": "Bob", "age": -1}`,
want: nil,
wantErr: ErrInvalidAge,
},
{
name: "malformed JSON",
input: `{invalid}`,
want: nil,
wantErr: &json.SyntaxError{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseUser(strings.NewReader(tt.input))
// 错误类型检查
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) && !errorsAs(err, tt.wantErr) {
t.Errorf("ParseUser() error = %v, want %v", err, tt.wantErr)
}
return
}
// 结果比较
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("ParseUser() mismatch (-want +got):\n%s", diff)
}
})
}
}
// 辅助函数:检查错误类型
func errorsAs(err, target error) bool {
targetType := reflect.TypeOf(target)
if targetType == nil {
return false
}
targetType = targetType.Elem()
return reflect.TypeOf(err).Elem().ConvertibleTo(targetType)
}

3.4 使用 cmp 进行深度比较#

标准库的 reflect.DeepEqual 有局限性,推荐使用 google/go-cmp

import "github.com/google/go-cmp/cmp"
func TestStructComparison(t *testing.T) {
want := &User{
Name: "Alice",
Email: "alice@example.com",
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
got := &User{
Name: "Alice",
Email: "alice@example.com",
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
// 忽略某些字段
opts := []cmp.Option{
cmpopts.IgnoreFields(User{}, "CreatedAt"),
cmpopts.IgnoreUnexported(User{}),
}
if diff := cmp.Diff(want, got, opts...); diff != "" {
t.Errorf("User mismatch (-want +got):\n%s", diff)
}
}

4. Benchmark 编写与运行#

4.1 基准测试基础#

基准测试用于测量代码性能,testing.B 会自动调整迭代次数以获得稳定结果:

func BenchmarkAdd(b *testing.B) {
// b.N 由框架自动调整
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}

运行基准测试:

# 运行所有基准测试
go test -bench=.
# 运行特定基准测试
go test -bench=BenchmarkAdd
# 增加运行时间(默认 1 秒)
go test -bench=. -benchtime=5s
# 运行指定次数
go test -bench=. -benchtime=1000x
# 显示内存分配统计
go test -bench=. -benchmem
# 禁用 CPU 优化
go test -bench=. -gcflags=-N

4.2 准备数据与重置计时器#

func BenchmarkJSONUnmarshal(b *testing.B) {
// 准备阶段:不在计时范围内
data := generateLargeJSON()
// 重置计时器,忽略准备阶段
b.ResetTimer()
for i := 0; i < b.N; i++ {
var result Data
if err := json.Unmarshal(data, &result); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkFileWrite(b *testing.B) {
for i := 0; i < b.N; i++ {
// 每次迭代创建新文件
b.StopTimer()
f, err := os.CreateTemp("", "bench_")
if err != nil {
b.Fatal(err)
}
b.StartTimer()
// 写入操作
_, err = f.Write([]byte("test data"))
f.Close()
os.Remove(f.Name())
if err != nil {
b.Fatal(err)
}
}
}

4.3 并行基准测试#

func BenchmarkParallelProcess(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Process(rand.Int())
}
})
}
// 运行时指定并行度
// go test -bench=ParallelProcess -cpu=1,2,4,8

4.4 报告内存分配#

func BenchmarkSliceAppend(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var s []int
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
// 对比预分配
func BenchmarkSlicePrealloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}

输出示例:

BenchmarkSliceAppend-8 1000000 1200 ns/op 8192 B/op 10 allocs/op
BenchmarkSlicePrealloc-8 5000000 240 ns/op 8192 B/op 1 allocs/op

4.5 子基准测试组织#

func BenchmarkJSON(b *testing.B) {
data := generateTestData()
b.Run("Unmarshal", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var result Data
json.Unmarshal(data, &result)
}
})
b.Run("Marshal", func(b *testing.B) {
d := Data{ /* ... */ }
for i := 0; i < b.N; i++ {
json.Marshal(d)
}
})
// 不同数据大小
sizes := []int{100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) {
data := generateData(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
process(data)
}
})
}
}

5. 子测试与子基准测试#

5.1 t.Run 子测试#

t.Run 创建独立的子测试,每个子测试有自己的 testing.T

func TestUserValidation(t *testing.T) {
tests := []struct {
name string
user User
wantErr bool
}{
{"valid user", User{Name: "Alice", Age: 25}, false},
{"empty name", User{Name: "", Age: 25}, true},
{"negative age", User{Name: "Bob", Age: -1}, true},
}
for _, tt := range tests {
tt := tt // 捕获变量(Go 1.22+ 可省略)
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.user)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

运行特定子测试

# 运行 TestUserValidation 下的所有子测试
go test -run TestUserValidation
# 运行特定子测试(使用 / 分隔)
go test -run TestUserValidation/valid_user
go test -run TestUserValidation/empty_name
# 使用正则匹配
go test -run "TestUserValidation/empty.*"

5.2 t.Parallel 并行测试#

子测试可以并行执行:

func TestParallel(t *testing.T) {
t.Run("group", func(t *testing.T) {
// 这两个子测试会并行执行
t.Run("parallel-1", func(t *testing.T) {
t.Parallel()
time.Sleep(100 * time.Millisecond)
// 测试逻辑...
})
t.Run("parallel-2", func(t *testing.T) {
t.Parallel()
time.Sleep(100 * time.Millisecond)
// 测试逻辑...
})
// 这个子测试会等待上面的并行测试完成后执行
t.Run("sequential", func(t *testing.T) {
// 没有 t.Parallel(),顺序执行
})
})
}

并行测试执行顺序

group (开始)
├── parallel-1 (开始,并行)
├── parallel-2 (开始,并行)
├── [等待并行测试完成]
└── sequential (执行)
group (结束)

5.3 b.Run 子基准测试#

func BenchmarkStringConcat(b *testing.B) {
parts := []string{"Hello", " ", "World", "!"}
b.Run("plus-operator", func(b *testing.B) {
for i := 0; i < b.N; i++ {
s := parts[0] + parts[1] + parts[2] + parts[3]
_ = s
}
})
b.Run("fmt.Sprintf", func(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprintf("%s%s%s%s", parts[0], parts[1], parts[2], parts[3])
_ = s
}
})
b.Run("strings.Builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
b.Run("strings.Join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
s := strings.Join(parts, "")
_ = s
}
})
}

6. Mock 技术#

6.1 接口 Mock 原理#

Go 的接口是隐式实现的,非常适合 Mock:

// 定义接口
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
}
// 真实实现
type DBUserRepository struct {
db *sql.DB
}
func (r *DBUserRepository) GetByID(ctx context.Context, id string) (*User, error) {
// 数据库查询...
}
// Mock 实现
type MockUserRepository struct {
GetByIDFunc func(ctx context.Context, id string) (*User, error)
SaveFunc func(ctx context.Context, user *User) error
}
func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*User, error) {
if m.GetByIDFunc != nil {
return m.GetByIDFunc(ctx, id)
}
return nil, errors.New("not implemented")
}
func (m *MockUserRepository) Save(ctx context.Context, user *User) error {
if m.SaveFunc != nil {
return m.SaveFunc(ctx, user)
}
return errors.New("not implemented")
}

6.2 手写 Mock 示例#

func TestUserService_GetUser(t *testing.T) {
tests := []struct {
name string
mock *MockUserRepository
want *User
wantErr bool
}{
{
name: "success",
mock: &MockUserRepository{
GetByIDFunc: func(ctx context.Context, id string) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
},
want: &User{ID: "1", Name: "Alice"},
wantErr: false,
},
{
name: "not found",
mock: &MockUserRepository{
GetByIDFunc: func(ctx context.Context, id string) (*User, error) {
return nil, ErrNotFound
},
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := NewUserService(tt.mock)
got, err := service.GetUser(context.Background(), "1")
if (err != nil) != tt.wantErr {
t.Errorf("GetUser() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("GetUser() mismatch (-want +got):\n%s", diff)
}
})
}
}

6.3 使用 testify/mock#

stretchr/testify 提供了更强大的 Mock 功能:

import "github.com/stretchr/testify/mock"
// Mock 对象
type MockRepository struct {
mock.Mock
}
func (m *MockRepository) 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 *MockRepository) Save(ctx context.Context, user *User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
// 测试
func TestUserService_Save(t *testing.T) {
mockRepo := new(MockRepository)
service := NewUserService(mockRepo)
user := &User{ID: "1", Name: "Alice"}
// 设置期望
mockRepo.On("Save", mock.Anything, user).Return(nil)
// 执行测试
err := service.Save(context.Background(), user)
assert.NoError(t, err)
// 验证期望被满足
mockRepo.AssertExpectations(t)
}
// 带返回值和错误的期望
func TestUserService_GetByID(t *testing.T) {
mockRepo := new(MockRepository)
service := NewUserService(mockRepo)
expectedUser := &User{ID: "1", Name: "Alice"}
mockRepo.On("GetByID", mock.Anything, "1").
Return(expectedUser, nil).
Once() // 只期望调用一次
user, err := service.GetUser(context.Background(), "1")
assert.NoError(t, err)
assert.Equal(t, expectedUser, user)
mockRepo.AssertExpectations(t)
}
// 基于条件的期望
func TestUserService_GetByID_MultipleCalls(t *testing.T) {
mockRepo := new(MockRepository)
// 第一次调用返回用户,第二次返回错误
mockRepo.On("GetByID", mock.Anything, "1").
Return(&User{ID: "1", Name: "Alice"}, nil).
Once()
mockRepo.On("GetByID", mock.Anything, "1").
Return(nil, ErrNotFound).
Once()
// 或者使用 Run 来执行自定义逻辑
mockRepo.On("GetByID", mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
id := args.Get(1).(string)
fmt.Println("GetByID called with:", id)
}).
Return(&User{ID: "1"}, nil)
}

6.4 使用gomock#

gomock 是官方维护的 Mock 工具,配合 mockgen 生成 Mock:

//go:generate mockgen -source=repository.go -destination=mock_repository.go -package=mocks
type Repository interface {
Get(ctx context.Context, id string) (*Entity, error)
Save(ctx context.Context, entity *Entity) error
}
// 测试代码
func TestWithGomock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockRepository(ctrl)
// 设置期望
mockRepo.EXPECT().
Get(gomock.Any(), "1").
Return(&Entity{ID: "1"}, nil)
mockRepo.EXPECT().
Save(gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, e *Entity) error {
assert.Equal(t, "1", e.ID)
return nil
})
// 使用 mockRepo...
}

6.5 HTTP Handler Mock#

import "net/http/httptest"
func TestHTTPHandler(t *testing.T) {
handler := &UserHandler{}
// 创建测试请求
req := httptest.NewRequest("GET", "/users/1", nil)
req = req.WithContext(context.WithValue(req.Context(), "userID", "1"))
// 创建响应记录器
rec := httptest.NewRecorder()
// 执行请求
handler.ServeHTTP(rec, req)
// 验证响应
assert.Equal(t, http.StatusOK, rec.Code)
var response User
json.Unmarshal(rec.Body.Bytes(), &response)
assert.Equal(t, "1", response.ID)
}
// Mock HTTP Client
func TestHTTPClient(t *testing.T) {
// 创建 mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/users", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"id": "1", "name": "Alice"})
}))
defer server.Close()
// 使用 mock server URL
client := NewClient(server.URL)
user, err := client.GetUser(context.Background(), "1")
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}

7. 测试覆盖率#

7.1 基本覆盖率分析#

# 查看覆盖率百分比
go test -cover ./...
# 查看详细覆盖率
go test -coverprofile=coverage.out ./...
# 查看函数级别覆盖率
go tool cover -func=coverage.out
# 生成 HTML 覆盖率报告
go tool cover -html=coverage.out -o coverage.html
# 按包查看覆盖率
go test -coverpkg=./... ./...

7.2 覆盖率模式#

Go 1.20+ 支持三种覆盖率模式:

# set: 是否覆盖(默认)
go test -covermode=set -coverprofile=coverage.out
# count: 执行次数
go test -covermode=count -coverprofile=coverage.out
# atomic: 原子计数(用于并发测试)
go test -covermode=atomic -coverprofile=coverage.out

7.3 覆盖率排除#

//go:build !coverage
package mypackage
// 不参与覆盖率统计的代码
func DebugOnly() {
// ...
}

7.4 覆盖率最佳实践#

// 确保覆盖边界条件
func TestBoundaryConditions(t *testing.T) {
// 空输入
t.Run("empty input", func(t *testing.T) {
result, err := Process([]int{})
assert.NoError(t, err)
assert.Nil(t, result)
})
// 单元素
t.Run("single element", func(t *testing.T) {
result, err := Process([]int{1})
assert.NoError(t, err)
assert.Equal(t, []int{1}, result)
})
// 大量数据
t.Run("large input", func(t *testing.T) {
data := make([]int, 10000)
for i := range data {
data[i] = i
}
result, err := Process(data)
assert.NoError(t, err)
})
// 错误路径
t.Run("nil input", func(t *testing.T) {
result, err := Process(nil)
assert.Error(t, err)
assert.Nil(t, result)
})
}

8. 性能测试进阶#

8.1 使用 pprof 进行性能分析#

# 生成 CPU profile
go test -bench=. -cpuprofile=cpu.prof
# 生成内存 profile
go test -bench=. -memprofile=mem.prof
# 生成阻塞 profile
go test -bench=. -blockprofile=block.prof
# 生成互斥锁 profile
go test -bench=. -mutexprofile=mutex.prof
# 分析 profile
go tool pprof cpu.prof

8.2 pprof 交互式分析#

$ go tool pprof cpu.prof
File: benchmark.test
Type: cpu
Time: Jan 1, 2024 at 12:00pm (UTC)
Duration: 5.12s, Total samples = 3.21s (62.70%)
(pprof) top10
Showing nodes accounting for 2.50s, 77.88% of 3.21s total
flat flat% sum% cum cum%
0.80s 24.92% 24.92% 0.80s 24.92% runtime.memmove
0.50s 15.58% 40.50% 0.50s 15.58% runtime.mallocgc
0.40s 12.46% 52.96% 0.40s 12.46% runtime.memclrNoHeapPointers
...
(pprof) list Process
Total: 3.21s
ROUTINE ======================== mypackage.Process
0.30s 0.80s (flat, cum) 24.92% of Total
...

8.3 benchmark 竞争检测#

# 启用竞争检测
go test -race -bench=.
# 注意:竞争检测会显著降低性能,不要用 -race 运行基准测试来评估真实性能

8.4 性能回归检测#

// 使用 benchstat 比较基准测试结果
// 安装:go install golang.org/x/perf/cmd/benchstat@latest
// 保存旧版本结果
// go test -bench=. -count=10 > old.txt
// 运行新版本并比较
// go test -bench=. -count=10 > new.txt
// benchstat old.txt new.txt

输出示例:

name old time/op new time/op delta
Add-8 2.45ns ± 2% 2.48ns ± 1% +1.22% (p=0.029 n=10+9)
name old alloc/op new alloc/op delta
Add-8 0.00B 0.00B ~ (all equal)
name old allocs/op new allocs/op delta
Add-8 0.00 0.00 ~ (all equal)

9. 模糊测试(Fuzz Testing)#

9.1 模糊测试基础#

Go 1.18 引入了原生模糊测试支持:

func FuzzAdd(f *testing.F) {
// 添加种子语料库
f.Add(1, 2)
f.Add(-1, -2)
f.Add(0, 0)
// 模糊测试函数
f.Fuzz(func(t *testing.T, a, b int) {
result := Add(a, b)
// 验证基本属性
// 加法交换律
if result != Add(b, a) {
t.Errorf("Add(%d, %d) != Add(%d, %d)", a, b, b, a)
}
// 加法结果应该 >= 单独一个加数(对于非负数)
if a >= 0 && b >= 0 {
if result < a || result < b {
t.Errorf("Add(%d, %d) = %d, want >= %d", a, b, result, max(a, b))
}
}
})
}

运行模糊测试:

# 运行模糊测试(默认无限运行)
go test -fuzz=FuzzAdd
# 限制运行时间
go test -fuzz=FuzzAdd -fuzztime=30s
# 运行特定时长后停止
go test -fuzz=FuzzAdd -fuzztime=1m
# 限制模糊测试迭代次数
go test -fuzz=FuzzAdd -fuzztime=10000x

9.2 模糊测试发现 Bug#

func ParseVersion(version string) (major, minor, patch int, err error) {
parts := strings.Split(version, ".")
if len(parts) != 3 {
return 0, 0, 0, errors.New("invalid version format")
}
// 这里可能有整数溢出问题
major, err = strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, err
}
minor, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, err
}
patch, err = strconv.Atoi(parts[2])
if err != nil {
return 0, 0, 0, err
}
return major, minor, patch, nil
}
func FuzzParseVersion(f *testing.F) {
// 种子语料
f.Add("1.2.3")
f.Add("0.0.0")
f.Add("10.20.30")
f.Fuzz(func(t *testing.T, version string) {
major, minor, patch, err := ParseVersion(version)
if err != nil {
// 错误是有效的返回值
return
}
// 验证:解析结果应该能重新序列化为相同的版本字符串
result := fmt.Sprintf("%d.%d.%d", major, minor, patch)
if result != version {
t.Errorf("roundtrip mismatch: input=%q, output=%q", version, result)
}
// 验证:数值应该非负
if major < 0 || minor < 0 || patch < 0 {
t.Errorf("negative version component: %d.%d.%d", major, minor, patch)
}
})
}

9.3 语料库管理#

# 发现问题的语料会保存到 testdata/fuzz/<FuzzName>/<hash>
# 目录结构:
testdata/
└── fuzz/
└── FuzzParseVersion/
├── 123abc... # 发现的失败用例
└── 456def... # 另一个失败用例
# 使用特定语料运行
go test -run=FuzzParseVersion
# 清除语料库(仅保留种子)
go clean -fuzzcache

9.4 模糊测试最佳实践#

func FuzzJSONRoundtrip(f *testing.F) {
// 使用有代表性的种子
f.Add(`{"name": "Alice", "age": 30}`)
f.Add(`[]`)
f.Add(`{}`)
f.Add(`null`)
f.Fuzz(func(t *testing.T, jsonStr string) {
// 模糊测试应该快速
// 避免网络调用、文件 I/O 等
var data interface{}
err := json.Unmarshal([]byte(jsonStr), &data)
if err != nil {
return // 无效 JSON 是预期的
}
// 验证往返一致性
encoded, err := json.Marshal(data)
if err != nil {
t.Fatalf("Marshal failed for valid data: %v", err)
}
var data2 interface{}
if err := json.Unmarshal(encoded, &data2); err != nil {
t.Fatalf("Unmarshal failed for re-encoded data: %v", err)
}
// 深度比较
if diff := cmp.Diff(data, data2); diff != "" {
t.Errorf("roundtrip mismatch (-want +got):\n%s", diff)
}
})
}

10. testing 包源码解析#

10.1 T 和 B 的公共方法#

// src/testing/testing.go — https://github.com/golang/go/blob/go1.25.0/src/testing/testing.go
// common 定义了 T 和 B 共享的方法
type common struct {
mu sync.Mutex
output []byte // 输出缓冲
w io.Writer // 输出目标
ran bool // 是否已运行
failed bool // 是否失败
skipped bool // 是否跳过
done bool // 是否完成
name string // 测试名称
start time.Duration // 开始时间
duration time.Duration // 持续时间
barrier chan bool // 并行测试屏障
signal chan signal // 完成信号
sub *T // 子测试
tempDirMu sync.Mutex
tempDir string
tempDirErr error
tempDirOnce sync.Once
context *testContext
}
func (c *common) Log(args ...any) {
c.mu.Lock()
defer c.mu.Unlock()
c.output = append(c.output, c.decorate(fmt.Sprintln(args...))...)
}
func (c *common) Fail() {
c.mu.Lock()
defer c.mu.Unlock()
c.failed = true
}
func (c *common) FailNow() {
c.Fail()
runtime.Goexit() // 终止当前 goroutine
}
func (c *common) SkipNow() {
c.skip()
runtime.Goexit()
}

10.2 并行测试机制#

func (t *T) Parallel() {
if t.isParallel {
panic("testing: t.Parallel called multiple times")
}
t.isParallel = true
// 通知父测试
t.signal <- true
// 等待许可
<-t.parent.barrier
// 等待所有并行测试完成
<-t.signal
}

并行测试执行流程:

sequenceDiagram participant Main participant Parent participant Child1 participant Child2 Main->>Parent: Run Parent->>Child1: t.Run + t.Parallel Child1-->>Parent: signal ready Parent->>Child2: t.Run + t.Parallel Child2-->>Parent: signal ready Parent->>Parent: wait for all children Parent->>Child1: barrier release Parent->>Child2: barrier release Child1-->>Parent: done signal Child2-->>Parent: done signal Parent->>Main: done

10.3 Benchmark 自适应迭代#

func (b *B) runN(n int) {
b.N = n
b.ResetTimer()
b.StartTimer()
b.benchFunc(b) // 执行基准函数
b.StopTimer()
}
func (b *B) launch() {
// 自适应调整迭代次数
n := 1
for {
b.runN(n)
// 如果运行时间足够,停止
if b.duration >= b.benchTime {
break
}
// 否则增加迭代次数
n = int(float64(n) * 1.2)
if n > 1e9 {
break
}
}
}

10.4 测试主函数#

func MainStart(deps testDeps, tests []InternalTest, benchmarks []InternalBenchmark, examples []InternalExample) *M {
return &M{
deps: deps,
tests: tests,
benchmarks: benchmarks,
examples: examples,
}
}
func (m *M) Run() (code int) {
// 解析参数
// 初始化
// 运行测试
// 运行基准
// 运行示例
for _, test := range m.tests {
t := &T{common: common{name: test.Name}}
tRunner(t, test.F)
if t.failed {
m.fail = true
}
}
if m.fail {
return 1
}
return 0
}

11. 测试最佳实践总结#

11.1 测试金字塔#

graph TD A[单元测试] --> B[集成测试] B --> C[端到端测试] style A fill:#90EE90 style B fill:#FFD700 style C fill:#FF6B6B
类型数量速度成本覆盖范围
单元测试单个函数/模块
集成测试多模块协作
E2E 测试完整业务流程

11.2 测试命名约定#

// 好的命名:描述行为和预期结果
func TestAdd_PositiveNumbers_ReturnsSum(t *testing.T) {}
func TestDivide_ByZero_ReturnsError(t *testing.T) {}
func TestParseJSON_InvalidInput_ReturnsError(t *testing.T) {}
// 不好的命名:模糊不清
func TestAdd(t *testing.T) {}
func TestDivide(t *testing.T) {}
func TestError(t *testing.T) {}

11.3 测试原则#

// FIRST 原则
// F - Fast: 测试要快
// I - Independent: 测试之间无依赖
// R - Repeatable: 结果可重复
// S - Self-validating: 自动判定结果
// T - Timely: 及时编写
// 示例:独立且可重复的测试
func TestDatabase(t *testing.T) {
// 每个测试使用独立的临时数据库
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
defer db.Close()
// 测试逻辑...
}

11.4 常用测试工具库#

用途
github.com/stretchr/testify断言、Mock、Suite
github.com/google/go-cmp深度比较
github.com/DATA-DOG/go-sqlmock数据库 Mock
github.com/golang/mock接口 Mock 生成
github.com/vektra/mockeryMock 生成工具
github.com/hexops/autogold快照测试
golang.org/x/tools/cmd/godoc文档测试

11.5 CI/CD 集成#

.github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage.out
- name: Run linters
uses: golangci/golangci-lint-action@v4

八、常见问题#

Q1:表格驱动测试有什么优势?#

表格驱动测试将测试用例与测试逻辑分离,新增用例只需添加一行数据。代码复用度高,测试失败时输出清晰的用例名称,便于定位问题。

Q2:Benchmark 结果怎么看?#

go test -bench=. 输出每次操作的耗时(ns/op)和内存分配次数(allocs/op)。-benchmem 显示内存分配详情。关注 ns/op 的变化趋势而非绝对值。

Q3:模糊测试和单元测试有什么区别?#

单元测试验证已知输入的预期输出,模糊测试用随机输入探索未知边界。模糊测试适合发现解析器、编解码器等输入处理代码的崩溃和漏洞。Go 1.18+ 原生支持 -fuzz 标志。

Q4:Mock 和 Stub 有什么区别?#

Stub 提供预设的返回值(被动替身),Mock 验证方法是否被正确调用(主动替身)。Go 中常用 gomock 生成 Mock,testify/mock 提供手动 Mock。

小结#

  • 表格驱动测试是 Go 的惯用模式,将测试用例与逻辑分离
  • Benchmark 基准测试衡量性能,配合 -benchmem 分析内存分配
  • 模糊测试(Go 1.18+)用随机输入发现边界崩溃和漏洞
  • 测试覆盖率不等于测试质量,关键路径需要 100% 覆盖
  • 集成测试用 testcontainers 管理外部依赖,保证环境一致性

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 测试与性能测试
https://blog.souloss.com/posts/golang/go-testing/
作者
Souloss
发布于
2022-09-15
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时