Week 07: 测试、基准与性能分析
掌握 Go 测试框架, 学习表驱动测试、Benchmark、pprof 性能分析与逃逸分析.
1. 测试基础
1.1 测试文件命名
- 测试文件以
_test.go结尾. - 与被测试文件放在同一目录.
- 测试函数以
Test开头, 参数为*testing.T.
mypackage/
├── math.go # 实现代码
└── math_test.go # 测试代码1.2 第一个测试
// math.go
package math
func Add(a, b int) int {
return a + b
}// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(1, 2)
if result != 3 {
t.Errorf("Add(1, 2) = %d; want 3", result)
}
}1.3 运行测试
go test # 运行当前包的测试
go test ./... # 运行所有包的测试
go test -v # 详细输出
go test -run TestAdd # 只运行匹配的测试
go test -count=1 # 禁用测试缓存2. 表驱动测试 (Table-Driven Tests)
Go 推荐使用表驱动测试, 将测试用例组织为数据:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正数相加", 1, 2, 3},
{"负数相加", -1, -1, -2},
{"零", 0, 0, 0},
{"混合", -1, 1, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}优点:
- 易于添加新用例.
- 每个用例有独立的名称.
t.Run创建子测试, 可单独运行 (-run TestAdd/正数相加).
3. 测试辅助函数
3.1 t.Helper()
标记函数为辅助函数, 错误报告会显示调用者位置:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 关键
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}3.2 t.Cleanup()
注册清理函数, 在测试结束时执行 (类似 defer):
func TestWithTempFile(t *testing.T) {
f, _ := os.CreateTemp("", "test")
t.Cleanup(func() {
os.Remove(f.Name())
})
// 使用 f...
}3.3 t.Parallel()
标记测试可并行执行:
func TestA(t *testing.T) {
t.Parallel()
// ...
}
func TestB(t *testing.T) {
t.Parallel()
// ...
}4. 测试覆盖率
go test -cover # 显示覆盖率
go test -coverprofile=cover.out # 生成覆盖率文件
go tool cover -html=cover.out # 可视化覆盖率覆盖率不是目标: 100% 覆盖率并不意味着没有 bug. 重点是测试关键路径和边界条件.
5. Mock 测试与依赖注入
5.1 接口抽象
Mock 测试的前提是使用接口抽象依赖:
// repository/user.go
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, user *User) error
}
// service/user.go
type UserService struct {
repo UserRepository // 依赖接口, 而非具体实现
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
return s.repo.GetByID(ctx, id)
}5.2 手写 Mock
// service/user_test.go
type mockUserRepository struct {
users map[int64]*User
err error
}
func (m *mockUserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
if m.err != nil {
return nil, m.err
}
return m.users[id], nil
}
func (m *mockUserRepository) Create(ctx context.Context, user *User) error {
if m.err != nil {
return m.err
}
m.users[user.ID] = user
return nil
}
func TestUserService_GetUser(t *testing.T) {
mockRepo := &mockUserRepository{
users: map[int64]*User{
1: {ID: 1, Name: "Alice"},
},
}
svc := NewUserService(mockRepo)
user, err := svc.GetUser(context.Background(), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("got %q, want %q", user.Name, "Alice")
}
}
// 测试错误场景
func TestUserService_GetUser_NotFound(t *testing.T) {
mockRepo := &mockUserRepository{
err: ErrUserNotFound,
}
svc := NewUserService(mockRepo)
_, err := svc.GetUser(context.Background(), 999)
if !errors.Is(err, ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
}5.3 gomock (代码生成)
go install go.uber.org/mock/mockgen@latest
# 生成 Mock
mockgen -source=repository/user.go -destination=repository/mock/user_mock.goimport "github.com/golang/mock/gomock"
func TestUserService_GetUser_GoMock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mock.NewMockUserRepository(ctrl)
mockRepo.EXPECT().
GetByID(gomock.Any(), int64(1)).
Return(&User{ID: 1, Name: "Alice"}, nil)
svc := NewUserService(mockRepo)
user, err := svc.GetUser(context.Background(), 1)
if err != nil || user.Name != "Alice" {
t.Fail()
}
}6. testify 断言库
6.1 基本断言
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithAssert(t *testing.T) {
// assert: 失败后继续执行
assert.Equal(t, 3, Add(1, 2))
assert.NotNil(t, obj)
assert.Contains(t, "hello world", "world")
assert.Error(t, err)
assert.ErrorIs(t, err, ErrNotFound)
// require: 失败后立即停止
require.NoError(t, err) // 如果失败, 后续代码不执行
require.NotNil(t, user)
assert.Equal(t, "Alice", user.Name)
}6.2 表驱动测试 + testify
func TestAdd_Testify(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 1, 2, 3},
{"negative", -1, -1, -2},
{"zero", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, Add(tt.a, tt.b))
})
}
}6.3 testify/suite
组织测试用例, 共享 setup/teardown:
import "github.com/stretchr/testify/suite"
type UserServiceTestSuite struct {
suite.Suite
db *sql.DB
service *UserService
}
func (s *UserServiceTestSuite) SetupSuite() {
s.db, _ = sql.Open("postgres", "test_db")
s.service = NewUserService(NewUserRepository(s.db))
}
func (s *UserServiceTestSuite) TearDownSuite() {
s.db.Close()
}
func (s *UserServiceTestSuite) SetupTest() {
s.db.Exec("TRUNCATE users")
}
func (s *UserServiceTestSuite) TestCreateUser() {
user, err := s.service.Create(context.Background(), &User{Name: "Alice"})
s.Require().NoError(err)
s.Assert().Equal("Alice", user.Name)
}
func TestUserService(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}
---
## 5. Fuzzing (Go 1.18+)
Fuzzing 是一种自动化测试技术, 用随机输入发现边界情况和崩溃.
### 5.1 编写 Fuzz 测试
```go
func FuzzParseJSON(f *testing.F) {
// 种子语料
f.Add([]byte(`{"name":"test"}`))
f.Add([]byte(`{"id":123}`))
f.Add([]byte(`[]`))
f.Fuzz(func(t *testing.T, data []byte) {
var result map[string]interface{}
// 函数不应 panic
_ = json.Unmarshal(data, &result)
})
}5.2 运行 Fuzzing
go test -fuzz=FuzzParseJSON -fuzztime=30s
go test -fuzz=FuzzParseJSON -fuzztime=1000x # 运行 1000 次发现的问题会保存在 testdata/fuzz/ 目录下.
6. 测试辅助函数补充
6.1 t.Setenv (Go 1.17+)
func TestWithEnv(t *testing.T) {
t.Setenv("API_KEY", "test-key")
// 测试结束后自动恢复原值
// 测试代码...
}6.2 t.TempDir (Go 1.15+)
func TestWithTempFile(t *testing.T) {
dir := t.TempDir() // 测试结束后自动删除
path := filepath.Join(dir, "test.txt")
os.WriteFile(path, []byte("hello"), 0644)
// ...
}7. Benchmark (基准测试)
7.1 编写 Benchmark
函数以 Benchmark 开头, 参数为 *testing.B:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}b.N 由 testing 框架自动调整, 确保测试运行足够长时间以获得稳定结果.
7.2 运行 Benchmark
go test -bench=. # 运行所有 benchmark
go test -bench=Add # 只运行匹配的
go test -bench=. -benchmem # 包含内存分配信息
go test -bench=. -benchtime=5s # 运行 5 秒
go test -bench=. -count=5 # 运行 5 次取平均输出解读:
BenchmarkAdd-8 1000000000 0.2916 ns/op 0 B/op 0 allocs/op8: GOMAXPROCS1000000000: 运行次数0.2916 ns/op: 每次操作耗时0 B/op: 每次操作分配内存0 allocs/op: 每次操作分配次数
7.3 避免编译器优化
编译器可能优化掉无副作用的代码:
var result int // 全局变量, 防止优化
func BenchmarkAdd(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = Add(1, 2)
}
result = r
}7.4 重置计时器
如果 benchmark 有昂贵的初始化, 使用 b.ResetTimer():
func BenchmarkProcess(b *testing.B) {
data := prepareData() // 不计入计时
b.ResetTimer()
for i := 0; i < b.N; i++ {
process(data)
}
}8. 性能分析 (pprof)
8.1 启用 pprof
import _ "net/http/pprof"
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// 应用逻辑...
}8.2 采集数据
# CPU Profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# 堆内存
go tool pprof http://localhost:6060/debug/pprof/heap
# Goroutine
go tool pprof http://localhost:6060/debug/pprof/goroutine
# 阻塞
go tool pprof http://localhost:6060/debug/pprof/block8.3 pprof 交互命令
(pprof) top # 显示最耗时的函数
(pprof) top10 # 前 10
(pprof) list Add # 显示 Add 函数的逐行分析
(pprof) web # 在浏览器中显示调用图8.4 火焰图
go install github.com/google/pprof@latest
pprof -http=:8080 cpu.prof9. 逃逸分析 (Escape Analysis)
9.1 什么是逃逸
编译器决定变量分配在栈还是堆:
- 栈分配: 函数返回自动回收, 零成本.
- 堆分配: 需要 GC 回收, 有性能开销.
当编译器无法确定变量的生命周期不超过函数调用时, 变量会"逃逸"到堆.
9.2 查看逃逸分析
go build -gcflags="-m" main.go
go build -gcflags="-m -m" main.go # 更详细输出示例:
./main.go:10:6: moved to heap: x
./main.go:15:13: &User{...} escapes to heap9.3 常见逃逸场景
// 1. 返回局部变量的指针
func createUser() *User {
u := User{Name: "Alice"} // 逃逸
return &u
}
// 2. 赋值给接口类型
func print(i interface{}) { ... }
x := 42
print(x) // x 被装箱, 逃逸
// 3. 切片扩容
s := make([]int, 0)
s = append(s, 1) // 可能逃逸
// 4. 闭包引用
func counter() func() int {
count := 0 // 逃逸
return func() int {
count++
return count
}
}9.4 减少逃逸的技巧
- 避免返回指针 (如果可以).
- 使用值类型而非接口 (在性能关键路径).
- 预分配切片容量 (
make([]int, 0, 100)). - sync.Pool 复用对象.
10. 内存分配分析
10.1 runtime.MemStats
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024)
fmt.Printf("TotalAlloc: %d MB\n", m.TotalAlloc/1024/1024)
fmt.Printf("NumGC: %d\n", m.NumGC)10.2 GODEBUG=gctrace=1
GODEBUG=gctrace=1 ./myapp输出每次 GC 的详细信息.
11. 练习
11.1 表驱动测试
为一个字符串反转函数编写表驱动测试, 包含边界情况 (空串、单字符、Unicode).
11.2 Benchmark 对比
对比字符串拼接的几种方式 (+, strings.Builder, bytes.Buffer) 的性能.
11.3 逃逸分析实验
编写代码, 故意制造逃逸, 使用 -gcflags="-m" 验证.
12. 思考题
- 为什么 Go 的 benchmark 需要循环
b.N次? - 表驱动测试比传统测试有什么优势?
- 如何判断一个函数需要优化?
- 栈分配为什么比堆分配快?
- pprof 的采样机制是什么?
13. 本周小结
- 测试:
_test.go文件,Test函数,t.Errorf. - 表驱动测试: 数据驱动,
t.Run子测试. - 覆盖率:
-cover,-coverprofile. - Benchmark:
Benchmark函数,b.N,-benchmem. - pprof: CPU/内存/Goroutine 分析, 火焰图.
- 逃逸分析:
-gcflags="-m", 栈 vs 堆分配.
测试是代码质量的保障, 性能分析是优化的起点. 不要盲目优化, 先测量.