
单元测试
前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制、切片、字符串、映射表、指针、结构体、函数、方法、接口、类型、错误、文件、反射、泛型和模块管理。接下来学习 Go 项目中不可缺少的一部分:单元测试(unit testing)。
代码能够编译,并不代表代码是正确的。编译器可以检查语法和类型,却不能替我们判断:一个空栈应该返回什么、一个非法输入是否应该返回错误、一个函数在边界值下是否仍然满足约定。单元测试要做的,就是把这些约定写成可以重复执行的程序。
Go 标准库中的 testing 包提供了测试、基准测试、模糊测试和示例测试的基础能力,go test 命令负责发现测试文件、编译测试二进制并执行测试。
本文的核心判断是:
- 测试应该验证公开行为,而不是只验证代码"执行过";
- 测试文件通常与被测源文件放在同一个包目录中,并以
_test.go结尾; - 表格驱动测试和子测试适合覆盖一组相似场景;
testing包不强制提供断言库,使用普通的if判断配合Errorf、Fatalf就足够开始;- 基准测试、模糊测试、竞态检测和覆盖率分别回答不同问题,不能用一个数字代替全部质量判断;
- 测试代码也需要维护,测试本身应该清楚、稳定并且容易定位失败原因。
本文示例在
go1.27.0 darwin/arm64环境中编译运行。不同操作系统、处理器和 Go 版本的耗时、覆盖率以及基准测试数字可能不同;测试 API 的基本规则以 Go 官方 testing 包文档 为准。
为什么需要单元测试
先看一个看起来已经完成的函数:
package mathutil
func AddSum(a, b int) int {
return a - b
}
这段代码没有语法错误,也可以正常编译。如果没有测试,错误可能直到接口上线后才被发现。我们真正关心的不是函数有没有被调用,而是它的输入和输出是否符合约定:
func TestAddSum(t *testing.T) {
got := AddSum(1, 2)
if got != 3 {
t.Errorf("AddSum(1, 2) = %d; want 3", got)
}
}
测试失败时,错误信息应该直接说明三件事:调用了什么、实际得到什么、期望得到什么。只写下面的代码没有测试价值:
func TestAddSum(t *testing.T) {
AddSum(1, 2) // 只是调用,没有检查结果
}
函数被调用并不等于行为正确。测试必须建立一个可观察的结果,然后把结果与期望进行比较。
testing 包的工作方式
Go 的测试发现依赖命名约定。官方文档规定,测试文件名以 _test.go 结尾,测试函数的形式是 TestXxx(*testing.T),其中 Xxx 不能以小写字母开头。
源码文件 测试文件
stack.go stack_test.go
│ │
└────────── go test ──────┘
│
编译临时测试二进制并执行
│
Test / Benchmark / Fuzz / Example
常见的四类函数如下:
| 函数形式 | 用途 | 执行方式 |
|---|---|---|
func TestXxx(t *testing.T) |
普通测试 | go test |
func BenchmarkXxx(b *testing.B) |
基准测试 | go test -bench . |
func FuzzXxx(f *testing.F) |
模糊测试 | go test 或 go test -fuzz=... |
func ExampleXxx() |
示例测试和文档示例 | go test |
测试文件会被 go build 等普通构建流程排除,只有 go test 会把它们编译进测试二进制。因此,测试辅助代码可以放在 _test.go 文件中,不会进入正常发布的程序。
第一个可运行的测试
官方推荐把测试文件放在被测包的目录中,而不是额外创建一个名为 test 的目录。先创建一个练习模块:
mkdir learn-test
cd learn-test
go mod init example.com/learn-test
mkdir stack
项目结构如下:
learn-test
├── go.mod
└── stack
├── stack.go
└── stack_test.go
这次我们实现一个泛型栈,并用单元测试验证它的后进先出行为。把测试和数据结构放在同一个包中,读者可以从一个完整的小例子看到测试的写法。
实现一个泛型栈
在 stack/stack.go 中写入:
package stack
import "errors"
// ErrEmpty 表示从空栈读取数据。
var ErrEmpty = errors.New("stack is empty")
// Stack 是一个后进先出(LIFO)的栈。
// 零值可以直接使用,不需要额外的构造函数。
type Stack[T any] struct {
items []T
}
// Push 将 value 放入栈顶。
func (s *Stack[T]) Push(value T) {
s.items = append(s.items, value)
}
// Pop 删除并返回栈顶元素。
func (s *Stack[T]) Pop() (T, error) {
if len(s.items) == 0 {
var zero T
return zero, ErrEmpty
}
last := len(s.items) - 1
value := s.items[last]
var zero T
s.items[last] = zero // 释放元素引用,避免长生命周期栈保留对象
s.items = s.items[:last]
return value, nil
}
// Peek 只读取栈顶元素,不删除它。
func (s *Stack[T]) Peek() (T, error) {
if len(s.items) == 0 {
var zero T
return zero, ErrEmpty
}
return s.items[len(s.items)-1], nil
}
// Len 返回栈中元素个数。
func (s *Stack[T]) Len() int {
return len(s.items)
}
// IsEmpty 判断栈是否为空。
func (s *Stack[T]) IsEmpty() bool {
return len(s.items) == 0
}
这里特意让 Stack[T] 的零值可用:
var numbers Stack[int]
numbers.Push(10)
Go 中很多标准库类型都遵循"零值可用"的设计。这样既少了一个初始化步骤,也减少了调用方忘记初始化的可能。
Pop 在空栈时返回 ErrEmpty,而不是依赖错误字符串。调用方可以使用 errors.Is 判断错误类别:
if errors.Is(err, ErrEmpty) {
// 栈为空,这是一个可预期的业务情况
}
编写第一个测试
在 stack/stack_test.go 中写入:
package stack
import (
"errors"
"testing"
)
func TestStackPushPop(t *testing.T) {
var s Stack[int]
s.Push(10)
s.Push(20)
if got := s.Len(); got != 2 {
t.Fatalf("Len() = %d; want 2", got)
}
got, err := s.Pop()
if err != nil {
t.Fatalf("Pop() returned an unexpected error: %v", err)
}
if got != 20 {
t.Errorf("first Pop() = %d; want 20", got)
}
got, err = s.Pop()
if err != nil {
t.Fatalf("second Pop() returned an unexpected error: %v", err)
}
if got != 10 {
t.Errorf("second Pop() = %d; want 10", got)
}
if !s.IsEmpty() {
t.Errorf("IsEmpty() = false; want true")
}
}
func TestStackPopEmpty(t *testing.T) {
var s Stack[string]
got, err := s.Pop()
if !errors.Is(err, ErrEmpty) {
t.Fatalf("Pop() error = %v; want ErrEmpty", err)
}
if got != "" {
t.Errorf("Pop() value = %q; want zero value", got)
}
}
进入 stack 目录执行:
go test
输出类似:
PASS
ok example.com/learn-test/stack 0.002s
go test 会执行当前目录对应的包。想看到每个测试函数的运行过程,可以添加 -v:
go test -v
=== RUN TestStackPushPop
--- PASS: TestStackPushPop (0.00s)
=== RUN TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS
ok example.com/learn-test/stack 0.002s
如果测试失败,go test 会输出失败测试的名称、源文件行号和我们通过 Errorf 或 Fatalf 写出的信息。
测试包应该怎么选择
测试文件可以使用两种包名。
与被测代码相同的包:白盒测试
package stack
这种方式可以访问 stack 包中的未导出标识符,适合测试包内部的细节,例如检查一个私有辅助函数或某个内部状态。上面的 stack_test.go 就是白盒测试。
使用 _test 后缀:黑盒测试
package stack_test
这种方式只能通过导出的 API 使用被测包,更接近真实调用方,适合验证包对外提供的契约。示例:
package stack_test
import (
"errors"
"testing"
"example.com/learn-test/stack"
)
func TestStackPublicAPI(t *testing.T) {
var s stack.Stack[string]
s.Push("Go")
got, err := s.Peek()
if err != nil {
t.Fatalf("Peek() returned an unexpected error: %v", err)
}
if got != "Go" {
t.Errorf("Peek() = %q; want Go", got)
}
_, err = s.Pop()
if err != nil {
t.Fatalf("Pop() returned an unexpected error: %v", err)
}
_, err = s.Pop()
if !errors.Is(err, stack.ErrEmpty) {
t.Errorf("Pop() error = %v; want stack.ErrEmpty", err)
}
}
同一个目录下可以同时存在 package stack 和 package stack_test 的测试文件,但正常源文件只能属于一个包。我的建议是:先用白盒测试把边界行为测清楚,再补一组黑盒测试确认公开 API 没有依赖内部实现。
用户补充的示例中把测试文件放在独立的 test 目录,并写成 package test。这种方式也可以存在,但它已经是另一个包,导入路径必须由 go.mod 中的模块名决定;初学和普通项目中,把 utils_test.go 放在 utils 目录下通常更直接。
表格驱动测试
当多个测试场景拥有相同的测试流程时,与其复制很多个测试函数,不如把变化部分放进表格。Go 社区把这种写法称为表格驱动测试(table-driven test)。
先抽出一个测试帮助函数:
func assertPop[T comparable](t *testing.T, s *Stack[T], want T) {
t.Helper()
got, err := s.Pop()
if err != nil {
t.Fatalf("Pop() returned an unexpected error: %v", err)
}
if got != want {
t.Errorf("Pop() = %v; want %v", got, want)
}
}
这里的 T comparable 是因为测试帮助函数需要使用 != 比较实际值和期望值。被测栈本身仍然可以存储任意 any 类型,并没有限制业务类型。
接着编写表格:
func TestStackPopTable(t *testing.T) {
tests := []struct {
name string
items []int
want []int
}{
{
name: "single item",
items: []int{1},
want: []int{1},
},
{
name: "last in first out",
items: []int{1, 2, 3},
want: []int{3, 2, 1},
},
{
name: "duplicate values",
items: []int{7, 7, 8},
want: []int{8, 7, 7},
},
}
for _, tc := range tests {
tc := tc // 兼容旧版本 Go 的闭包变量语义
t.Run(tc.name, func(t *testing.T) {
var s Stack[int]
for _, item := range tc.items {
s.Push(item)
}
for _, want := range tc.want {
assertPop(t, &s, want)
}
})
}
}
这里使用 t.Run 创建了三个子测试。运行结果会显示完整的层级名称:
=== RUN TestStackPopTable
=== RUN TestStackPopTable/single_item
=== RUN TestStackPopTable/last_in_first_out
=== RUN TestStackPopTable/duplicate_values
--- PASS: TestStackPopTable (0.00s)
--- PASS: TestStackPopTable/single_item (0.00s)
--- PASS: TestStackPopTable/last_in_first_out (0.00s)
--- PASS: TestStackPopTable/duplicate_values (0.00s)
子测试的价值不只是输出更漂亮,还可以单独运行某个场景:
go test -run '^TestStackPopTable/last_in_first_out$'
-run 参数是正则表达式,并且支持用 / 匹配测试层级。官方博客 Using Subtests and Sub-benchmarks 也使用这种方式筛选子测试。
Error、Fatal 和 Fail
Error 或 Errorf 会记录失败并继续执行当前测试函数;Fatal 或 Fatalf 会记录失败并通过 runtime.Goexit 结束当前测试 goroutine。它们的选择通常遵循这个规则:
value, err := operation()
if err != nil {
t.Fatalf("operation failed: %v", err) // 没有 value 就无法继续
}
if value != want {
t.Errorf("value = %v; want %v", value, want) // 一个断言失败,不妨继续看其他断言
}
不要在测试 goroutine 之外调用 t.Fatal 或 t.FailNow。官方文档明确说明,这些方法必须由运行该测试的 goroutine 调用;异步 goroutine 应该通过 channel、错误值或 t.Errorf 把结果传回测试主流程。
帮助函数和清理函数
t.Helper
公共测试逻辑抽成帮助函数后,如果没有标记,失败位置可能指向帮助函数内部,而不是具体测试用例。只需要调用一次 t.Helper():
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v; want %v", got, want)
}
}
当断言失败时,testing 会跳过这个帮助函数,把文件名和行号定位到调用 assertEqual 的测试代码。测试辅助函数越多,t.Helper 带来的定位收益越明显。
t.Cleanup
测试需要释放资源时,可以使用 t.Cleanup 注册清理函数。测试结束后,清理函数会自动执行,并且多个清理函数按照后进先出的顺序运行:
func TestResource(t *testing.T) {
resource := openResource()
t.Cleanup(func() {
_ = resource.Close()
})
// 测试 resource
}
实际的资源创建失败时仍然要立即报告错误。清理函数适合关闭文件、停止服务、恢复全局配置等工作,可以避免测试中间某个分支忘记释放资源。
t.TempDir 和 t.Setenv
需要临时文件时,不要把固定目录写死在项目中:
func TestWriteFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "result.txt")
if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
}
t.TempDir 返回的目录会在测试结束后自动删除。测试环境变量时,可以使用 t.Setenv,测试结束后 Go 会恢复原值:
func TestConfigFromEnv(t *testing.T) {
t.Setenv("APP_MODE", "test")
// 读取 APP_MODE 并进行断言
}
依赖全局环境的测试不应该随意调用 t.Parallel;例如 t.Setenv 与并行测试组合会让测试之间互相影响。
TestMain:整个包的钩子
如果同一个测试包需要统一初始化和收尾,可以定义 TestMain:
package stack
import (
"fmt"
"os"
"testing"
)
func setup() {
fmt.Println("setup")
}
func teardown() {
fmt.Println("teardown")
}
func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
os.Exit(code)
}
当 TestMain 存在时,测试进程会先调用它。只有调用 m.Run(),包中的普通测试、基准测试和示例测试才会真正运行。m.Run 返回测试结果状态码,通常直接传给 os.Exit。
不过,包级别状态越多,测试之间越容易互相影响。能用单个测试的 t.Cleanup 解决的问题,不要全部堆到 TestMain 中;TestMain 更适合启动一次测试服务器、创建数据库连接或配置统一日志等包级资源。
基准测试
普通测试回答"结果是否正确",基准测试回答"这段代码在重复执行时大致需要多少时间和内存"。基准测试函数必须以 Benchmark 开头,参数类型为 *testing.B。
在 stack/stack_test.go 中加入:
func BenchmarkStackPushPop(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var s Stack[int]
s.Push(i)
_, _ = s.Pop()
}
}
func BenchmarkStackPushPopParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
var s Stack[int]
for pb.Next() {
s.Push(1)
_, _ = s.Pop()
}
})
}
执行基准测试:
go test -run '^$' -bench '^BenchmarkStackPushPop$' -benchmem
输出示例:
goos: darwin
goarch: arm64
pkg: example.com/learn-test/stack
cpu: Apple M-series
BenchmarkStackPushPop-12 104216470 11.31 ns/op 8 B/op 1 allocs/op
PASS
ok example.com/learn-test/stack 0.612s
实际数字会随着处理器、Go 版本、编译器优化和系统负载变化。结果字段含义如下:
| 字段 | 含义 |
|---|---|
N |
基准框架为了稳定测量而执行的迭代次数 |
ns/op |
每次迭代平均耗时 |
B/op |
每次迭代平均分配的字节数 |
allocs/op |
每次迭代平均分配次数 |
这里使用 b.N 而不是手写一个固定循环次数,因为 testing 会自动调整 N,让测量达到足够的稳定性。b.RunParallel 会把工作分配给多个并发执行单元;每个并发执行单元都使用自己的栈,所以这个基准没有故意引入数据竞争。
-run '^$' 用于跳过普通测试。若不加它,go test -bench . 仍然可能先执行普通测试,然后再执行基准测试。基准测试不应该包含与被测操作无关的准备工作;如果确实有一次性准备,可以用 b.StopTimer 和 b.StartTimer 把准备阶段排除在计时之外。
覆盖率
覆盖率可以回答"测试执行过哪些语句",但不能直接回答"测试是否设计得好"。先查看当前包的覆盖率:
go test -cover
保存覆盖率文件并查看函数级统计:
go test -coverprofile=coverage.out
go tool cover -func=coverage.out
也可以生成 HTML 报告:
go tool cover -html=coverage.out
覆盖率高但断言很弱的测试,仍然可能漏掉关键错误。例如测试只调用函数却从不检查返回值,代码行会被执行,业务行为却没有被验证。因此我更建议先根据输入、输出、错误和边界条件设计测试,再把覆盖率作为遗漏线索,而不是把某个百分比当成唯一目标。
在模块根目录执行所有包的测试和覆盖率:
go test -cover ./...
./... 是 Go 包路径模式,表示当前模块及其子目录中的包。它和 go test 当前目录只测试一个包的行为不同。
模糊测试
普通测试由我们提供有限的输入;模糊测试(fuzzing)会在种子输入基础上持续变换数据,并利用覆盖率寻找新的执行路径。Go 从 1.18 起在标准工具链中支持原生模糊测试。
为栈添加一个模糊测试。它把任意字符串转换成字符序列,压栈后再逆序弹出,验证后进先出性质:
func FuzzStackLIFO(f *testing.F) {
f.Add("Go")
f.Add("")
f.Add("泛型与测试")
f.Fuzz(func(t *testing.T, input string) {
values := []rune(input)
var s Stack[rune]
for _, value := range values {
s.Push(value)
}
for i := len(values) - 1; i >= 0; i-- {
got, err := s.Pop()
if err != nil {
t.Fatalf("Pop() returned an unexpected error: %v", err)
}
if got != values[i] {
t.Fatalf("Pop() = %q; want %q", got, values[i])
}
}
if !s.IsEmpty() {
t.Fatal("stack is not empty after popping all values")
}
})
}
普通执行 go test 时,Go 会运行模糊测试中的种子语料;想真正启动持续模糊搜索,可以指定 -fuzz 和运行时间:
go test -fuzz=FuzzStackLIFO -fuzztime=10s
模糊测试函数必须满足 func FuzzXxx(f *testing.F) 的形式,并在 f.Fuzz 中接收 *testing.T 和支持的基本参数类型。官方文档 Go Fuzzing 说明了种子语料、生成语料和失败输入的关系。
如果模糊测试找到失败输入,Go 会把能够复现问题的样本保存到类似下面的目录:
testdata/fuzz/FuzzStackLIFO/
└── <hash>
修复代码后,这个样本会继续作为回归测试执行。也就是说,模糊测试不是只"随机跑一遍",它会把发现的问题转化为项目中的测试资产。
示例测试
示例测试既能验证输出,又可以被 go doc 识别为文档示例。函数名为 Example,函数体末尾使用 // Output: 写出精确期望:
func ExampleStack() {
var s Stack[string]
s.Push("Go")
s.Push("testing")
top, _ := s.Pop()
fmt.Println(top)
// Output:
// testing
}
不要把不稳定的内容放进输出断言,例如当前时间、随机数和依赖机器环境的绝对路径。示例测试最适合展示包的常见用法,同时让文档里的代码不会悄悄过时。
并行测试和竞态检测
t.Parallel
如果测试之间完全隔离,可以让它们并行执行:
func TestIndependentCase(t *testing.T) {
t.Parallel()
// 只能使用本测试独有的变量和资源
}
调用 t.Parallel 后,测试会在满足并行限制时与其他并行测试同时执行。共享可变变量、固定临时文件名、同一个数据库记录和全局环境变量,都可能使并行测试互相污染。并行执行是优化手段,不是测试正确性的前提,先保证隔离再使用。
go test -race
我们的 Stack[T] 没有加锁,因此同一个栈不能被多个 goroutine 同时读写。竞态检测器可以在测试运行时发现一部分数据竞争:
go test -race ./...
-race 会插入额外的检测代码,运行速度和内存消耗会增加。它不能证明"绝对没有并发 bug",但对并发代码而言,定期执行 go test -race 比只看普通测试结果更可靠。Go 官方的 Race Detector 文档 介绍了它的使用方式和限制。
如果产品需求是并发安全的栈,就应该在实现中增加 sync.Mutex 或设计清晰的单线程所有权模型,并编写真正的并发测试;不要因为普通单元测试通过,就默认数据结构已经线程安全。
测试依赖和可替换的时间
测试难写,很多时候不是因为逻辑复杂,而是因为代码直接依赖当前时间、随机数、网络或真实数据库。一个简单的做法是把变化的依赖通过接口传进来:
package expiry
import "time"
type Clock interface {
Now() time.Time
}
func IsExpired(clock Clock, deadline time.Time) bool {
return !clock.Now().Before(deadline)
}
type fakeClock struct {
now time.Time
}
func (c fakeClock) Now() time.Time {
return c.now
}
测试可以固定时间:
func TestIsExpired(t *testing.T) {
deadline := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
clock := fakeClock{now: deadline.Add(time.Second)}
if !IsExpired(clock, deadline) {
t.Fatal("IsExpired() = false; want true")
}
}
这里没有等待真实时间,也没有修改系统时钟,所以测试快速且稳定。网络请求、数据库和消息队列也可以使用相同的思路:先定义业务真正需要的最小接口,再在测试中注入一个可控的替身。不要为了"方便 mock"而把所有类型都抽象成很大的接口,接口应该由使用者定义,并且只包含真正需要的方法。
常用命令
模块根目录下经常用到的测试命令如下:
| 命令 | 作用 |
|---|---|
go test |
测试当前包 |
go test ./... |
测试当前模块中的所有包 |
go test -v |
输出每个测试的运行信息 |
go test -run TestName |
运行名称匹配正则的测试 |
go test -run '^TestX/Case$' |
运行某个子测试 |
go test -count=1 |
跳过测试缓存,强制重新执行 |
go test -shuffle=on |
随机化测试顺序,帮助发现共享状态问题 |
go test -failfast |
首个测试失败后尽快停止 |
go test -timeout=30s |
设置测试总超时时间 |
go test -cover |
输出语句覆盖率摘要 |
go test -race ./... |
运行竞态检测 |
go test -json |
输出机器可解析的测试事件 |
go test -bench . -benchmem |
执行基准测试并报告内存分配 |
go test -fuzz=FuzzXxx -fuzztime=10s |
执行指定模糊测试 |
测试结果被缓存时,go test 可能显示缓存结果。怀疑环境、时间或随机数导致结果不可信时,可以使用:
go test -count=1 ./...
查看所有测试命令和参数的官方解释,可以执行:
go help test
go help testflag
一个完整的测试流程
把上面的例子组合起来,日常开发可以按下面的流程执行:
修改业务代码
│
├── gofmt -w .
├── go test ./...
├── go test -race ./... (包含并发代码时)
├── go test -cover ./... (检查遗漏路径时)
└── go test -bench ... (性能有明确目标时)
测试失败后先读第一条真正的失败信息,不要只看最后的 FAIL。如果失败来自模糊测试保存的语料,先用普通 go test 重现,再修复实现并确认回归测试通过。
常见错误
把"调用过"当成"测试通过"
func TestSomething(t *testing.T) {
DoSomething() // 没有检查返回值或状态
}
这种测试只能证明代码没有在这里直接 panic,不能证明返回值、错误和副作用符合预期。
用错误字符串判断错误类型
if err.Error() == "stack is empty" {
// 文案一改,这个判断就失效
}
应该让代码返回哨兵错误或自定义错误类型,再使用 errors.Is 或 errors.As。本文的 ErrEmpty 就是这种用法。
测试依赖网络、时间和随机数
网络抖动、时区、机器负载和随机种子都会使测试偶发失败。单元测试应尽量使用内存数据和可控依赖;真实网络和数据库放到专门的集成测试中。
为了覆盖率测试内部实现
如果测试强行检查切片容量、私有字段排列或某个临时变量,重构实现时会得到大量无意义的失败。优先验证公开行为,只有内部不变量本身就是需要保证的契约时,才进行白盒断言。
基准测试没有使用 b.N
func BenchmarkBad(b *testing.B) {
for i := 0; i < 1000; i++ {
work()
}
}
固定循环次数会让 testing 无法正确调节工作量。基准测试必须使用 b.N,并把不属于被测操作的准备阶段移出计时区间。
并行测试共享可变状态
调用 t.Parallel 后,包级变量、全局环境变量和固定文件名都可能成为隐蔽的共享状态。先让每个测试拥有独立数据,再考虑并行化;最后用 go test -race 检查并发访问。
小结
到这里,我们已经从一个泛型栈出发,完整走过了 Go 单元测试的主要能力:
- 使用
_test.go文件和TestXxx函数编写普通测试; - 使用同包测试和
_test黑盒测试区分内部实现与公开契约; - 使用表格驱动测试和
t.Run覆盖多个场景; - 使用
t.Helper、t.Cleanup、t.TempDir管理测试代码和资源; - 使用
TestMain做包级初始化与收尾; - 使用
BenchmarkXxx、b.N和b.RunParallel测量性能; - 使用覆盖率寻找未执行路径,而不是把覆盖率当作质量分数;
- 使用
FuzzXxx发现边界输入,并把失败样本保存为回归测试; - 使用示例测试、竞态检测和依赖注入,让代码更容易理解和验证。
一个项目不需要一开始就引入复杂的测试框架。先让每个重要函数拥有清楚的输入、输出和错误约定,再用 go test ./... 把这些约定固定下来。测试的价值不在于文件数量,而在于它能否在代码改变后及时告诉我们:行为哪里变了、为什么变了、是否仍然符合设计。
官方资料
- Package testing:
testing.T、testing.B、testing.F和测试 API 的完整文档。 - Add a test - The Go Programming Language:官方教程中的测试文件、测试函数和
go test入门。 - Using Subtests and Sub-benchmarks:官方博客对
t.Run、b.Run和测试筛选的说明。 - Go Fuzzing:官方模糊测试指南和语料库规则。
- The Go Race Detector:
go test -race的使用与限制。