《Go 语言编程入门》8.2 基准测试与覆盖率

本节把 TaskAPI 的测试从「对不对」推进到「快不快、测没测到」:用 testing.B 写基准、用 Go 1.24 起的 b.Loop 取代手写 b.N 循环,读懂 -benchmem 的 ns/op 与 B/op,用 go test -cover 与 go tool cover 量出未覆盖分支,并说明为什么 100% 覆盖率不等于正确。

8.2 基准测试与覆盖率

上一节的表驱动测试回答「行为对不对」,这一节回答两个新问题:这段代码有多快? 以及 测试到底盖住了多少代码? Go 把这两件事都做进了 testing 包——基准测试就是名字以 Benchmark 开头的函数,覆盖率只是一个命令行开关。它们不需要引入任何第三方框架,这正是 Go 工具链的魅力。

本节给 TaskAPI 的存储与校验加基准,用 testing.B 的 b.Loop 写循环,实测 -benchmem 的分配数据;再用 go test -cover 与 go tool cover -func 量出各函数覆盖率,讨论哪些数字值得追、哪些不该追。

8.2.1 基准测试的签名

基准函数与测试函数长得很像,只有两点不同:

项单元测试基准测试
函数名TestXxxBenchmarkXxx
参数*testing.T*testing.B
运行命令go testgo 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 覆盖率的三个陷阱

覆盖率是个好指标,但极容易被误用:

  1. 100% 不等于正确。上表里 ValidateTitle 是 100%,可它只证明「所有语句被执行过」,不证明边界值都对——是表里那些用例在保证正确性,不是覆盖率数字。
  2. main 与 Complete 是 0% 很正常。main 函数难以单元测试,Complete 只是被测试间接调用不到。追着 0% 去写无意义的测试,是本末倒置。
  3. 别把覆盖率设成硬性门槛。门槛会诱导人写「为覆盖而覆盖」的测试。更有意义的做法是看 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练