大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
测试与基准
这一课你将能做到
- 写出表驱动测试(table-driven tests),并用
t.Run组织子测试、用-run精准过滤 - 使用
go test -v / -cover / -race等常用参数定位问题与查看覆盖率 - 编写基准测试(benchmark),读懂 ns/op 与内存分配数据;用 httptest 测 HTTP handler
为什么要学
Go 把测试做进了工具链:不需要 JUnit/pytest 这类三方框架,新建 _test.go 文件、写 TestXxx 函数、执行 go test,覆盖率、基准、竞态检测全部内置。
更重要的是 Go 社区有一套高度统一的测试审美——表驱动测试:把「用例数据」排成表格,循环跑断言。打开任何 Go 标准库源码,测试都是这个写法。学会它 = 获得读懂整个 Go 生态测试代码的通行证。面试中「说说表驱动测试」「怎么测 HTTP」也是 Go 岗位的固定题。
先建立图像
项目结构(测试文件与被测代码同包同目录):
calc/
├── go.mod
├── calc.go ← 被测代码 package calc
└── calc_test.go ← 测试代码 package calc(同名包 + _test.go 后缀)
三种测试函数:
func TestXxx(t *testing.T) {} ← 单元测试:go test
func BenchmarkXxx(b *testing.B) {} ← 性能基准:go test -bench=.
func ExampleXxx() {} ← 示例文档:go test 校验输出注释
用例表格(表驱动的心脏):
┌──────────┬────┬────┬──────┬──────────┐
│ name │ a │ b │ want │ wantErr │
├──────────┼────┼────┼──────┼──────────┤
│ 正常除法 │ 10 │ 2 │ 5 │ nil │
│ 除零 │ 1 │ 0 │ 0 │ ErrZero │
└──────────┴────┴────┴──────┴──────────┘
跟着做
步骤 1:被测代码与第一个测试
$ go mod init example.com/calc
calc.go:
package calc
import "errors"
var ErrDivideByZero = errors.New("除数不能为零")
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
calc_test.go(注意文件名后缀):
package calc // 与被测代码同包,可访问私有函数
import (
"errors"
"testing"
)
func TestDivide(t *testing.T) {
got, err := Divide(10, 2)
if err != nil {
t.Fatalf("不期望的错误: %v", err)
}
if got != 5 {
t.Errorf("Divide(10,2) = %v, 期望 5", got)
}
}
$ go test ./...
ok example.com/calc 0.3s
规则速记:文件名 *_test.go;函数名 Test 开头 + 首字母大写;参数固定 t *testing.T;t.Errorf 标记失败但继续,t.Fatalf 标记失败并终止当前测试。
步骤 2:表驱动测试 + 子测试
把多组用例排进结构体切片,一个函数测完所有场景:
package calc
import (
"errors"
"testing"
)
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr error
}{
{"正常除法", 10, 2, 5, nil},
{"负数运算", -9, 3, -3, nil},
{"小数结果", 1, 4, 0.25, nil},
{"除以零", 1, 0, 0, ErrDivideByZero},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { // 每个用例一个子测试
got, err := Divide(tt.a, tt.b)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("期望错误 %v, 实际 %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("不期望的错误: %v", err)
}
if got != tt.want {
t.Errorf("Divide(%v, %v) = %v, 期望 %v", tt.a, tt.b, got, tt.want)
}
})
}
}
$ go test -v ./...
=== RUN TestDivide
=== RUN TestDivide/正常除法
=== RUN TestDivide/负数运算
=== RUN TestDivide/小数结果
=== RUN TestDivide/除以零
--- PASS: TestDivide (0.00s)
--- PASS: TestDivide/正常除法 (0.00s)
...
只跑某个子测试:
$ go test -v -run "TestDivide/除以零" ./...
新增用例只是往表里加一行——这就是表驱动的可扩展性。
步骤 3:覆盖率与竞态
$ go test -cover ./...
ok example.com/calc 0.3s coverage: 100.0% of statements
$ go test -coverprofile=cover.out ./... && go tool cover -func=cover.out
example.com/calc/calc.go:7: Divide 100.0%
$ go test -race ./... # 竞态检测(并发代码必跑)
-race 会在测试运行时实时捕捉数据竞争并打印冲突的读写位置,是并发代码的体检仪。
步骤 4:基准测试
calc_test.go 中追加:
func BenchmarkDivide(b *testing.B) {
b.ReportAllocs() // 输出内存分配统计
for i := 0; i < b.N; i++ {
Divide(10, 2)
}
}
$ go test -bench=. -benchmem ./...
goos: windows
goarch: amd64
BenchmarkDivide-8 512345678 2.34 ns/op 0 B/op 0 allocs/op
解读:b.N 由框架自动调整(先小量跑,逐步放大直到采样稳定),保证统计可靠;2.34 ns/op 是单次操作耗时,0 allocs/op 表示零堆分配——分配数常比耗时更能暴露问题。
注意:基准里的初始化不要计入测量,用 b.ResetTimer():
func BenchmarkWithSetup(b *testing.B) {
data := make([]int, 1000)
for i := range data {
data[i] = i
}
b.ResetTimer() // 重新计时,剔除上面的准备成本
for i := 0; i < b.N; i++ {
_ = data[i%1000]
}
}
步骤 5:httptest——不启服务测 HTTP
net/http 的 handler 就是个函数,用 httptest 构造假请求和假响应即可直接调用:
// hello.go
package main
import (
"encoding/json"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(map[string]string{"msg": "你好, " + name})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello/{name}", helloHandler)
http.ListenAndServe(":8080", mux)
}
hello_test.go:
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHelloHandler(t *testing.T) {
tests := []struct {
name string
url string
wantCode int
wantBody string
}{
{"正常访问", "GET /hello/gopher", http.StatusOK, "gopher"},
{"名字带编码", "GET /hello/%E4%BD%A0", http.StatusOK, "你"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "", nil)
// 为了演示 PathValue,手动构造带 pattern 的请求:
req.SetPathValue("name", "gopher")
if tt.url != "GET /hello/gopher" {
req.SetPathValue("name", "你")
}
rec := httptest.NewRecorder()
helloHandler(rec, req) // 直接调用,无需端口、无需网络
if rec.Code != tt.wantCode {
t.Errorf("状态码 = %d, 期望 %d", rec.Code, tt.wantCode)
}
if !strings.Contains(rec.Body.String(), tt.wantBody) {
t.Errorf("响应体 %q 不包含 %q", rec.Body.String(), tt.wantBody)
}
})
}
}
要点:httptest.NewRequest 造请求、httptest.NewRecorder 造可检查的 ResponseWriter、直接调 handler——毫秒级完成,适合放进 CI。要测「经过路由的完整链路」,把 mux 传给 httptest.NewServer 即可。
讲清楚要点
- 测试文件不进产物:
go build完全忽略_test.go;测试代码可以随意使用 testing 包而不污染业务。 - 同包测试(white-box)与
_test包测试(black-box):测试文件声明package calc可测私有函数;声明package calc_test只测导出 API,更贴近使用者视角。两者可混用。 - 表驱动是社区标准:用例即数据,新增场景加一行;配合
t.Run得到独立命名、可单独过滤、可并行的子测试。 - Errorf 与 Fatalf 的区别:前者记录失败继续执行(适合循环内继续收集信息);后者立刻终止当前测试函数(前置条件失败时)。
- 基准由框架自适应放大 b.N:不要自己猜次数;对比性能用
-bench固定环境多次取均值(benchstat 工具)。 - 没有断言库是刻意的:
if got != want { t.Errorf(...) }就是标准做法;第三方断言库(testify)存在但非必需。
你可能会懵的地方
-
为什么 Go 不提供 assert 断言库? 官方理由:断言宏隐藏控制流,且报错文案千篇一律。Go 用普通 if +
t.Errorf显式书写,配合%v打印,可读性和定制性更好。团队需要时再引 testify,但先掌握原生写法。 -
b.N 每次运行都不一样,正常吗? 正常。框架动态调整迭代次数直到耗时达到稳定采样区间(约 1 秒)。跨版本对比性能时关注 ns/op 的相对变化而非绝对值。
-
怎么测试 goroutine / channel 并发代码? 先
-race扫一遍;同步点用 channel/WaitGroup 显式等待,而不是 sleep;对「永远不会发生」的分支用select { case <-time.After(...) ; t.Fatal }防测试挂死。 -
mock 怎么做? Go 惯用「接口替身」而非 mock 框架:为依赖定义小接口(如
type Store interface{ Get(...) }),测试时传一个内存假实现。接口越小,替身越省力——这也是接口要小的另一个理由。 -
测试函数里的循环变量捕获问题还在吗? Go 1.22 起循环变量每轮独立,老版本里
t.Run闭包捕获tt的经典坑已消除;但显式用tt := tt或以参数传入的写法依然兼容且清晰。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
测试文件名写成 calcTest.go | 不被工具链识别为测试 | 必须 calc_test.go 下划线后缀 |
函数名 testDivide / Testdivide | 被忽略,no tests to run | Test + 首字母大写 |
测试里 time.Sleep 等异步结果 | 又慢又不稳定 | channel / WaitGroup 显式同步 |
| 基准里先建大数据再测,不 ResetTimer | 初始化开销污染结果 | b.ResetTimer() |
用 panic 让测试失败 | 输出是 panic 栈而非失败报告 | t.Fatalf("got %v want %v", ...) |
断言只写 if got != want 不打印值 | 失败时无从排查 | t.Errorf("... = %v, want %v") |
卡点急救
no tests to run:函数名不符合TestXxx,或-run正则没匹配上;go test -v ./...全量确认。panic: test timed out:测试挂死(等一个没人写的 channel、死锁)。加上超时信号与 select 保护,再-race排查。- 覆盖率显示 0%:包路径不对;用
./...覆盖所有包,且测试文件与被测代码同目录。 - 本地过 CI 挂:常见于依赖时间、随机顺序、端口资源。测试要确定性:注入时钟/种子、httptest 分配端口。
面试常问
-
什么是表驱动测试?为什么 Go 社区推崇? 把输入/期望值组织成结构体切片,循环 +
t.Run执行断言。优点:新增用例零成本、失败报告带用例名、逻辑与数据分离、天然适配并发(t.Parallel());标准库源码即范例。 -
如何测试 HTTP 接口? 单 handler:
httptest.NewRequest+NewRecorder直接调用,零网络开销;整链路:httptest.NewServer(mux)起临时服务用真 HTTP 客户端打。配合表驱动覆盖状态码与响应体。 -
基准测试怎么读结果? ns/op 单次耗时、B/op 堆内存、allocs/op 分配次数。分配次数高往往先于耗时暴露问题(GC 压力);跨提交对比用 benchstat 做统计显著性判断,避免单次运行的噪声。
-
Go 如何做依赖注入与 mock? 面向小接口编程:消费方定义所需接口,生产实现与测试替身(fake/stub)都实现它;测试里注入假实现替代 DB/HTTP。不依赖运行时反射的 mock 框架,代码即替身。
自测三问
t.Errorf和t.Fatalf的区别?表驱动循环里为什么常用 Fatalf 处理前置错误?- 写出表驱动测试的骨架代码(表格结构 + t.Run 循环)。
BenchmarkXxx里为什么要b.ResetTimer()?ns/op 和 allocs/op 各说明什么?
小结一句话
Go 的测试哲学 = 工具链内置(test/cover/bench/race)+ 表驱动用例 + 小接口替身 mock;测试写得顺,是因为第 4 课起你写的每个接口都为它铺了路。
延伸
- 官方教程:为 Go 模块添加测试
- testing 包文档
- 下一课:综合练习——把 12 节课的武器合起来,从零交付一个带测试的 REST API 项目。