Go 测试
Go 测试围绕 testing 包、*_test.go 约定与 go test 工具链,建立失败报告、隔离、示例与覆盖率的基础质量反馈。
[!info] 关联笔记
Go 测试
这个概念为什么会出现
语言再清晰,没有可重复的反馈回路,回归仍会悄悄回来。Go 把测试做成工具链一等公民,而不是 IDE 插件或第三方 runner 的附加品:
- 约定即契约:
*_test.go、TestXxx、go test - 标准库
testing提供失败报告、日志、跳过、并行与子测试 - 同一条命令链可接上 benchmark、fuzz、race、覆盖率
若不先建立这套约定,后续表驱动、竞态检测与性能基线都会“会用命令却不会设计测试”。
[!abstract] 一句话理解 Go 测试是
go test+testing包驱动的内建质量反馈:用可观察行为写断言,用清晰失败信息定位输入/期望/实际值,并与包边界、示例与覆盖率共用同一工具链。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:为结算服务写「订单金额合计」单测
电商结算要把「商品小计 + 运费」合成应付金额。
改价、满减、运费规则会频繁动这行加法;若没有可重复的 go test,回归只能靠手工点单。
本例把合计抽成 OrderTotal,用 *_test.go 固定一对输入输出,演示 testing.T 的最小失败报告形态。
// file: order.go
package checkout
// OrderTotal 结算:商品小计 + 运费 = 应付金额(单位:分,避免浮点)。
// 业务意图:给下单页一个可测的纯函数入口。
// 教学点:被测代码普通 .go;测试放同包 *_test.go。
func OrderTotal(subtotalCents, shippingCents int) int {
return subtotalCents + shippingCents
}
// file: order_test.go
package checkout
import "testing"
// TestOrderTotal 为结算合计写最小单测。
//
// 业务意图:锁定「100 元商品 + 10 元运费 = 110 元」这条基线。
// 教学点:
// - TestXxx(t *testing.T) 是 go test 发现的入口;
// - 失败信息带输入 / 实际 / 期望,方便 CI 日志定位。
func TestOrderTotal(t *testing.T) {
// 模拟一笔普通国内单:小计 10000 分,运费 1000 分。
got := OrderTotal(10000, 1000)
if got != 11000 {
// Fatalf:标记失败并立刻结束本测试函数(后续断言不再跑)。
t.Fatalf("OrderTotal(10000,1000)=%d, want 11000", got)
}
}
建议运行(在含上述文件的模块目录):
go test .
go test -v -run TestOrderTotal
go test -cover .
期望输出(成功时简版):
PASS
ok .../checkout 0.00Xs
结合场景再看三个关注点
-
同包
package checkout可测未导出 helper
若改成package checkout_test,则只能测导出 API,更贴近调用方契约。 -
t.Fatal立即停,t.Error可继续
结算断言若互不依赖,可用Error一次暴露多处偏差;本例只有一条基线,用Fatal足够。 -
失败消息写清业务输入
日志里要能读出「哪笔金额算错了」,而不是只写got != want。
核心概念与准确模型
文件与函数约定
| 约定 | 含义 |
|---|---|
*_test.go | 仅参与 go test 构建,不进普通 go build 产物 |
func TestXxx(t *testing.T) | 单元/集成测试入口;Xxx 首字母大写 |
func BenchmarkXxx(b *testing.B) | 基准测试(见 go-benchmarking) |
func FuzzXxx(f *testing.F) | 模糊测试(见 go-fuzz-testing) |
func ExampleXxx() | 示例测试,可核对输出 |
TestMain(m *testing.M) | 包级 setup/teardown 钩子 |
这些是工具链约定 + testing 包 API,不是语言语法的一部分。
同包测试 vs 外部测试包
package foo // 白盒:可测未导出 helper
package foo_test // 黑盒:只依赖导出 API,import "example.com/m/foo"
工程上常见策略:
- 核心算法细节:同包测试
- 公共库契约:外部测试包,避免“测到了实现细节却锁死重构”
失败、日志与控制流
t.Log / t.Logf // 仅在 -v 或失败时更有用
t.Error / t.Errorf // 标记失败,继续
t.Fatal / t.Fatalf // 标记失败并结束当前测试函数
t.Skip / t.Skipf // 跳过(环境不满足等)
t.Helper() // 标记辅助函数,失败栈指向调用方
t.Cleanup(func(){...}) // 注册清理,逆序执行
t.Parallel() // 与其他已标记并行的测试并发(见边界)
t.Helper() 对封装断言函数几乎是必需的,否则失败行号落在 helper 内部,定位成本升高。
示例测试(Example)
func ExampleAdd() {
fmt.Println(Add(1, 2))
// Output:
// 3
}
go test 会编译并核对 Output: 注释。示例同时进入 pkg.go.dev 文档,是“可运行文档”的一环。
覆盖率
go test -cover
go test -coverprofile=c.out && go tool cover -html=c.out
覆盖率是执行到了哪些语句的度量,不是测试质量本身。100% 覆盖仍可能漏业务规则。
与模块/包的关系
go test按包编译、执行- 测试依赖可放在
go.mod的普通require,或使用测试专用依赖管理模式(项目约定) - 构建标签(
//go:build integration)可隔离慢测/集成测
边界情况与反直觉行为
1. 测试函数内的 goroutine
若测试启动 goroutine 后主测试返回,进程可能提前结束或出现竞态。必须用 sync.WaitGroup、channel 或 t.Cleanup 等待,并考虑 go test -race。
2. 全局可变状态
包级 var、单例、环境变量会使测试顺序敏感。t.Setenv、t.Chdir(较新版本)与显式注入优于改全局后“碰运气还原”。
3. t.Parallel 与表驱动共享变量
并行子测试若共享可变 slice/map 而无隔离,会引入假失败。每个子测试应复制输入或使用不可变数据。
4. Fatal 在 goroutine 中
在非测试 goroutine里调用 t.Fatal 会直接 os.Exit 式地毁掉整个测试进程行为(文档明确警告)。应在测试 goroutine 中收集错误,或用 channel 回传后由主测试 Fatal。
5. 缓存
go test 会缓存成功结果。改环境相关逻辑时用 go test -count=1 强制重跑。
常见误区
[!warning] 常见误区:断言只写 “failed” 错误:
t.Fatal("bad")。
正确:t.Fatalf("Clamp(%d)=%d, want %d", in, got, want)。
[!warning] 常见误区:把测试当调试脚本 错误:大量
fmt.Println、依赖人工盯输出。
正确:可自动判定的断言 + 必要时t.Log。
[!warning] 常见误区:只测 happy path 错误:只断言合法输入。
正确:边界值、错误返回、幂等与空输入同样一等公民。
[!warning] 常见误区:覆盖率等于质量 错误:追求 100% 却不断言关键不变量。
正确:覆盖关键行为与失败模式;覆盖率作缺口提示。
工程实践
- 围绕可观察行为组织测试:函数/包的契约,而不是私有实现步骤。
- 失败即文档:读失败信息应能复现。
- 依赖注入优于运行时打桩:小接口传入时钟、时钟源、存储。
- 分层:单元快测默认跑;集成测用 build tag 或目录约定。
- CI 最小集:
go test ./...,关键包加-race,主分支可加 cover 门槛。 - 命名:
TestClamp_belowMin或表驱动name字段清晰可读。 - 与静态分析配合:测试抓行为回归;
go vet/staticcheck 抓另一类问题(go-static-analysis)。
func assertEq[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Fatalf("got %v, want %v", got, want)
}
}
可验证实验
实验 1:Error vs Fatal
在同一 Test 里先 t.Errorf 再继续断言,观察 -v 下是否执行后续行;再改为 Fatalf 对比。
实验 2:外部测试包
把 package mathx 改为 package mathx_test 并 import,确认未导出符号不可访问。
实验 3:覆盖率 HTML
对故意漏测的分支生成 coverprofile,在浏览器中标红未覆盖行。
实验 4:缓存
连续两次 go test 观察 “cached”;改测试后或 -count=1 后缓存失效。
本节总结
- 本质:约定文件/函数名 +
testingAPI +go test执行器。 - 关键技能:清晰失败信息、包边界选择、隔离副作用、与 race/bench/fuzz 共用入口。
- 最易错:共享状态、goroutine 生命周期、把覆盖率当目标。
- 下一步:表驱动与子测试 把用例规模化。
自测题
概念题
package foo与package foo_test的测试权限差在哪里?t.Error与t.Fatal对后续断言的影响?- 为什么
t.Helper()重要?
代码推理题
func TestX(t *testing.T) {
go func() {
t.Fatal("boom")
}()
}
这段测试的风险是什么?
工程思考题
库函数依赖当前时间做“是否过期”判断。如何设计以便单测稳定?
参考答案
展开
- 同包可访问未导出标识符;
foo_test只能通过导出 API。 Error记录失败继续;Fatal结束当前测试函数。- 把失败行号归到调用 helper 的测试代码,便于定位。
代码题:在其他 goroutine 中调用t.Fatal不安全;主测试可能未等待就结束。
工程题:注入func() time.Time或clock接口,测试传入固定时间。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package testing | 标准库文档 | T/B/F API、约定 |
| go test | 命令文档 | 标志、缓存、匹配 |
| Add a test | 官方教程 | 最小路径 |
| Go Wiki: TestComments | Wiki | 失败信息风格 |
| Cover | 官方博客 | 覆盖率工具 |
笔记元信息
- 建议文件名:
go-testing.md - 所属阶段:阶段五
- 学习顺序:测试与质量起点
- 建议下一篇:表驱动测试与子测试
- 本篇状态:已深化(书章结构;工具链约定与工程隔离)