53.2 单元测试
一句话
单元测试只测一个函数 / 方法的行为,不碰任何外部依赖,追求快、独立、可重复。
测什么、不测什么
| ✅ 该测 | ❌ 不该测 |
|---|---|
| 纯逻辑(计算、校验、转换、排序) | 外部依赖(数据库、HTTP、文件、时钟) |
| 分支与边界条件(空值、零值、极值) | 框架 / 库的自身行为(别人测过了) |
| 错误处理路径(参数非法、状态异常) | 私有实现细节(内部变量、私有函数名) |
Go 怎么写:表驱动测试
表驱动是 Go 社区的标配——一个测试函数覆盖多组用例:
func Add(a, b int) int { return a + b }
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"正数相加", 1, 2, 3},
{"负数相加", -1, -2, -3},
{"零参与", 0, 5, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
两个要点:
t.Run把每组用例变成子测试,跑挂了可以单独go test -run 'TestAdd/正数相加'复现。- 失败信息带上输入和期望——只写
"not equal"的测试,等于没写。
FIRST 五原则
| 原则 | 含义 | 违反的后果 |
|---|---|---|
| Fast | 毫秒级,一个包测完 < 1 秒 | 慢 → 人不跑 |
| Independent | 测试间不共享状态、不依赖顺序 | 互相污染,时好时坏 |
| Repeatable | 任何时候跑结果一样 | 依赖时间/随机 → flaky |
| Self-validating | 自己断言,不靠人肉看输出 | "看打印日志"不算测试 |
| Timely | 和功能代码一起写 | 事后补 = 没人补 |
好测试 vs 坏测试
| 好测试 | 坏测试 |
|---|---|
| 测行为(给输入,看输出) | 测实现(断言调了哪个函数) |
| 一个测试一个断言意图 | 一个测试里塞十件事 |
| 失败信息一眼看出错在哪 | 失败只有一句 "not equal" |
最普遍的反模式:测了实现细节
// ❌ 测实现:验证内部调用了 save(),断言 saveCalled == true
// 后果:你一重构(改函数名、改内部结构),测试就挂一大片,
// 即使对外行为完全没变。测试成了重构的"人质"。
🔑 记住:改实现不该让测试挂,改行为才该。 如果每次重构都要连带改测试,说明测试和实现耦合了——这是单元测试里最普遍的坏味道。
落到数字:单测的成本与并行
| 项 | 量级 | 说明 |
|---|---|---|
| 一个纯逻辑单测 | 0.1–1ms | 无 IO、无 sleep |
| 带 mock 的单测 | 1–10ms | 反射/序列化开销 |
t.Parallel() 后 | 接近核数倍加速 | 前提:无共享状态 |
| 1000 个单测 | 1–10s | 超过 30s 就该查 “谁在 sleep/IO” |
func TestParseAmount(t *testing.T) {
t.Parallel()
cases := []struct{ in string; want int64; ok bool }{
{"19.90", 1990, true},
{"-1", 0, false},
{"1e3", 0, false}, // 明确不接受的输入
}
for _, c := range cases {
c := c
t.Run(c.in, func(t *testing.T) {
t.Parallel()
got, err := ParseAmount(c.in)
if (err == nil) != c.ok || got != c.want {
t.Fatalf("ParseAmount(%q) = %d, %v; want %d, ok=%v", c.in, got, err, c.want, c.ok)
}
})
}
}
用模糊测试兜住 “没想到的输入”
go test -fuzz=FuzzParseAmount -fuzztime=30s ./...
模糊测试不替代表驱动,但它能自动找出让解析器 panic 的输入——这类 bug 手写用例几乎覆盖不到。
自检
Q1 你重构了一个函数——行为完全不变,只改了内部实现。单元测试应该怎样?
▸ 答案:**全部照常通过。**如果挂了一片,说明测试耦合了实现细节 (断言了内部函数调用/内部状态),需要重写成 “测行为”。
Q2 一个单元测试失败信息只有 got != want,够吗?缺什么?
▸ 答案:不够。缺输入是什么、期望多少、实际多少。没有这三样,你每次都要先复现、再加打印才能知道错哪——测试白写了。
Q3 为什么单测里不要 time.Sleep?
▸ 答案:因为 sleep 让测试又慢又不稳:慢是确定的(每个用例至少几十 ms),不稳是随机的(机器负载高时又不够)。需要等待时用 “轮询 + 超时” 或注入可控时钟,而不是固定 sleep。
Q4 单测要不要连数据库?
▸ 答案:纯逻辑单测不连(连了就是集成测试,见 53.3 集成测试)。但 “仓储层的 SQL 正确性” 必须由集成测试覆盖——把它塞进单测,只会得到 “用内存 fake 验证了一个假契约”。
Q5 一个测试方法里断言 20 个字段,是好还是坏?
▸ 答案:通常是坏:失败时只知道 “有一处不对”,定位成本高。更好的做法是一个测试一个行为,必要时用子测试拆分;但如果 20 个字段属于 “同一个不可分割的结构”,整体断言是可以接受的(如快照测试)。