Go 测试:从单元测试到可观测的质量体系
面向有 Java 经验的开发者,基于 Go 1.26。
Go 没有为测试另造一套庞大的框架。编译、运行、缓存、覆盖率、竞态检测、模糊测试和基准测试都由 go test 统一调度,测试代码依旧是普通的 Go 代码。这套设计看起来朴素,却抓住了测试最重要的一件事:结果能否稳定复现。测试文件跟着包走,依赖写进 go.mod,同一条命令可以在开发机和 CI 中执行。
只会写 func TestXxx(t *testing.T) 还不够。测试要长期维护,就绕不开边界划分、数据构造、时间与并发、外部依赖、失败诊断、基准偏差、覆盖率误读和不稳定测试。下面从最小示例开始,把这些问题逐个放回真实的测试流程中。
目录
1. 测试文件与执行模型
测试文件以 _test.go 结尾,和被测代码放在同一个 package 目录。它们不会进入普通的 go build 产物,只会由 go test 编译。
money/
├── money.go
├── money_test.go
└── example_test.gogo test 大致会做这些事:
- 加载目标 package 及其依赖;
- 编译 package;
- 编译
_test.go文件和由工具生成的测试入口; - 运行测试、示例、模糊测试的种子语料和基准测试中被选中的部分;
- 根据源码、环境和参数决定是否复用缓存结果。
常用执行范围:
go test # 当前 package
go test ./... # 当前 module 下所有 package
go test ./internal/pay # 指定 package
go test -run TestParse # 用正则筛选测试名
go test -v ./... # 输出每个测试和日志
go test -count=1 ./... # 不复用成功结果缓存go test ./... 的 ... 是 package pattern,不是 shell 的文件通配符。它会递归匹配当前 module 下的 package,但不会自动跨进嵌套 module。
2. 编写第一个单元测试
被测代码:
package money
import (
"errors"
"math"
)
var ErrInvalidRate = errors.New("rate must be finite and non-negative")
func ApplyDiscount(cents int64, rate float64) (int64, error) {
if rate < 0 || rate > 1 || math.IsNaN(rate) || math.IsInf(rate, 0) {
return 0, ErrInvalidRate
}
return cents - int64(math.Round(float64(cents)*rate)), nil
}测试函数必须满足三个条件:
- 名字以
Test开头,后面的首字符不是小写字母; - 参数只有一个:
t *testing.T; - 没有返回值。
package money
import "testing"
func TestApplyDiscount(t *testing.T) {
got, err := ApplyDiscount(10_000, 0.2)
if err != nil {
t.Fatalf("ApplyDiscount() error = %v", err)
}
const want int64 = 8_000
if got != want {
t.Errorf("ApplyDiscount() = %d, want %d", got, want)
}
}测试失败信息至少要回答三个问题:调用了什么、实际得到什么、期望什么。如果只写 failed 或 结果错误,失败后还得重新调试一遍,日志本身几乎没有价值。
Error、Fatal 和 FailNow
t.Error/t.Errorf:记录失败,当前测试继续执行;t.Fatal/t.Fatalf:记录失败,并通过runtime.Goexit终止当前测试 goroutine;t.Fail:只把测试标为失败;t.FailNow:把测试标为失败并终止当前测试 goroutine。
前置条件不成立时用 Fatalf,多个彼此独立的断言可以用 Errorf:
user, err := loadUser()
if err != nil {
t.Fatalf("loadUser() error = %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %q, want %q", user.Name, "Alice")
}
if user.Active != true {
t.Errorf("Active = %v, want true", user.Active)
}不要从测试创建的其他 goroutine 调用 Fatalf 或 FailNow。它们只终止调用者所在的 goroutine,并不会按预期终止主测试流程。工作 goroutine 应把结果或错误通过 channel 传回测试 goroutine。
3. 表驱动测试
同一个行为需要覆盖多个输入时,表驱动测试比复制多个函数更容易维护:
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
cents int64
rate float64
want int64
wantErr error
}{
{
name: "twenty percent",
cents: 10_000,
rate: 0.2,
want: 8_000,
},
{
name: "round to nearest cent",
cents: 999,
rate: 0.1,
want: 899,
},
{
name: "negative rate",
cents: 100,
rate: -0.1,
wantErr: ErrInvalidRate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ApplyDiscount(tt.cents, tt.rate)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("error = %v, want %v", err, tt.wantErr)
}
if err == nil && got != tt.want {
t.Errorf("result = %d, want %d", got, tt.want)
}
})
}
}测试表应该保存“一个用例真正变化的东西”。如果每行塞进十几个布尔开关,测试表反而会隐藏意图。复杂场景可以给每个用例一个 prepare 或 check 函数,但不要为了追求统一格式把简单断言变成晦涩的元编程。
用例名会成为完整测试路径的一部分:
TestApplyDiscount/twenty_percent
TestApplyDiscount/negative_rate可以单独运行:
go test -run 'TestApplyDiscount/negative_rate'4. 子测试与并行测试
t.Run 创建子测试。父测试可以组织共享准备工作,也可以让子测试并行:
func TestParser(t *testing.T) {
tests := []struct {
name string
in string
want int
}{
{"zero", "0", 0},
{"positive", "42", 42},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := strconv.Atoi(tt.in)
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Errorf("Atoi(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}从 Go 1.22 开始,for range 每次迭代创建新的迭代变量,因此在 module 的语言版本至少为 1.22 时,上例闭包捕获 tt 是安全的。维护旧 module 时仍要看 go.mod 的 go 行,不能只看本机工具链版本。
并行测试不是默认优化按钮。只有这些条件满足时才适合调用 t.Parallel():
- 用例不修改进程级环境;
- 不依赖全局时钟、全局 logger 或单例;
- 临时文件和监听端口相互隔离;
- 被测依赖允许并发访问;
- 用例之间没有隐含顺序。
并行子测试会先暂停,等顺序部分和父测试到达合适阶段后再调度。可以利用这一点实现分组:
func TestGrouped(t *testing.T) {
t.Run("parallel group", func(t *testing.T) {
for _, name := range []string{"a", "b", "c"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
// ...
})
}
})
// 到这里时,组内子测试已经完成。
}5. 失败、日志与辅助函数
5.1 日志
t.Log 和 t.Logf 在测试失败或使用 -v 时显示:
t.Logf("request id=%s", requestID)日志是诊断信息,不应替代断言。不要让测试依赖人工阅读日志来判断成功与否。
5.2 t.Helper
辅助函数调用 t.Helper() 后,失败位置会指向调用辅助函数的测试行,而不是辅助函数内部:
func mustReadFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %q: %v", path, err)
}
return data
}测试辅助函数通常接收 testing.TB,这样既能用于测试,也能用于基准:
func mustTempFile(tb testing.TB, content []byte) string {
tb.Helper()
path := filepath.Join(tb.TempDir(), "input.txt")
if err := os.WriteFile(path, content, 0o600); err != nil {
tb.Fatal(err)
}
return path
}testing.TB 是 *testing.T、*testing.B 等类型共同实现的接口。
6. 测试包应该放在包内还是包外
测试可以使用两种 package:
package money // 白盒测试
package money_test // 黑盒测试包内测试能访问未导出标识符,适合验证很难从公共 API 观察到的内部算法。包外测试只能使用导出的 API,更接近真实调用者,也能发现包的 API 是否难用、是否存在导入循环。
实际项目可以混用:
money/
├── money.go
├── money_internal_test.go // package money
└── money_test.go // package money_test优先从公共行为测试。只有当内部算法复杂、公共路径难以覆盖故障定位,或确实需要验证不变量时,再写包内测试。测试紧贴实现细节会让正常重构产生大量无意义修改。
7. 依赖注入与测试替身
Go 没有强制使用 mock 框架。小接口、函数值和普通 struct 往往已经足够。
7.1 在使用方定义小接口
type UserStore interface {
FindByID(ctx context.Context, id int64) (User, error)
}
type Service struct {
store UserStore
}手写 stub:
type storeStub struct {
find func(context.Context, int64) (User, error)
}
func (s storeStub) FindByID(ctx context.Context, id int64) (User, error) {
return s.find(ctx, id)
}测试:
func TestServiceProfile(t *testing.T) {
store := storeStub{
find: func(_ context.Context, id int64) (User, error) {
if id != 7 {
t.Fatalf("id = %d, want 7", id)
}
return User{ID: id, Name: "Alice"}, nil
},
}
svc := Service{store: store}
got, err := svc.Profile(context.Background(), 7)
if err != nil {
t.Fatal(err)
}
if got.Name != "Alice" {
t.Errorf("Name = %q, want Alice", got.Name)
}
}接口应由需要它的代码定义,而不是由基础设施包为了“方便 mock”提前定义一个包含几十个方法的大接口。
7.2 函数依赖
只有一个操作时,函数值更直接:
type Clock func() time.Time
type TokenIssuer struct {
now Clock
}生产环境传 time.Now,测试传固定函数。这个设计比修改全局时钟安全,也允许并行测试。
7.3 fake、stub 与 mock
- stub:按预设返回结果;
- fake:有简化但可工作的实现,例如内存仓库;
- mock:不仅返回结果,还验证调用次数、顺序和参数;
- spy:记录调用,测试结束后检查。
不要把所有替身都叫 mock,更不要对每次函数调用都做严格顺序验证。过度交互验证会把测试绑定到实现步骤,而不是业务结果。
8. HTTP、文件、环境变量与时间
8.1 HTTP handler
httptest.NewRecorder 可以直接测试 handler,不需要真实监听端口:
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
}8.2 HTTP client
httptest.NewServer 创建一个只在测试期间存在的本地服务器:
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"name":"Alice"}`)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, server.Client())代码应允许注入 *http.Client。直接使用 http.DefaultClient 会让超时、transport 和测试隔离都难以控制。
8.3 临时目录
dir := t.TempDir()
path := filepath.Join(dir, "config.json")测试结束后目录会自动清理。不要把临时文件写到源码目录,也不要用固定的 /tmp/test.txt;并行测试和 CI 很容易相互覆盖。
8.4 环境变量与工作目录
t.Setenv("APP_ENV", "test")测试结束后环境变量自动恢复。因为环境变量是进程级共享状态,调用 Setenv 的测试不能与其他可能观察该变量的测试并行。
工作目录可以通过 os.Chdir 修改,但同样属于进程级状态。更稳妥的设计是把根目录作为参数传给业务代码。
8.5 时间
不要用长时间 time.Sleep 等待并发结果:
select {
case got := <-result:
// 断言 got
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for result")
}超时是最后一道防死锁保护,不是同步机制。更好的被测 API 应暴露可等待的结果或接收 context.Context。
需要控制当前时间时注入函数或小接口:
type Clock interface {
Now() time.Time
}不要在业务包里用可变全局变量替换 time.Now,否则并行测试和真实并发调用都会产生竞态。
9. 清理资源与 Go 1.26 测试产物
9.1 t.Cleanup
db := openTestDB(t)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close database: %v", err)
}
})清理函数按后进先出顺序执行,即使测试调用了 Fatal 也会运行。构造资源的辅助函数应该顺手注册清理,这样调用方不容易忘记。
defer 和 Cleanup 都能释放资源,区别是:
defer绑定当前辅助函数的返回;t.Cleanup绑定整个测试或子测试的结束。
辅助函数创建并返回资源时,通常使用 Cleanup。
9.2 ArtifactDir
Go 1.26 为 T、B 和 F 增加了 ArtifactDir。测试可以把失败截图、协议转储或性能结果写进去:
func TestRender(t *testing.T) {
got := renderPage()
if !valid(got) {
path := filepath.Join(t.ArtifactDir(), "rendered.html")
if err := os.WriteFile(path, got, 0o600); err != nil {
t.Fatal(err)
}
t.Fatalf("rendered output is invalid; artifact: %s", path)
}
}默认情况下产物位于测试临时目录并在结束后删除。传入 -artifacts 后,产物会保留在 -outputdir 指定的目录(未指定时是当前目录):
go test -artifacts -outputdir ./test-artifacts ./...产物目录适合保存诊断文件,不应该成为测试间交换数据的隐式通道。
10. 比较复杂结果
简单可比较值直接使用 ==。slice、map 或包含它们的 struct 不能直接比较。
10.1 手工比较
业务对象字段较少时,手工比较最清楚:
if got.ID != want.ID || got.Name != want.Name {
t.Errorf("user = %#v, want %#v", got, want)
}10.2 标准库辅助
if !slices.Equal(gotIDs, wantIDs) {
t.Errorf("ids = %v, want %v", gotIDs, wantIDs)
}
if !maps.Equal(gotCounts, wantCounts) {
t.Errorf("counts = %v, want %v", gotCounts, wantCounts)
}10.3 reflect.DeepEqual
reflect.DeepEqual 可用于通用深比较,但语义不一定符合业务直觉。例如 nil slice 和空 slice 不相等,函数值除了都为 nil 外不相等。它只告诉你“不相等”,不会生成好读的差异。
第三方 go-cmp 在大型项目中很常见,因为它能输出结构化 diff,并允许配置忽略字段或自定义比较。引入前仍要明确比较语义,不能把工具默认行为当业务契约。
Golden file 适合大段稳定输出:
golden := filepath.Join("testdata", "invoice.golden")
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if diff := bytes.Compare(got, want); diff != 0 {
t.Fatalf("invoice differs from %s", golden)
}更新 golden 文件应使用显式参数,例如 -update,不能在普通测试失败时自动覆盖期望结果。
11. 错误、panic 与并发测试
11.1 错误
不要比较错误字符串:
if !errors.Is(err, ErrInvalidRate) {
t.Fatalf("error = %v, want ErrInvalidRate", err)
}需要检查结构化字段时使用 errors.As:
var parseErr *ParseError
if !errors.As(err, &parseErr) {
t.Fatalf("error type = %T, want *ParseError", err)
}
if parseErr.Offset != 4 {
t.Errorf("Offset = %d, want 4", parseErr.Offset)
}11.2 panic
只有 API 契约明确承诺 panic 时才测试 panic。业务输入错误一般应该返回 error:
func assertPanics(t *testing.T, f func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
f()
}recover 只能捕获同一个 goroutine 中正在展开的 panic。被测 goroutine 的 panic 不会被测试 goroutine 的 recover 捕获。
11.3 并发和竞态
go test -race ./...竞态检测器只发现实际执行路径上的竞态,因此它不能替代覆盖足够场景的并发测试。必要时用真实压力运行带 -race 构建的程序。
并发测试应验证可观察的不变量,不要依赖 goroutine 的具体调度顺序:
func TestCounterConcurrent(t *testing.T) {
var c Counter
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
for range 1_000 {
c.Inc()
}
})
}
wg.Wait()
if got, want := c.Value(), int64(100_000); got != want {
t.Fatalf("Value() = %d, want %d", got, want)
}
}如果 module 需要兼容没有 WaitGroup.Go 的旧 Go 版本,就使用 Add、go、defer Done 的传统写法。
12. 覆盖率
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out覆盖率说明哪些语句被执行过,不说明断言是否正确,也不说明边界、并发时序和失败路径是否经过充分验证。高覆盖率可以由“调用了所有代码但没有有效断言”的测试制造出来。
合理用法:
- 找到长期没被执行的分支;
- 观察关键包覆盖率是否意外下降;
- 帮助审查新增代码是否缺少错误路径测试;
- 不把全仓库单一百分比当作质量目标。
跨 package 统计:
go test -coverpkg=./... -coverprofile=coverage.out ./...集成测试可以使用 Go 1.20 起提供的二进制覆盖率:
go build -cover -o ./bin/app ./cmd/app
GOCOVERDIR=./coverage-data ./bin/app
go tool covdata percent -i=./coverage-data
go tool covdata textfmt -i=./coverage-data -o=integration.out13. 模糊测试
模糊测试通过覆盖率引导不断变异输入,特别适合解析器、编解码器、协议、压缩、校验和安全边界。
func FuzzRoundTrip(f *testing.F) {
f.Add("hello")
f.Add("中文")
f.Add("")
f.Fuzz(func(t *testing.T, input string) {
encoded := Encode(input)
decoded, err := Decode(encoded)
if err != nil {
t.Fatalf("Decode(Encode(%q)): %v", input, err)
}
if decoded != input {
t.Fatalf("round trip = %q, want %q", decoded, input)
}
})
}普通 go test 会运行 f.Add 添加的种子和已保存的回归语料。真正开始变异:
go test -fuzz=FuzzRoundTrip -fuzztime=30s发现失败后,输入会写到 testdata/fuzz/FuzzRoundTrip,以后普通测试也会复现它。确认失败代表真实 bug 后,应把语料提交到仓库。
好的 fuzz 不变量包括:
- 不应 panic;
- 编码后解码得到原值;
- 规范化操作具有幂等性;
- 两个独立实现结果一致;
- 输出长度、权限或资源消耗不超过上限。
模糊目标必须足够快且结果确定。不要默认访问网络、依赖当前时间或创建无限量资源。攻击者可控长度的输入要先限制,否则测试可能只是在反复触发内存耗尽。
14. 基准测试
传统基准:
func BenchmarkEncode(b *testing.B) {
input := strings.Repeat("go", 128)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = Encode(input)
}
}Go 1.24 起推荐 B.Loop:
func BenchmarkEncode(b *testing.B) {
input := strings.Repeat("go", 128)
b.ReportAllocs()
for b.Loop() {
_ = Encode(input)
}
}Go 1.26 修正了 B.Loop 循环体阻止内联的问题,通常可以从 b.N 迁移到 b.Loop。运行:
go test -bench=. -benchmem ./...
go test -run='^$' -bench=BenchmarkEncode -count=10 > old.txt基准结果常见字段:
BenchmarkEncode-12 820000 1420 ns/op 512 B/op 2 allocs/op含义分别是基准名及 GOMAXPROCS、迭代次数、每次耗时、每次分配字节数、每次分配次数。
子基准:
for _, size := range []int{16, 256, 4096} {
b.Run(strconv.Itoa(size), func(b *testing.B) {
input := make([]byte, size)
for b.Loop() {
_ = Hash(input)
}
})
}基准必须防止测到无关工作:
- 输入构造放到计时循环外;
- 必要时使用
b.ResetTimer、b.StopTimer、b.StartTimer; - I/O 基准设置
b.SetBytes; - 控制 CPU 频率、后台负载和编译器版本;
- 多次运行并用统计工具比较,不凭一次结果下结论;
- 避免让编译器把整个计算优化掉。
15. 示例测试与文档
Example 既是测试,也是 go doc 和 pkg.go.dev 可展示的可执行示例:
func ExampleApplyDiscount() {
got, err := ApplyDiscount(10_000, 0.2)
fmt.Println(got, err)
// Output:
// 8000 <nil>
}输出注释是断言。无序输出使用:
// Unordered output:
// apple
// banana命名规则:
func Example() {}
func ExampleApplyDiscount() {}
func ExampleApplyDiscount_zeroRate() {}示例应短小、确定,不依赖网络或当前时间。它的首要读者是人,复杂场景仍应放到普通测试中。
16. 集成测试、构建标签与 TestMain
16.1 构建标签
//go:build integration
package repository_test运行:
go test -tags=integration ./...标签适合明确区分需要数据库、容器或外部服务的测试。不要为了让失败测试消失而随意加标签。
16.2 TestMain
func TestMain(m *testing.M) {
code := m.Run()
os.Exit(code)
}TestMain 适合 package 级、确实无法按测试隔离的启动工作。它只有一个 *testing.M,没有 *testing.T,失败诊断和清理不如普通测试方便。能用辅助函数和 t.Cleanup 完成时,不要升级成 TestMain。
16.3 外部系统
集成测试应做到:
- 连接信息显式配置;
- 每次运行使用独立 schema、数据库名或租户;
- 数据准备可重放;
- 测试结束清理;
- 设置总超时;
- 失败时保留必要日志和产物;
- 不把开发者本机已有服务当成隐式前提。
17. 测试缓存、随机性与不稳定测试
成功的 package 测试可能被缓存:
ok example.com/app/user (cached)缓存是正常能力。需要确认重复运行时使用:
go test -count=1 ./...测试不应依赖 map 遍历顺序、goroutine 调度、当前日期、随机端口之外的固定端口,或另一个测试留下的数据。
随机测试应记录种子:
seed := time.Now().UnixNano()
t.Logf("seed=%d", seed)
rng := rand.New(rand.NewSource(seed))如果随机输入对发现边界很重要,优先考虑 fuzz;它能保存最小化后的失败输入,比只打印随机种子更适合长期回归。
排查不稳定测试:
go test -run TestName -count=100
go test -race -run TestName -count=20
go test -shuffle=on -count=20 ./...-shuffle 能暴露测试间顺序依赖,并在输出中打印可复现种子。
18. CI 中的测试分层
一条实用流水线可以分为:
- 格式与静态检查:
gofmt、go vet; - 快速单元测试:
go test ./...; - 竞态测试:
go test -race ./...; - 集成测试:
go test -tags=integration ./...; - 覆盖率报告;
- 定时 fuzz 和基准回归。
每次提交都跑分钟级 fuzz 通常不现实。可以在 PR 上运行种子语料,在夜间任务中给 fuzz 固定时间预算。基准也更适合固定硬件的专用 runner,否则共享虚拟机噪声会淹没真实变化。
测试命令应该设置总超时:
go test -timeout=5m ./...超时不能替代单个外部调用的 context deadline;它只是避免整条 CI 永远挂住。
19. 与 JUnit 的差异
| JUnit 习惯 | Go 对应方式 |
|---|---|
@Test | func TestXxx(t *testing.T) |
| 参数化测试 | 表驱动测试 + t.Run |
@BeforeEach | 普通辅助函数 |
@AfterEach | t.Cleanup |
@BeforeAll | 谨慎使用 TestMain,或父测试分组 |
| assertion library | if + t.Errorf,也可选第三方库 |
| Mockito | 小接口、函数值、手写 fake/stub |
| JMH | testing.B |
| property testing | 原生 fuzz |
Go 没有要求把测试组织成 class。共享状态通常不是方便,而是隔离风险;把准备过程写成返回明确依赖的普通函数,测试会更容易并行和复用。
20. 常见误区
只测 happy path
至少考虑零值、边界值、非法输入、依赖失败、取消、超时和并发访问。
比较完整错误字符串
错误包装后字符串会变化。使用 errors.Is、errors.As 和结构化字段。
用 time.Sleep 猜异步任务完成
快机器浪费时间,慢机器偶发失败。使用 channel、WaitGroup、context 和最终超时。
测试私有实现步骤
测试每个内部函数调用次数会阻碍重构。优先断言公共行为和外部可观察副作用。
为覆盖率写无断言测试
执行到不代表验证过。覆盖率是地图,不是质量成绩。
随手 t.Parallel
全局变量、环境、工作目录、默认 HTTP transport、数据库和固定端口都会让并行测试互相污染。
基准只跑一次
纳秒级差异很容易是噪声。固定环境、多次采样、使用统计比较,并同时观察分配。
在单元测试访问真实公网
公网延迟、限流、数据变化和证书都不受控制。使用本地 fake server;把真实联调放到独立集成测试。
21. 命令速查
| 目标 | 命令 |
|---|---|
| 当前 package | go test |
| 所有 package | go test ./... |
| 详细输出 | go test -v ./... |
| 指定测试 | go test -run 'TestName/subcase' |
| 禁用成功缓存 | go test -count=1 ./... |
| 随机顺序 | go test -shuffle=on ./... |
| 竞态检测 | go test -race ./... |
| 覆盖率 | go test -cover ./... |
| 覆盖率文件 | go test -coverprofile=coverage.out ./... |
| 基准 | go test -bench=. -benchmem ./... |
| 模糊测试 | go test -fuzz=FuzzName -fuzztime=30s |
| 集成标签 | go test -tags=integration ./... |
| 保留产物 | go test -artifacts -outputdir ./test-artifacts ./... |
| 总超时 | go test -timeout=5m ./... |
可运行示例
测试代码首先得能编译,并且每次都能稳定退出。下面三个目录都可以直接交给 go test 执行,不依赖公网、固定端口或调用顺序。
示例一:表驱动测试与子测试
第一个例子用一套测试流程覆盖整数除法的正常值、截断、负数和错误输入。任何一组失败时,报告都应该直接指出对应的数据,而不是只留下一个模糊的函数名。
被测代码:
// Package calc 提供可独立测试的小型计算函数。
package calc
import "fmt"
// Divide 执行整数除法。除数为零是调用者可以处理的输入错误,因此返回 error,
// 而不是 panic。调用者若不需要商,可以忽略第一个返回值。
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("除数不能为零")
}
return a / b, nil
}测试代码:
package calc
import "testing"
func TestDivide(t *testing.T) {
tests := []struct {
name string
a int
b int
want int
wantErr bool
}{
// 每一行只描述输入和期望;增加边界条件时不必复制测试流程。
{name: "整除", a: 12, b: 3, want: 4},
{name: "整数截断", a: 7, b: 2, want: 3},
{name: "负数", a: -12, b: 3, want: -4},
{name: "除数为零", a: 8, b: 0, wantErr: true},
}
for _, tt := range tests {
tt := tt // 明确创建本轮副本,便于将来安全地加入 t.Parallel。
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Fatalf("Divide(%d, %d) error = %v, wantErr = %v",
tt.a, tt.b, err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("Divide(%d, %d) = %d, want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}运行:
go test ./examples/ch17/table-driven -v预期结果的关键部分:
=== RUN TestDivide
=== RUN TestDivide/整除
=== RUN TestDivide/整数截断
=== RUN TestDivide/负数
=== RUN TestDivide/除数为零
--- PASS: TestDivide
PASS拆解:
- 表中只保存变化的输入与期望,公共的调用和断言只写一次。
t.Run为每行数据创建具名子测试,可用-run 'TestDivide/除数为零'精确执行。- 先判断错误状态,再检查正常结果,避免错误路径上的零值造成误判。
tt := tt让循环变量成为本轮副本。现代 Go 已改进 range 变量语义,但显式副本能清楚表达未来加入t.Parallel后每个闭包应持有独立用例。
修改实验:增加 a=1, b=-2 和极值用例;随后在子测试里加入 t.Parallel(),运行 go test -race 验证没有共享状态竞态。
示例二:用 httptest 测试 HTTP 协议
HTTP 测试还要同时验证路由、状态码、响应头和 JSON,但没有必要为此依赖一套外部服务。第二个例子使用本地测试服务器,把这条链路留在测试进程内。
处理器:
// Package greeting 提供一个很小的 HTTP Handler,便于演示端到端协议测试。
package greeting
import (
"encoding/json"
"net/http"
"strings"
)
type response struct {
Message string `json:"message"`
}
// Handler 返回 http.Handler,调用方不需要绑定真实端口即可测试。
func Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" {
http.Error(w, "缺少 name", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
// 对内存中的小响应直接编码即可;真实服务还应统一记录编码失败。
_ = json.NewEncoder(w).Encode(response{Message: "你好," + name})
})
return mux
}测试:
package greeting
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHello(t *testing.T) {
// NewServer 绑定本机随机端口,测试覆盖路由、HTTP 状态、响应头和 JSON,
// 但不依赖公网,也不需要手工启动后台服务。
server := httptest.NewServer(Handler())
t.Cleanup(server.Close)
resp, err := server.Client().Get(server.URL + "/hello?name=Go")
if err != nil {
t.Fatalf("请求测试服务: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
var body response
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("解析响应: %v", err)
}
if body.Message != "你好,Go" {
t.Errorf("message = %q, want %q", body.Message, "你好,Go")
}
}
func TestHelloRejectsMissingName(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
recorder := httptest.NewRecorder()
// Recorder 适合只测 Handler 的快速单元测试,不经过真实 TCP 连接。
Handler().ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
}运行:
go test ./examples/ch17/http-test -v预期结果:
=== RUN TestHello
--- PASS: TestHello
=== RUN TestHelloRejectsMissingName
--- PASS: TestHelloRejectsMissingName
PASS拆解:
httptest.NewServer经过真实 HTTP 客户端和本地 TCP 栈,适合覆盖协议集成;NewRecorder更轻,适合只验证 Handler。server.Client()已配置为信任测试服务器。测试 TLS 服务时可换成httptest.NewTLSServer,不要关闭证书校验来“解决”测试问题。- 响应体必须关闭,否则大量测试可能耗尽连接;JSON 应解码后按字段比较,不要依赖空格或字段顺序。
- 服务绑定随机回环端口,因此可以并行运行,也不会依赖公网。
修改实验:添加 POST /hello 用例并期待 405;再把服务改为 TLS,比较 server.Client() 与默认客户端的行为。
示例三:Fuzz 种子与性质测试
反转 Unicode 字符串时,有限的手写示例很难覆盖所有字符组合。这里不再枚举每一个期望值,而是让 Fuzz 测试验证“反转两次恢复原值”等不变量。
被测代码:
// Package text 提供按 Unicode 字符反转文本的函数。
package text
// Reverse 按 rune 而不是 byte 反转,避免破坏中文和 emoji 的 UTF-8 编码。
func Reverse(s string) string {
runes := []rune(s)
for left, right := 0, len(runes)-1; left < right; left, right = left+1, right-1 {
runes[left], runes[right] = runes[right], runes[left]
}
return string(runes)
}Fuzz 与基准:
package text
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
// 种子语料应覆盖空串、ASCII、多字节字符和组合场景。
// 普通 go test 会执行这些种子;-fuzz 才会在此基础上持续变异输入。
for _, seed := range []string{"", "Go", "你好", "Go语言🙂"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input string) {
reversed := Reverse(input)
// 性质 1:反转不应制造无效 UTF-8。随机字节串可能本来就无效,
// string 转 []rune 会把无效序列替换成 RuneError,所以只约束有效输入。
if utf8.ValidString(input) && !utf8.ValidString(reversed) {
t.Fatalf("Reverse(%q) 产生无效 UTF-8", input)
}
// 性质 2:对有效 UTF-8 字符串反转两次应恢复原值。
// 这比枚举几个期望字符串更适合发现算法的普遍性错误。
if utf8.ValidString(input) {
if got := Reverse(reversed); got != input {
t.Fatalf("Reverse(Reverse(%q)) = %q", input, got)
}
}
})
}
func BenchmarkReverse(b *testing.B) {
input := "Go 泛型、并发与工程化🙂"
for b.Loop() {
_ = Reverse(input)
}
}先执行固定种子:
go test ./examples/ch17/fuzz-reverse -v再进行一秒变异探索:
go test ./examples/ch17/fuzz-reverse -fuzz=FuzzReverse -fuzztime=1s两条命令都应以 PASS 结束。还可以单独运行基准:
go test ./examples/ch17/fuzz-reverse -run='^$' -bench=BenchmarkReverse -benchmem拆解:
- 种子覆盖空串、ASCII、中文和 emoji;普通
go test会把它们作为回归用例执行。 - Fuzzer 生成的字符串可以包含无效 UTF-8,所以性质只对有效输入断言“双重反转等于原值”。
- 发现崩溃时,Go 会把输入写入
testdata/fuzz;应先修复再把该输入作为永久回归语料提交。 - 基准的耗时和分配数字依赖机器,不应写死为测试阈值;它用于对比修改前后,而不是证明绝对性能。
修改实验:故意把 Reverse 改成按字节交换并运行 Fuzz,观察中文种子如何快速暴露错误;修复后用 -race 再验证一次。
22. 练习
- 为一个金额解析函数编写表驱动测试,覆盖空字符串、负数、小数位过多、最大值和非法字符。
- 为 HTTP client 使用
httptest.NewServer模拟 200、429、500、慢响应和损坏 JSON。 - 给一个并发计数器写测试,并分别用互斥锁版和错误版运行
go test -race。 - 为 URL 规范化函数写 round-trip 或幂等性 fuzz test。
- 为两种 JSON 编码方式写
B.Loop基准,同时报告吞吐量和分配。 - 使用
-shuffle找出一个依赖测试执行顺序的故意错误示例,再把共享状态改成测试内依赖。