Go的秘密生活:测试

发布: (2026年1月10日 GMT+8 12:40)
11 min read
原文: Dev.to

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)

  1. 定义 一个匿名结构体切片,包含输入、期望输出和名称。
  2. 使用 range 遍历 该切片。
  3. 在循环内部 执行 逻辑。
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.blogThink Like a Genius 作者。

Back to Blog

相关文章

阅读更多 »

Go的秘密生活:错误处理

第12章:碎玻璃的声音 星期一的早晨,厚重的灰色雾气笼罩着整座城市。档案馆内部,寂静至极,随后被打破……

数据库事务泄漏

介绍 我们经常谈论 memory leaks,但在 backend development 中还有另一个沉默的性能杀手:Database Transaction Leaks。我最近…

天才大师与他高效的笨蛋仆人

“为什么!为什么!呃!” 当我看到Sanni抓住一把自己的头发并用力拉扯时,我的嘴角上扬。我靠在椅子上,注视着她眼中凶狠的光芒……