← [ BACK_TO_NOTES ]

Go 测试

Go 测试围绕 testing 包、*_test.go 约定与 go test 工具链,建立失败报告、隔离、示例与覆盖率的基础质量反馈。

#concept #growing #dev #go

[!info] related notes

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)
	}
}

把两个代码块分别保存为 order.go 和 order_test.go;新目录先执行 go mod init example.com/checkout,再运行:

go test .
go test -v -run TestOrderTotal
go test -cover .

期望输出(成功时简版):

PASS
ok  	.../checkout	0.00Xs

结合场景再看三个关注点

  1. 同包 package checkout 可测未导出 helper
    若改成 package checkout_test,则需导入业务包,并把调用改为 checkout.OrderTotal(...);只能通过导出 API 测试。

  2. t.Fatal 立即停,t.Error 可继续
    结算断言若互不依赖,可用 Error 一次暴露多处偏差;本例只有一条基线,用 Fatal 足够。

  3. 失败消息写清业务输入
    日志里要能读出「哪笔金额算错了」,而不是只写 got != want。

核心概念与准确模型

文件与函数约定

约定含义
*_test.go仅参与 go test 构建,不进普通 go build 产物
func TestXxx(t *testing.T)单元/集成测试入口;名称在 Test 后不能紧接小写字母
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      // 标记失败,以 runtime.Goexit 结束调用 goroutine(执行 defer)
t.Skip / t.Skipf        // 跳过(环境不满足等)
t.Helper()              // 报告日志/失败的文件行号时跳过此 helper
t.Cleanup(func(){...})  // 注册清理,逆序执行
t.Parallel()            // 与其他已标记并行的测试并发(见边界)

t.Helper() 对封装断言函数几乎是必需的,否则失败行号落在 helper 内部,定位成本升高。它不改变 panic 的堆栈。

示例测试(Example)

// 添加到 order_test.go,并为该文件补 import "fmt"。
func ExampleOrderTotal() {
	fmt.Println(OrderTotal(1, 2))
	// Output:
	// 3
}

带 Output: 或 Unordered output: 注释的 Example 会运行并核对输出;没有输出注释的示例只编译、不执行。包发布到 pkg.go.dev 后,这些示例也可展示在文档中。

覆盖率

go test -cover
go test -coverprofile=c.out && go tool cover -html=c.out

覆盖率是执行到了哪些语句的度量,不是测试质量本身。100% 覆盖仍可能漏业务规则。

与模块/包的关系

  • go test 按包编译、执行
  • 测试依赖同样由 go.mod 的 require 管理;Go Modules 没有单独的 test/dev dependency 区域
  • 构建标签(//go:build integration)可隔离慢测/集成测

边界情况与反直觉行为

1. 测试函数内的 goroutine

若测试启动 goroutine 后主测试返回,进程可能提前结束或出现竞态。必须用 sync.WaitGroup、channel 或 t.Cleanup 等待,并考虑 go test -race。

2. 全局可变状态

包级 var、单例、环境变量会使测试顺序敏感。t.Setenv、t.Chdir(Go 1.24+)会通过 Cleanup 恢复状态,但不能用于并行测试或具有并行祖先的测试,因为它们修改进程全局状态。显式注入可减少这种依赖。

3. t.Parallel 与表驱动共享变量

并行子测试若共享可变 slice/map 而无隔离,会引入假失败。每个子测试应复制输入或使用不可变数据。

4. Fatal 在 goroutine 中

Fatal 调用 FailNow,后者通过 runtime.Goexit 终止调用它的 goroutine,并执行该 goroutine 的 defer;它不调用 os.Exit,也不停止其他 goroutine。只能在运行当前 Test 的 goroutine 中调用。工作 goroutine 应把错误送回测试 goroutine,由后者等待结果并决定是否 Fatal。

5. 缓存

显式指定包的 go test . / go test ./... 可缓存成功结果;不带包参数的 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% 却不断言关键不变量。
正确:覆盖关键行为与失败模式;覆盖率作缺口提示。

工程实践

  1. 围绕可观察行为组织测试:函数/包的契约,而不是私有实现步骤。
  2. 失败即文档:读失败信息应能复现。
  3. 依赖注入优于运行时打桩:小接口传入时钟、时钟源、存储。
  4. 分层:单元快测默认跑;集成测用 build tag 或目录约定。
  5. CI 最小集:go test ./...,关键包加 -race,主分支可加 cover 门槛。
  6. 命名:TestClamp_belowMin 或表驱动 name 字段清晰可读。
  7. 与静态分析配合:测试抓行为回归;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”;再用 go test -count=1 . 强制执行。对照不带包参数的 go test,它每次都会运行测试。

本节总结

  • 本质:约定文件/函数名 + testing API + go test 执行器。
  • 关键技能:清晰失败信息、包边界选择、隔离副作用、与 race/bench/fuzz 共用入口。
  • 最易错:共享状态、goroutine 生命周期、把覆盖率当目标。
  • 下一步:表驱动与子测试 把用例规模化。

自测题

概念题

  1. package foo 与 package foo_test 的测试权限差在哪里?
  2. t.Error 与 t.Fatal 对后续断言的影响?
  3. 为什么 t.Helper() 重要?

代码推理题

func TestX(t *testing.T) {
	go func() {
		t.Fatal("boom")
	}()
}

这段测试的风险是什么?

工程思考题

库函数依赖当前时间做“是否过期”判断。如何设计以便单测稳定?

参考答案

展开
  1. 同包可访问未导出标识符;foo_test 只能通过导出 API。
  2. Error 记录失败继续;Fatal 结束当前测试函数。
  3. 把失败行号归到调用 helper 的测试代码,便于定位。
    代码题:在其他 goroutine 中调用 t.Fatal 不安全;主测试可能未等待就结束。
    工程题:注入 func() time.Time 或 clock 接口,测试传入固定时间。

延伸阅读与资料来源

资料类型支撑
Package testing标准库文档T/B/F API、约定
go test命令文档标志、缓存、匹配
Add a test官方教程最小路径
Go Wiki: TestCommentsWiki失败信息风格
Cover官方博客覆盖率工具

笔记元信息

  • 建议文件名:go-testing.md
  • 所属阶段:阶段五
  • 学习顺序:测试与质量起点
  • 建议下一篇:表驱动测试与子测试
  • 本篇状态:已深化(书章结构;工具链约定与工程隔离)
创建于 2026/6/20 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本