
单元测试前面我们已经学习了 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 testfunc 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.002sgo test会执行当前目录对应的包。想看到每个测试函数的运行过程可以添加-vgo 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和FailError或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整个包的钩子如果同一个测试包需要统一初始化和收尾可以定义TestMainpackage 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 -coverprofilecoverage.out go tool cover -funccoverage.out也可以生成 HTML 报告go tool cover -htmlcoverage.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 -fuzzFuzzStackLIFO -fuzztime10s模糊测试函数必须满足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 -count1跳过测试缓存强制重新执行go test -shuffleon随机化测试顺序帮助发现共享状态问题go test -failfast首个测试失败后尽快停止go test -timeout30s设置测试总超时时间go test -cover输出语句覆盖率摘要go test -race ./...运行竞态检测go test -json输出机器可解析的测试事件go test -bench . -benchmem执行基准测试并报告内存分配go test -fuzzFuzzXxx -fuzztime10s执行指定模糊测试测试结果被缓存时go test可能显示缓存结果。怀疑环境、时间或随机数导致结果不可信时可以使用go test -count1 ./...查看所有测试命令和参数的官方解释可以执行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.Nfunc 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 testingtesting.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 Detectorgo test -race的使用与限制。