8.1 表驱动单元测试
一个项目里最容易腐化的不是代码,而是测试。测试写得太啰嗦,改一次行为要改十处断言,久而久之就没人愿意维护。Go 社区给出的解药是表驱动测试(table-driven test):把「输入 → 期望输出」压成一张表,用同一个循环跑遍所有用例。它简单到不需要任何框架,却能让测试的增删改成本降到最低。
本节给 TaskAPI 的两块核心逻辑补测试:
internal/task的标题校验与internal/store的内存实现。这是全卷第一次写_test.go,第 8 章的后两节会在这批测试上继续加基准、覆盖率与测试替身。
8.1.1 Go 测试的三个约定
Go 没有内置的测试框架,testing 包用三个约定把测试钉死在语言里:
- 文件名以
_test.go结尾。这类文件只在go test时编译,不会进生产二进制。 - 函数签名固定为
func TestXxx(t *testing.T)。Xxx必须以大写字母开头,否则不会被执行。 - 用
go test ./...运行。它按包并行,输出按包汇总。
_test.go 文件里可以有两个包名:
| 包名 | 写法 | 能访问什么 |
|---|---|---|
| 内部测试包 | package task | 包的导出与未导出标识符 |
| 外部测试包 | package task_test | 只有导出标识符,模拟真实调用方 |
内部测试包能测私有函数,外部测试包能验证公开 API 是否自洽。两者可以在同一目录共存。
8.1.2 表驱动测试的骨架
先看最简单的形态:给 ValidateTitle 写一张用例表。internal/task/validate_test.go:
package task
import (
"errors"
"strings"
"testing"
)
func TestValidateTitle(t *testing.T) {
tests := []struct {
name string
title string
wantErr error
}{
{name: "normal", title: "写第 8 章", wantErr: nil},
{name: "empty", title: "", wantErr: ErrInvalidTitle},
{name: "only spaces", title: " ", wantErr: ErrInvalidTitle},
{name: "too long", title: strings.Repeat("字", 101), wantErr: ErrInvalidTitle},
{name: "boundary 100", title: strings.Repeat("字", 100), wantErr: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTitle(tt.title)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ValidateTitle(%q) err = %v, want %v", tt.title, err, tt.wantErr)
}
})
}
}
这张表有四个字段:name(子测试名)、输入 title、期望 wantErr,以及循环变量 tt。三个约定值得记牢:
name字段是必需的。没有它,t.Run只能自动编号,失败时你分不清是哪个用例。- 匿名结构体切片是惯用写法,字段名即文档,比位置参数清楚得多。
wantErr用nil与哨兵错误表示,配合errors.Is判断,而不是比较字符串——第 6 章的错误分层在这里派上用场。
实测运行:
$ GOTOOLCHAIN=go1.27.0 go test ./internal/task/ -v
=== RUN TestValidateTitle
=== RUN TestValidateTitle/normal
=== RUN TestValidateTitle/empty
=== RUN TestValidateTitle/only_spaces
=== RUN TestValidateTitle/too_long
=== RUN TestValidateTitle/boundary_100
--- PASS: TestValidateTitle (0.00s)
--- PASS: TestValidateTitle/normal (0.00s)
--- PASS: TestValidateTitle/empty (0.00s)
--- PASS: TestValidateTitle/only_spaces (0.00s)
--- PASS: TestValidateTitle/too_long (0.00s)
--- PASS: TestValidateTitle/boundary_100 (0.00s)
PASS
ok taskapi/internal/task 0.454s
注意 boundary 100 与 too long 这对用例——边界值是表驱动测试最大的收益点:加一个 100 字的用例,比读十遍实现更能确认上限逻辑没写错。
8.1.3 t.Run 与子测试
t.Run(name, func(t *testing.T){...}) 会把一段逻辑注册成子测试。它带来三个好处:
- 独立失败。某个子测试
Fatal不会中断其他子测试。 - 独立过滤。
go test -run 'TestValidateTitle/empty'只跑匹配的用例。 - 独立并行。子测试里调用
t.Parallel()可并发执行。
子测试名里的空格会被替换成下划线(only spaces → only_spaces),这是 go test 对名字做规范化,不影响你写的原字符串。
8.1.4 t.Error 还是 t.Fatal
testing.T 的失败方法分两类:
| 方法 | 行为 | 适用场景 |
|---|---|---|
t.Error / t.Errorf | 标记失败,继续执行 | 想一次收集多个失败 |
t.Fatal / t.Fatalf | 标记失败,立即终止当前测试 | 后续断言依赖前面结果 |
判断原则很简单:如果继续跑下去会 panic 或给出无意义的报错,就用 Fatal。例如上例中 err 不符合预期时,继续比较其他字段就没有意义,所以用 Fatalf。反之,若在遍历一个切片、想报告所有越界元素,就用 Errorf 让循环跑完。
8.1.5 t.Helper 让报错指向调用处
当断言逻辑被抽成辅助函数时,t.Fatalf 默认会把行号指向辅助函数内部,而不是调用它的测试。用 t.Helper() 修正:
func assertInvalid(t *testing.T, title string) {
t.Helper()
if err := ValidateTitle(title); !errors.Is(err, ErrInvalidTitle) {
t.Fatalf("ValidateTitle(%q) = %v, want ErrInvalidTitle", title, err)
}
}
调用 t.Helper() 后,go test 会跳过这一栈帧,报错行号直接落到 assertInvalid(t, "") 那一行。这对表驱动测试尤其重要——否则所有失败都指向同一个辅助函数,等于没有定位信息。
8.1.6 测试 MemStore:命中与未命中
同样的表驱动思路用到 internal/store。MemStore.Get 有两条路径:命中与未命中,正好一张表覆盖:
package store
import (
"errors"
"testing"
"taskapi/internal/task"
)
func TestMemStoreGet(t *testing.T) {
s := NewMemStore()
id, err := s.Add(task.New(0, "写测试"))
if err != nil {
t.Fatalf("Add: %v", err)
}
tests := []struct {
name string
id int64
wantErr error
}{
{name: "hit", id: id, wantErr: nil},
{name: "miss", id: 999, wantErr: ErrNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := s.Get(tt.id)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Get(%d) err = %v, want %v", tt.id, err, tt.wantErr)
}
})
}
}
func TestMemStoreList(t *testing.T) {
s := NewMemStore()
for i := 0; i < 3; i++ {
if _, err := s.Add(task.New(0, "任务")); err != nil {
t.Fatal(err)
}
}
if got := len(s.List()); got != 3 {
t.Fatalf("List len = %d, want 3", got)
}
}
实测输出:
$ GOTOOLCHAIN=go1.27.0 go test ./internal/store/ -v
=== RUN TestMemStoreGet
=== RUN TestMemStoreGet/hit
=== RUN TestMemStoreGet/miss
--- PASS: TestMemStoreGet (0.00s)
--- PASS: TestMemStoreGet/hit (0.00s)
--- PASS: TestMemStoreGet/miss (0.00s)
=== RUN TestMemStoreList
--- PASS: TestMemStoreList (0.00s)
PASS
ok taskapi/internal/store 0.485s
这里有一个容易忽略的点:s 在多个子测试间共享。MemStore 内部用 sync.RWMutex 保护,所以并发安全;但如果测试本身要并发(见 8.1.7),共享状态就需要更谨慎的设计。
8.1.7 并行与清理
两个实用的 *testing.T 能力:
func TestValidateTitleHelper(t *testing.T) {
t.Parallel()
t.Run("empty", func(t *testing.T) {
t.Parallel()
assertInvalid(t, "")
})
t.Run("spaces", func(t *testing.T) {
t.Parallel()
assertInvalid(t, " ")
})
}
t.Parallel():把当前测试标记为可并行,go test会在前一个并行测试Pause后调度它。输出里会出现PAUSE/CONT标记,这是正常的。t.Cleanup(fn):注册清理函数,在当前测试结束时(无论成功失败)按后进先出执行。它比defer更适合辅助函数——辅助函数注册的清理会跟随调用它的测试生命周期。t.TempDir():返回一个测试专属临时目录,测试结束自动删除,省去手写os.RemoveAll。
8.1.8 go test 常用参数
| 参数 | 作用 |
|---|---|
./... | 递归跑当前模块所有包 |
-v | 打印每个测试与子测试名 |
-run 'TestX/sub' | 按正则过滤测试 |
-short | 置 testing.Short(),跳过慢测试 |
-count=1 | 禁用结果缓存,强制重跑 |
-timeout 30s | 设置整体超时(默认 10m) |
-failfast | 遇到第一个失败就停 |
go test 默认会缓存成功结果,输出里出现 (cached) 就说明没真跑。改测试或代码后缓存自动失效,但改环境变量不会——这时加 -count=1 最稳妥。
8.1.9 小结
表驱动测试的核心只有一句话:把「用例」变成数据,把「逻辑」变成循环。它带来的直接收益是用例可枚举、可过滤、可并行;间接收益是逼你把函数设计成「纯输入输出」——一个难以表驱动测试的函数,往往也是耦合过重的函数。
下一节我们把测试从「正确性」推进到「性能与覆盖」:用 testing.B 写基准、用 -benchmem 看分配、用 go test -cover 量出哪些分支还没被测到。
阅读导航:上一节:7.3 go get/tidy/vendor 与依赖治理 · 下一节:8.2 基准测试与覆盖率 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。