Go 测试

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

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 测试

这个概念为什么会出现

语言再清晰,没有可重复的反馈回路,回归仍会悄悄回来。Go 把测试做成工具链一等公民,而不是 IDE 插件或第三方 runner 的附加品:

  • 约定即契约:*_test.goTestXxxgo 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

结合场景再看三个关注点

  1. 同包 package checkout 可测未导出 helper
    若改成 package checkout_test,则只能测导出 API,更贴近调用方契约。

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

  3. 失败消息写清业务输入
    日志里要能读出「哪笔金额算错了」,而不是只写 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.Setenvt.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% 却不断言关键不变量。
正确:覆盖关键行为与失败模式;覆盖率作缺口提示。

工程实践

  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”;改测试后或 -count=1 后缓存失效。

本节总结

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

自测题

概念题

  1. package foopackage foo_test 的测试权限差在哪里?
  2. t.Errort.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.Timeclock 接口,测试传入固定时间。

延伸阅读与资料来源

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

笔记元信息

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