基准测试与示例测试
基准测试(Benchmark)用于测量函数或代码的性能表现,帮助发现性能瓶颈和验证优化效果。示例测试(Example)则为文档提供可运行的代码示例,同时验证示例输出是否正确。两者都是 Go testing 包的重要组成部分。
基准测试(Benchmark)
编写基准测试
基准测试函数以 Benchmark 开头,接收 *testing.B 参数:
// string/string_test.go
package string
import (
"strings"
"testing"
)
func BenchmarkConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
strings.Join([]string{"Hello", ", ", "World", "!"}, "")
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("Hello")
sb.WriteString(", ")
sb.WriteString("World")
sb.WriteString("!")
sb.String()
}
}b.N 的工作原理
b.N 是测试框架自动设定的循环次数。框架会先运行一小段时间来确定合适的 b.N 值,使得整个基准测试运行足够长的时间(默认约 1 秒)以获得稳定的结果。你的代码不应该修改 b.N。
运行基准测试
# 运行所有基准测试
$ go test -bench=. ./string/
goos: darwin
goarch: arm64
pkg: myproject/string
BenchmarkConcat-8 12567843 89.32 ns/op 32 B/op 1 allocs/op
BenchmarkConcatBuilder-8 28901234 40.15 ns/op 128 B/op 1 allocs/op
PASS
ok myproject/string 2.345s
# 运行特定基准测试
$ go test -bench=BenchmarkConcat ./string/
# 指定运行时间
$ go test -bench=. -benchtime=5s ./string/
# 指定最小运行次数
$ go test -bench=. -benchtime=1000x ./string/
# 运行基准测试并显示内存分配信息
$ go test -bench=. -benchmem ./string/基准测试结果分析
结果字段解读
BenchmarkConcat-8 12567843 89.32 ns/op 32 B/op 1 allocs/op
│ │ │ │ │
│ │ │ │ └─ 每次操作内存分配次数
│ │ │ └─ 每次操作内存分配字节数
│ │ └─ 每次操作耗时(纳秒)
│ └─ 总运行次数
└─ 基准测试名称(-8 表示 GOMAXPROCS=8)| 字段 | 含义 | 说明 |
|---|---|---|
BenchmarkConcat-8 | 测试名称 | -8 表示使用的 CPU 核心数 |
12567843 | 运行次数 | 框架自动决定,使总运行时间约 1 秒 |
89.32 ns/op | 每次操作耗时 | 纳秒/操作,越小越好 |
32 B/op | 每次操作内存分配 | 字节/操作,越小越好 |
1 allocs/op | 每次操作分配次数 | 次数/操作,越少越好 |
对比不同实现
package string
import (
"fmt"
"strings"
"testing"
)
// 实现 1:使用 + 拼接
func concatPlus(parts []string) string {
var s string
for _, p := range parts {
s += p
}
return s
}
// 实现 2:使用 strings.Join
func concatJoin(parts []string) string {
return strings.Join(parts, "")
}
// 实现 3:使用 strings.Builder
func concatBuilder(parts []string) string {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
return sb.String()
}
// 准备测试数据
var parts = []string{"Go", "is", "a", "wonderful", "programming", "language"}
func BenchmarkPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
concatPlus(parts)
}
}
func BenchmarkJoin(b *testing.B) {
for i := 0; i < b.N; i++ {
concatJoin(parts)
}
}
func BenchmarkBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
concatBuilder(parts)
}
}$ go test -bench=. -benchmem
BenchmarkPlus-8 8234567 145.2 ns/op 360 B/op 12 allocs/op
BenchmarkJoin-8 28901234 41.5 ns/op 48 B/op 1 allocs/op
BenchmarkBuilder-8 32145678 37.2 ns/op 64 B/op 1 allocs/op
PASS分析结果
+拼接:最慢,每次字符串拼接都创建新字符串,产生大量内存分配strings.Join:快 3.5 倍,只分配一次内存strings.Builder:最快,底层预分配缓冲区,减少扩容
ResetTimer 与 StopTimer
这三个方法用于精确控制基准测试的计时范围。在基准测试中,如果包含了不希望被计入耗时的准备工作(如创建测试数据),应该使用 b.StopTimer() 暂停计时,准备完毕后用 b.StartTimer() 或 b.ResetTimer() 恢复计时。
基本用法
func BenchmarkLargeDataProcessing(b *testing.B) {
// 暂停计时:创建测试数据不需要计入
b.StopTimer()
data := makeLargeTestData() // 耗时的准备工作
b.StartTimer() // 开始计时
for i := 0; i < b.N; i++ {
processData(data)
}
}b.ResetTimer()
b.ResetTimer() 重置计时器并清零内存分配计数器:
func BenchmarkWithSetup(b *testing.B) {
// 跳过不需要的初始化时间
heavySetup() // 耗时的初始化
// 重置计时器:忽略上面的初始化时间
b.ResetTimer()
for i := 0; i < b.N; i++ {
targetFunction()
}
}使用 b.Run() 进行参数化基准测试
func BenchmarkMapOperations(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
// 准备测试数据
b.StopTimer()
m := make(map[int]int, size)
for i := 0; i < size; i++ {
m[i] = i * 2
}
b.StartTimer()
for i := 0; i < b.N; i++ {
_ = m[size/2] // 查找操作
}
})
}
}$ go test -bench=BenchmarkMapOperations -benchmem
BenchmarkMapOperations/size_10-8 215678912 5.523 ns/op 0 B/op 0 allocs/op
BenchmarkMapOperations/size_100-8 126345678 9.456 ns/op 0 B/op 0 allocs/op
BenchmarkMapOperations/size_1000-8 85432167 13.876 ns/op 0 B/op 0 allocs/op
BenchmarkMapOperations/size_10000-8 62145678 19.234 ns/op 0 B/op 0 allocs/op基准测试的注意事项
- 避免编译器优化:确保循环体使用了结果(赋值给
_或全局变量) - 注意
b.N外的准备代码:使用StopTimer或ResetTimer排除 - 避免在循环内分配内存:内存分配会严重影响结果
- 多次运行取平均值:单次运行可能有偏差
- 关闭不必要的程序:确保系统负载稳定
示例测试(Example)
示例测试既是文档也是测试。它展示函数的使用方法,同时验证输出是否正确。godoc 工具会自动提取示例测试生成文档。示例测试函数以 Example 开头。
基本示例测试
// math/math_test.go
package math
import "fmt"
// 展示 Add 函数的基本用法
func ExampleAdd() {
sum := Add(2, 3)
fmt.Println(sum)
// Output: 5
}
// 展示 Divide 函数的用法(包括错误处理)
func ExampleDivide() {
result, err := Divide(10, 3)
if err != nil {
fmt.Println("错误:", err)
return
}
fmt.Printf("%.2f\n", result)
// Output: 3.33
}
// 无输出的示例(仅作为文档)
func Example() {
fmt.Println("Hello, Go!")
}示例测试的命名规则
示例测试的命名决定了它们与哪个函数/类型/方法关联:
package sort
// ExampleInts — 关联包级别函数
func ExampleInts() {
ints := []int{3, 1, 4, 1, 5}
Ints(ints)
fmt.Println(ints)
// Output: [1 1 3 4 5]
}
// ExamplePerson_String — 关联 Person 类型的 String 方法
func ExamplePerson_String() {
p := Person{Name: "Alice", Age: 30}
fmt.Println(p.String())
// Output: Alice (30)
}
// ExamplePerson_New — 关联 Person 类型(作为构造函数示例)
func ExamplePerson_New() {
p := NewPerson("Bob", 25)
fmt.Printf("%s is %d years old\n", p.Name, p.Age)
// Output: Bob is 25 years old
}
// ExamplePerson_Validate_error — 关联 Validate 方法,展示错误场景
func ExamplePerson_Validate_error() {
p := Person{Name: "", Age: -1}
err := p.Validate()
fmt.Println(err)
// Output: name is empty and age must be positive
}godoc 中的展示
godoc 会根据示例测试的命名规则,将示例自动放置在对应函数/类型/方法的文档页面下方。后缀 _error、_output 等可以区分不同的使用场景。
Unordered Output
当输出顺序不确定时,使用 Unordered output 注释:
func ExampleMapIteration() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Printf("%s:%d ", k, v)
}
fmt.Println()
// Unordered output: a:1 b:2 c:3
// 或 // Unordered output: c:3 a:1 b:2
}没有 Output 注释的示例
不包含 // Output: 注释的示例测试仅作为文档展示,不会被执行验证:
// 仅文档示例,不验证输出
func Example() {
fmt.Println("这是一个文档示例")
fmt.Println("不会被测试框架验证")
}go test 命令详解
常用命令参数
| 参数 | 说明 | 示例 |
|---|---|---|
go test | 运行当前目录的测试 | go test |
go test ./... | 运行所有目录的测试 | go test ./... |
-v | 详细输出模式 | go test -v |
-run <regex> | 运行匹配正则的测试 | go test -run TestAdd |
-bench <regex> | 运行基准测试 | go test -bench=. |
-benchtime <dur> | 基准测试运行时间 | go test -bench=. -benchtime=5s |
-count <n> | 运行测试 n 次 | go test -count=3 |
-cover | 显示覆盖率 | go test -cover |
-coverprofile <f> | 输出覆盖率到文件 | go test -coverprofile=c.out |
-race | 检测数据竞争 | go test -race |
-short | 跳过耗时测试 | go test -short |
-timeout <dur> | 设置超时时间 | go test -timeout 30s |
-run + -bench | 先运行测试再运行基准 | go test -run=^$ -bench=. |
-memprofile <f> | 输出内存分配分析 | go test -memprofile=mem.prof |
-cpuprofile <f> | 输出 CPU 分析 | go test -cpuprofile=cpu.prof |
常用命令组合
# 运行所有测试并显示覆盖率
go test -cover ./...
# 运行测试并生成 HTML 覆盖率报告
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# 运行特定测试(正则匹配)
go test -run "TestAdd|TestSub" ./...
# 运行基准测试(不运行单元测试)
go test -run=^$ -bench=. ./...
# 运行基准测试并显示内存信息
go test -bench=. -benchmem ./...
# 使用 race detector 检测数据竞争
go test -race ./...
# 并行运行测试(利用多核)
go test -parallel 4 ./...
# 设置超时时间(默认 10 分钟)
go test -timeout 60s ./...
# 运行指定包的测试
go test ./math/... ./string/...
# 多次运行测试以检测不稳定性(flaky tests)
go test -count=5 ./...构建标签(Build Tags)
使用构建标签来控制测试条件:
// +build integration
package mytest
func TestDatabase(t *testing.T) {
// 这个测试只在指定标签时运行
// go test -tags=integration ./...
}// 跳过耗时测试
func TestExpensive(t *testing.T) {
if testing.Short() {
t.Skip("跳过耗时测试(-short 模式)")
}
// 耗时操作...
}性能分析工具链
# 1. 生成 CPU profile
go test -bench=. -cpuprofile=cpu.prof ./...
# 2. 分析 CPU profile(交互式)
go tool pprof cpu.prof
# 3. 生成火焰图(需要 graphviz)
go tool pprof -http=:8080 cpu.prof
# 浏览器打开 http://localhost:8080
# 4. 内存分配分析
go test -bench=. -memprofile=mem.prof ./...
go tool pprof mem.prof
# 5. 阻塞分析
go test -bench=. -blockprofile=block.prof ./...pprof 的常用命令
在 go tool pprof 交互模式中:
top:显示最耗时的函数top -cum:按累计时间排序list 函数名:查看函数每行代码的耗时web:生成调用图(需要 graphviz)peek 函数名:查看函数的调用者和被调用者
测试的最佳实践
1. 测试结构:AAA 模式
AAA 是编写测试的经典模式:Arrange(准备)→ Act(执行)→ Assert(断言)。保持这三个部分的清晰分离,使测试更易读易维护。
func TestAdd(t *testing.T) {
// Arrange:准备测试数据
a, b := 2, 3
expected := 5
// Act:执行被测操作
result := Add(a, b)
// Assert:验证结果
if result != expected {
t.Errorf("Add(%d, %d) = %d, 期望 %d", a, b, result, expected)
}
}2. 测试命名:描述行为而非实现
// ❌ 不好的命名
func TestAdd1(t *testing.T) { ... }
func TestAdd2(t *testing.T) { ... }
func TestAddWorks(t *testing.T) { ... }
// ✅ 好的命名:描述被测行为和期望
func TestAddPositiveNumbers(t *testing.T) { ... }
func TestAddNegativeNumbers(t *testing.T) { ... }
func TestAddReturnsZeroForEmptyInput(t *testing.T) { ... }
func TestAddHandlesOverflow(t *testing.T) { ... }3. 测试独立性
每个测试应该独立运行
- 测试之间不应该有依赖关系
- 测试的执行顺序不应影响结果
- 使用
t.TempDir()创建独立的临时资源 - 不依赖全局状态
- 使用子测试隔离不同的测试场景
4. 基准测试的最佳实践
// ✅ 好的基准测试实践
func BenchmarkGood(b *testing.B) {
// 1. 在循环外准备大型数据
data := generateTestData()
// 2. 使用 ResetTimer 排除准备时间
b.ResetTimer()
// 3. 循环内只包含要测量的代码
for i := 0; i < b.N; i++ {
// 4. 确保结果被使用(防止编译器优化)
result := process(data)
if result == nil {
b.Fatal("意外结果")
}
}
}
// ❌ 不好的基准测试
func BenchmarkBad(b *testing.B) {
for i := 0; i < b.N; i++ {
data := generateTestData() // 每次循环都创建数据,扭曲结果
_ = process(data)
}
}5. 使用测试辅助库(简要介绍)
Go 社区常用的测试辅助库:
| 库 | 用途 | 示例 |
|---|---|---|
testify | 断言和 Mock | assert.Equal(t, got, want) |
httptest | HTTP 测试 | httptest.NewServer() |
testing/fstest | 文件系统测试 | fstest.MapFS{} |
testcontainers-go | Docker 集成测试 | 容器化数据库 |
// 使用 testify 的断言(需要 go get github.com/stretchr/testify)
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithTestify(t *testing.T) {
assert.Equal(t, 42, compute()) // 失败时继续
require.NoError(t, doSomething()) // 失败时停止
assert.True(t, isValid(input))
assert.Contains(t, result, "expected")
assert.Greater(t, count, 10)
}练习题
练习 1:编写基准测试并分析结果
为以下两个 Fibonacci 实现编写基准测试,对比性能差异:
// 递归实现(指数时间复杂度)
func FibRecursive(n int) int {
if n <= 1 {
return n
}
return FibRecursive(n-1) + FibRecursive(n-2)
}
// 迭代实现(线性时间复杂度)
func FibIterative(n int) int {
if n <= 1 {
return n
}
a, b := 0, 1
for i := 2; i <= n; i++ {
a, b = b, a+b
}
return b
}基准测试代码:
package fib
import "testing"
func BenchmarkFibRecursive10(b *testing.B) {
for i := 0; i < b.N; i++ {
FibRecursive(10)
}
}
func BenchmarkFibRecursive20(b *testing.B) {
for i := 0; i < b.N; i++ {
FibRecursive(20)
}
}
func BenchmarkFibRecursive30(b *testing.B) {
for i := 0; i < b.N; i++ {
FibRecursive(30)
}
}
func BenchmarkFibIterative10(b *testing.B) {
for i := 0; i < b.N; i++ {
FibIterative(10)
}
}
func BenchmarkFibIterative20(b *testing.B) {
for i := 0; i < b.N; i++ {
FibIterative(20)
}
}
func BenchmarkFibIterative30(b *testing.B) {
for i := 0; i < b.N; i++ {
FibIterative(30)
}
}
// 使用 b.Run 进行参数化基准测试(更优雅)
func BenchmarkFibIterative(b *testing.B) {
for _, n := range []int{10, 20, 30, 50, 100} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
FibIterative(n)
}
})
}
}典型结果:
BenchmarkFibRecursive10-8 52345678 22.1 ns/op 0 B/op 0 allocs/op
BenchmarkFibRecursive20-8 345678 3421.5 ns/op 0 B/op 0 allocs/op
BenchmarkFibRecursive30-8 23456 51234.2 ns/op 0 B/op 0 allocs/op
BenchmarkFibIterative10-8 87654321 13.5 ns/op 0 B/op 0 allocs/op
BenchmarkFibIterative20-8 65432178 18.2 ns/op 0 B/op 0 allocs/op
BenchmarkFibIterative30-8 54321098 21.0 ns/op 0 B/op 0 allocs/op分析:
- 递归实现随 n 增大,耗时急剧上升(指数增长),
n=30时已需要约 50μs - 迭代实现始终在约 15-20ns 内完成,几乎不受 n 影响
n=20时迭代实现比递归快约 188 倍- 两种实现都没有内存分配(0 B/op, 0 allocs/op)
结论:对于 Fibonacci 这类问题,迭代实现远优于递归实现。
练习 2:编写示例测试
为以下 Set 类型编写示例测试,展示创建、添加、删除、查找等操作,并确保输出验证通过:
type Set[T comparable] struct {
items map[T]bool
}
func NewSet[T comparable]() *Set[T] { ... }
func (s *Set[T]) Add(item T) { ... }
func (s *Set[T]) Remove(item T) { ... }
func (s *Set[T]) Contains(item T) bool { ... }
func (s *Set[T]) Size() int { ... }
func (s *Set[T]) Items() []T { ... }示例测试代码:
package set
import "fmt"
func ExampleNewSet() {
s := NewSet[int]()
fmt.Println(s.Size())
// Output: 0
}
func ExampleSet_Add() {
s := NewSet[string]()
s.Add("Go")
s.Add("Python")
s.Add("Go") // 重复添加
fmt.Println(s.Size())
fmt.Println(s.Contains("Go"))
// Output: 2
// true
}
func ExampleSet_Remove() {
s := NewSet[int]()
s.Add(1)
s.Add(2)
s.Add(3)
s.Remove(2)
fmt.Println(s.Size())
fmt.Println(s.Contains(2))
// Output: 2
// false
}
func ExampleSet_Contains() {
s := NewSet[int]()
s.Add(42)
fmt.Println(s.Contains(42))
fmt.Println(s.Contains(100))
// Output: true
// false
}
func ExampleSet_Items() {
s := NewSet[int]()
s.Add(3)
s.Add(1)
s.Add(2)
fmt.Println(s.Items()) // 注意:顺序可能不确定
// Unordered output: [1 2 3]
// 或:Unordered output: [3 1 2]
}
// 综合使用示例
func Example() {
s := NewSet[string]()
s.Add("apple")
s.Add("banana")
s.Add("cherry")
fmt.Println("包含 apple:", s.Contains("apple"))
fmt.Println("包含 grape:", s.Contains("grape"))
fmt.Println("大小:", s.Size())
s.Remove("banana")
fmt.Println("删除后大小:", s.Size())
// Output:
// 包含 apple: true
// 包含 grape: false
// 大小: 3
// 删除后大小: 2
}运行示例测试:
$ go test -v -run=Example
=== RUN ExampleNewSet
--- PASS: ExampleNewSet (0.00s)
=== RUN ExampleSet_Add
--- PASS: ExampleSet_Add (0.00s)
=== RUN ExampleSet_Remove
--- PASS: ExampleSet_Remove (0.00s)
=== RUN ExampleSet_Contains
--- PASS: ExampleSet_Contains (0.00s)
=== RUN ExampleSet_Items
--- PASS: ExampleSet_Items (0.00s)
=== RUN Example
--- PASS: Example (0.00s)
PASS练习 3:go test 命令练习
假设你有一个项目结构如下:
myproject/
├── calc/
│ ├── calc.go
│ └── calc_test.go
├── utils/
│ ├── utils.go
│ └── utils_test.go
├── integration/
│ └── integration_test.go // 包含标签:// +build integration
└── go.mod请写出满足以下需求的 go test 命令:
- 运行所有单元测试,显示详细输出
- 只运行
calc包的测试 - 运行所有测试并显示覆盖率百分比
- 运行集成测试
- 只运行基准测试(不运行单元测试)
- 运行基准测试,每次至少 3 秒,并显示内存分配信息
命令解答:
# 1. 运行所有单元测试,显示详细输出
go test -v ./...
# 2. 只运行 calc 包的测试
go test ./calc/
# 或指定特定测试
go test -run=. ./calc/
# 3. 运行所有测试并显示覆盖率百分比
go test -cover ./...
# 4. 运行集成测试(使用构建标签)
go test -tags=integration ./integration/
# 5. 只运行基准测试(不运行单元测试)
go test -run=^$ -bench=. ./...
# 6. 运行基准测试,每次至少 3 秒,显示内存分配
go test -run=^$ -bench=. -benchtime=3s -benchmem ./...补充常用命令:
# 生成 HTML 覆盖率报告
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# 使用 race detector 检测数据竞争
go test -race ./...
# 运行测试 5 次以检测不稳定性
go test -count=5 ./...
# 设置超时时间为 30 秒
go test -timeout=30s ./...
# 并行度设为 4
go test -parallel=4 ./...
# 显示 CPU 和内存 profile
go test -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof ./...
go tool pprof -http=:8080 cpu.prof