《Go 语言编程入门》8.1 表驱动单元测试

本节给 TaskAPI 的标题校验与内存存储补上第一批测试:从 Go 测试的三个约定讲起,搭出表驱动测试的骨架,用 t.Run 组织子测试,讲清 t.Error 与 t.Fatal 的取舍、t.Helper 如何修好报错行号,再用实测覆盖 MemStore 的命中与未命中两条路径,最后给出 go test 常用参数表。

8.1 表驱动单元测试

一个项目里最容易腐化的不是代码,而是测试。测试写得太啰嗦,改一次行为要改十处断言,久而久之就没人愿意维护。Go 社区给出的解药是表驱动测试(table-driven test):把「输入 → 期望输出」压成一张表,用同一个循环跑遍所有用例。它简单到不需要任何框架,却能让测试的增删改成本降到最低。

本节给 TaskAPI 的两块核心逻辑补测试:internal/task 的标题校验与 internal/store 的内存实现。这是全卷第一次写 _test.go,第 8 章的后两节会在这批测试上继续加基准、覆盖率与测试替身。

8.1.1 Go 测试的三个约定

Go 没有内置的测试框架,testing 包用三个约定把测试钉死在语言里:

  1. 文件名以 _test.go 结尾。这类文件只在 go test 时编译,不会进生产二进制。
  2. 函数签名固定为 func TestXxx(t *testing.T)。Xxx 必须以大写字母开头,否则不会被执行。
  3. 用 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 基准测试与覆盖率 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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