Go的秘密生活:测试
Source: Dev.to
Source: …
真值表
星期三的雨点在档案室的窗户上敲出稳固的节奏,把曼哈顿的天际线模糊成灰色和石板色的污迹。屋内,空气中混杂着旧纸的味道和新鲜、锐利的柠檬香。
伊桑站在长橡木桌前,周围散落着纸片。他疯狂地敲击键盘,运行指令,皱眉,删除一行,又重新运行。
“柠檬罂粟籽松饼,”他边说边把一个白色袋子滑到桌上,目光不离键盘。“还有伦敦雾。伯爵茶、香草糖浆、蒸奶。”
埃莉诺接过茶。
“你看起来……有点激动,伊桑。”
“我在修用户名验证器的 bug,”伊桑嘀咕道。“我修好一种情况,又把另一种弄坏。再修好那一种,又把第一种弄坏。我已经
go run main.go运行了一个小时,只是手动改输入变量看看会怎样。”
埃莉诺慢慢把茶放下。
“你在手动测试?”
“还能怎么做?”
“伊桑,你是人类。你有创造力、直觉,而且容易感到无聊。你在重复性任务上很糟糕。”她打开笔记本电脑。“电脑没有创造力,也很无聊,但它们永远不会疲倦。我们不手动测试。我们写代码来测试我们的代码。”
“Go 并不要求你安装笨重的测试框架,”埃莉诺继续说。“它是内置的。你只需要在代码旁边创建一个以
_test.go结尾的文件。”
她新建了一个名为 validator_test.go 的文件:
package main
import "testing"
func TestIsValidUsername(t *testing.T) {
result := IsValidUsername("admin")
expected := true
if result != expected {
t.Errorf("IsValidUsername('admin') = %v; want %v", result, expected)
}
}
“函数必须以 Test 开头,并接受一个指向
testing.T的指针。这个t就是你的控制面板。你用它来报告失败。”
她在终端运行 go test。
PASS
“好,”伊桑说。“但我有二十种不同的情况。空字符串、符号、太长、太短……”
“所以你要写二十个断言?”埃莉诺问。“把同样的
if块复制粘贴二十遍?”
“大概是吧?”
“不。”埃莉诺摇摇头。“那就是让你在样板代码里溺死。在 Go 中,我们使用一种特定的惯用法。我们把测试案例当作数据,而不是代码。我们构建一个 真值表。”
她擦掉屏幕,开始敲写一个看起来更像账本而非脚本的结构:
package main
import "testing"
func TestIsValidUsername(t *testing.T) {
// 1. 定义表格
// 一个匿名结构体切片,包含所有输入和期望输出
tests := []struct {
name string // 测试案例的描述
input string // 函数的输入
expected bool // 我们期望得到的结果
}{
{"Valid user", "ethan_rose", true},
{"Too short", "ab", false},
{"Too long", "this_username_is_way_too_long_for_our_system", false},
{"Empty string", "", false},
{"Contains symbols", "ethan!rose", false},
{"Starts with number", "1player", false},
}
// 2. 遍历表格
for _, tt := range tests { // tt = “test table” 条目
// 3. 运行子测试
t.Run(tt.name, func(t *testing.T) {
got := IsValidUsername(tt.input)
if got != tt.expected {
// 使用 Errorf,而不是 Fatalf。
// Errorf 标记失败但继续执行下一个案例。
t.Errorf("IsValidUsername(%q) = %v; want %v", tt.input, got, tt.expected)
}
})
}
}
“看看这个结构,”埃莉诺说,手指划过切片。“逻辑——
if检查、执行——只写了一遍。测试的复杂度与数据的复杂度是分离的。”
伊桑盯着看。
“这……像电子表格。”
“正是。它就是一张表格。如果你发现了新 bug——比如,用户名不能以下划线结尾——你不需要写新函数。只要在这里再加一行就行了。”
Source: …
她输入:
{"Ends with underscore", "ethan_", false},
“这样就完成了。测试框架会处理其余的工作。注意我使用的是
t.Errorf,而不是t.Fatalf。如果使用Fatal,第一次失败就会停止整个测试。使用Error,我们可以一次看到所有的失败。”
“留意
t.Run那一行,” Eleanor 指出,“这会创建一个子测试。如果‘空字符串’的情况失败,Go 会按名称准确告诉你是哪一个失败了。”
她故意破坏代码以示范:
--- FAIL: TestIsValidUsername (0.00s)
--- FAIL: TestIsValidUsername/Empty_string (0.00s)
validator_test.go:26: IsValidUsername("") = true; want false
FAIL
“它会立刻给出上下文信息。你修复那个特定的案例,重新运行测试,就能看到绿色的 PASS。这就是反馈循环。先在表格中写一个会失败的案例,修复代码,让它通过。重复这个过程。”
Ethan 揉了揉眼睛。
“这本来可以帮我省下今天早上的三个小时。”
“它可以在你的职业生涯中为你省下三年时间,” Eleanor 边说边咬了一口柠檬罂粟籽松饼。“表驱动模式会迫使你思考边界情况。当你看到表格时,大脑自然会问:‘还有什么没考虑到?我检查过负数了吗?我检查过 nil 吗?’”
“这对错误处理也适用吗?” Ethan 问道,“比如我们上次讨论的错误处理?”
“它在错误处理上表现尤为出色,” Eleanor 微笑着说。
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
filename string
wantErr bool // 简单的布尔检查:我们是否得到错误?
}{
{"Valid file", "config.json", false},
{"File not found", "missing.json", true},
{"Bad permissions", "root_only.json", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseConfig(tt.filename)
// 如果我们期望出现错误(true)却没有得到错误(nil)……则失败。
if (err != nil) != tt.wantErr {
t.Errorf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
“这里的
wantErr是一个简单的布尔值。我们并不总是需要 …”
(对话继续。)
Conversation Excerpt
“检查确切的错误信息文本——通常,仅仅知道它失败了就足以进行逻辑检查。如果你需要检查特定的错误类型,可以在循环中使用
errors.Is。”
Ethan 闭上眼睛,想象着他那凌乱的 main.go。
“所以测试文件基本上就是程序的规范。”
“是的。它是永远不会说谎的文档。注释可能会过时,图表可能会出错。但只要测试通过,代码就能工作。”
她喝完茶说:“有句古老的俄语谚语:Doveryay, no proveryay。”
“信任,但要验证?”
“没错。相信你写了好代码。但要用表格来验证它。”
Ethan 新建了一个名为 user_test.go 的文件并开始输入:
tests := []struct {
// fields for each test case
}{ ... }
“Eleanor?”
“什么事?”
“这个松饼挺不错的。”
“还算可以,”她说,嘴角微微上扬。“现在,给用户名包含表情符号的情况加一个测试用例。我怀疑你的验证器会失败。”
Source: …
Go Testing Primer
测试包
- Go 内置的测试框架。
- 不需要外部库。
文件命名
- 测试文件 必须 以
_test.go结尾(例如user_test.go)。 - 编译常规应用时会被忽略,但会被
go test识别。
测试函数
- 必须以
Test开头。 - 签名:
func TestXxx(t *testing.T)
表驱动测试(Idiomatic Go)
- 定义 一个匿名结构体切片,包含输入、期望输出和名称。
- 使用
range遍历 该切片。 - 在循环内部 执行 逻辑。
func TestValidateUsername(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
{"valid‑ascii", "john_doe", false},
{"invalid‑space", "john doe", true},
{"emoji‑username", "😀user", true},
}
for _, tc := range cases {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
err := ValidateUsername(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateUsername(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
}
})
}
}
Error 与 Fatal
| 函数 | 行为 |
|---|---|
t.Errorf | 记录一次失败 但继续 运行测试函数。适用于表驱动测试,以便看到多个失败。 |
t.Fatalf | 记录一次失败 并立即停止 测试函数。仅在测试无法继续(例如设置失败)时使用。 |
子测试 (t.Run)
- 允许为循环的每一次迭代标记标签。
- 若某个 case 失败,
go test会报告该失败 case 的具体名称。
运行测试
| 命令 | 描述 |
|---|---|
go test | 运行包内的所有测试。 |
go test -v | 详细输出——显示每个子测试。 |
go test -run TestName | 只运行指定的测试函数。 |
思维模型
测试不是负担;它是一张真理表。
它把 数据(测试用例)与 执行逻辑(测试框架)分离开来。
下一章:实践中的接口
当你的验证器需要为管理员和普通用户制定不同规则时,会发生什么?
Ethan 了解到 “accept interfaces, return structs” 是实现灵活设计的关键。
关于作者
Aaron Rose – 软件工程师兼技术作家,供职于 tech‑reader.blog;Think Like a Genius 作者。