8.2 基准测试与覆盖率
上一节的表驱动测试回答「行为对不对」,这一节回答两个新问题:这段代码有多快? 以及 测试到底盖住了多少代码? Go 把这两件事都做进了 testing 包——基准测试就是名字以 Benchmark 开头的函数,覆盖率只是一个命令行开关。它们不需要引入任何第三方框架,这正是 Go 工具链的魅力。
本节给 TaskAPI 的存储与校验加基准,用
testing.B的b.Loop写循环,实测-benchmem的分配数据;再用go test -cover与go tool cover -func量出各函数覆盖率,讨论哪些数字值得追、哪些不该追。
8.2.1 基准测试的签名
基准函数与测试函数长得很像,只有两点不同:
| 项 | 单元测试 | 基准测试 |
|---|---|---|
| 函数名 | TestXxx | BenchmarkXxx |
| 参数 | *testing.T | *testing.B |
| 运行命令 | go test | go test -bench=. |
*testing.B 的核心是循环:go test 会自动决定循环次数,让每次测量持续约 1 秒,从而把计时噪声摊薄。你只负责写「一次操作」的代码。
8.2.2 b.N 与 b.Loop
历史上基准循环写作:
func BenchmarkXxx(b *testing.B) {
for i := 0; i < b.N; i++ {
// 被测操作
}
}
从 Go 1.24 起,标准做法换成了 b.Loop():
func BenchmarkXxx(b *testing.B) {
for b.Loop() {
// 被测操作
}
}
b.Loop() 不只是语法糖,它修掉了 b.N 时代的老问题:
- 防止编译器把被测代码优化掉。
b.N循环里的纯计算可能被内联消除,b.Loop会保留结果。 - 自动处理计时器。第一次调用前会重置计时器、清理 setup,你不用再手写
b.ResetTimer()。 - 不能中途
break,避免「循环次数被自己改小」导致数据失真。
本卷统一用 b.Loop。
8.2.3 实测:校验与列表的基准
给 ValidateTitle 写基准(internal/task/bench_test.go):
package task
import "testing"
func BenchmarkValidateTitle(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if err := ValidateTitle("写第 8 章 的基准测试"); err != nil {
b.Fatal(err)
}
}
}
再给 MemStore.List 写一个——它每次都要分配一个新切片,正好用来观察内存分配(internal/store/bench_test.go):
package store
import (
"testing"
"taskapi/internal/task"
)
func BenchmarkMemStoreList(b *testing.B) {
s := NewMemStore()
for i := 0; i < 1000; i++ {
_, _ = s.Add(task.New(0, "seed"))
}
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
_ = s.List()
}
}
运行:
$ GOTOOLCHAIN=go1.27.0 go test -bench=. -benchmem -run='^$' ./internal/store/ ./internal/task/
goos: darwin
goarch: arm64
pkg: taskapi/internal/store
cpu: Apple M1 Pro
BenchmarkMemStoreList-10 80112 14846 ns/op 57344 B/op 1 allocs/op
PASS
goos: darwin
goarch: arm64
pkg: taskapi/internal/task
cpu: Apple M1 Pro
BenchmarkValidateTitle-10 28258731 40.81 ns/op 0 B/op 0 allocs/op
PASS
-run='^$' 的作用是只跑基准、不跑单元测试——-bench 只筛选基准函数,-run 默认仍是 .* 会顺带跑测试,用空正则屏蔽掉可以省时间。
8.2.4 读懂 -benchmem 的三列
-benchmem 给每个基准加三列,含义如下:
| 列 | 含义 | 读法 |
|---|---|---|
ns/op | 每次操作耗时(纳秒) | 越小越好,注意受 CPU 影响 |
B/op | 每次操作分配的字节数 | 关注是否随数据量线性增长 |
allocs/op | 每次操作的分配次数 | 最该盯的一列,能降到 0 就降 |
回到实测:BenchmarkValidateTitle 是 0 B/op, 0 allocs/op,说明校验一个短标题完全不分配堆内存,这是理想状态。BenchmarkMemStoreList 是 57344 B/op, 1 allocs/op,因为每次 List() 都要 make([]task.Task, 0, len(s.data)) 并拷贝 1000 条任务——1 次分配是预期的,57344 字节也约等于 1000 个 Task 的大小。
优化的正确顺序是先看 allocs/op,再看 ns/op。分配次数往往比绝对耗时更能反映算法的可扩展性:一个 allocs/op 随元素数线性增长的函数,换个数据规模就会崩。
8.2.5 覆盖率的正确姿势
覆盖率用两个命令两步走:
$ GOTOOLCHAIN=go1.27.0 go test -coverprofile=cover.out ./...
$ GOTOOLCHAIN=go1.27.0 go tool cover -func=cover.out
-coverprofile 会跑测试并把每个语句的执行情况写进 cover.out;go tool cover 再把它渲染成人能看的报告。实测输出:
taskapi/internal/service/service.go:15: Create 83.3%
taskapi/internal/store/memstore.go:17: NewMemStore 100.0%
taskapi/internal/store/memstore.go:22: Add 100.0%
taskapi/internal/store/memstore.go:32: Get 100.0%
taskapi/internal/store/memstore.go:43: List 100.0%
taskapi/internal/task/validate.go:16: ValidateTitle 100.0%
taskapi/internal/task/task.go:15: New 0.0%
taskapi/internal/task/task.go:20: Complete 0.0%
total: (statements) 48.3%
两个有用的开关:
go tool cover -html=cover.out打开浏览器,未覆盖语句标红,最直观。go tool cover -func=cover.out打印上面这种函数级汇总,适合贴进 CI 日志。
还有 -covermode 三档:set(是否执行过,默认)、count(执行次数)、atomic(并发安全计数)。要看热点用 count,多 goroutine 下用 atomic。
8.2.6 覆盖率的三个陷阱
覆盖率是个好指标,但极容易被误用:
- 100% 不等于正确。上表里
ValidateTitle是 100%,可它只证明「所有语句被执行过」,不证明边界值都对——是表里那些用例在保证正确性,不是覆盖率数字。 main与Complete是 0% 很正常。main函数难以单元测试,Complete只是被测试间接调用不到。追着 0% 去写无意义的测试,是本末倒置。- 别把覆盖率设成硬性门槛。门槛会诱导人写「为覆盖而覆盖」的测试。更有意义的做法是看 diff 覆盖率:新改的代码有没有配套测试。
一个务实的起点是:核心业务包(这里是 internal/store、internal/task)追到 80% 以上,入口与胶水层不强求。
8.2.7 子基准与 -benchtime
当你要比较「同一操作在不同数据规模下的表现」时,用 b.Run 注册子基准,思路和 t.Run 一模一样:
func BenchmarkAddSizes(b *testing.B) {
for _, n := range []int{10, 100} {
b.Run(fmt.Sprintf("seed-%d", n), func(b *testing.B) {
s := NewMemStore()
for i := 0; i < n; i++ {
_, _ = s.Add(task.New(0, "x"))
}
b.ReportAllocs()
for b.Loop() {
_, _ = s.Add(task.New(0, "bench"))
}
})
}
}
输出里每个子基准的名字形如 BenchmarkAddSizes/seed-10-10、BenchmarkAddSizes/seed-100-10——/ 前是子基准名,末尾的 -10 是 GOMAXPROCS 值。子基准同样支持过滤:
GOTOOLCHAIN=go1.27.0 go test -bench='BenchmarkAddSizes/seed-100' ./internal/store/
另一个常用开关是 -benchtime,用来改变测量时长或次数:
# 每个基准至少跑 5 秒(默认 1s),结果更稳
GOTOOLCHAIN=go1.27.0 go test -bench=. -benchtime=5s ./...
# 固定跑 100 次,适合快速冒烟
GOTOOLCHAIN=go1.27.0 go test -bench=. -benchtime=100x ./...
-benchtime 的取值可以是时长(5s)也可以是次数(100x)。调参时用时长,做快速验证时用次数。
8.2.8 基准也要能回归
基准测试的价值在于对比。单次数字没有意义,只有「改前 vs 改后」的差异才有意义。两种用法:
# 老写法:跑两次,手动比对
GOTOOLCHAIN=go1.27.0 go test -bench=BenchmarkMemStoreList -count=10 ./internal/store/
# 用 benchstat 做统计比较(第三方工具,本节未实跑)
GOTOOLCHAIN=go1.27.0 go test -bench=. -count=10 ./... > old.txt
# ...改代码...
GOTOOLCHAIN=go1.27.0 go test -bench=. -count=10 ./... > new.txt
benchstat old.txt new.txt
-count=10 让每个基准重复 10 次,观察方差。如果 10 次结果波动超过 10%,说明测量环境不稳,先别下结论。benchstat 属于 golang.org/x/perf,需要额外安装,本卷不引入。
8.2.9 小结
本节把「快不快」与「测没测到」都变成了命令:
| 想做的事 | 命令 |
|---|---|
| 跑全部基准 | go test -bench=. -benchmem ./... |
| 只看某基准 | go test -bench=BenchmarkXxx ./pkg/ |
| 生成覆盖率数据 | go test -coverprofile=cover.out ./... |
| 看函数级覆盖率 | go tool cover -func=cover.out |
| 看行级高亮 | go tool cover -html=cover.out |
下一节我们处理一个更贴近工程的问题:当被测代码依赖外部存储时,测试怎么隔离?答案是测试替身——用手写的 fake 顶替真实的 TaskStore。
阅读导航:上一节:8.1 表驱动单元测试 · 下一节:8.3 测试替身与接口 mock 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。