Skip to content

Go 测试:让一次失败告诉你该改哪里 ​

一个测试只说“失败了”,你仍得从头调试;一个测试说“零件单价 250、数量 2,报价却不是 500”,问题范围就小得多。本文用一个报价服务把表驱动测试、fake、HTTP 测试、基准、模糊测试和覆盖率接起来,重点是每种测试回答什么问题,以及它没覆盖什么。

示例在 Go 1.27.2、Linux/amd64 上运行。它有真实的 HTTP 网络测试,但价格来源是手写 fake,没有启动数据库。读到“集成测试”时要看清这个边界。

先把要验证的东西拆成三层 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    T["纯函数测试"] --> P["Total:单价 × 数量<br/>验证范围与溢出"]
    S["Service 测试"] --> Q["Quote:查价再计算"]
    Q --> F["手写 Prices fake"]
    R["Recorder 测试"] --> H["Handler:解析请求与响应"]
    H --> Q
    N["HTTP 集成测试<br/>真实 TCP client/server"] --> H
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    T["纯函数测试"] --> P["Total:单价 × 数量<br/>验证范围与溢出"]
    S["Service 测试"] --> Q["Quote:查价再计算"]
    Q --> F["手写 Prices fake"]
    R["Recorder 测试"] --> H["Handler:解析请求与响应"]
    H --> Q
    N["HTTP 集成测试<br/>真实 TCP client/server"] --> H

图里的两个 HTTP 入口共享 handler,但前者不经过真实网络,后者会。实际数据库依赖没有出现在图里,也没有被这套测试验证。

想检查什么入口成本与盲区
业务规则和边界值TestXxx、t.Run快且容易定位;前提是预期本身写对
出错路径与依赖交互小接口 + fake可控;fake 行为可能与真实系统不同
HTTP 状态、响应内容httptest.NewRecorder、NewRequest不需监听端口;看不到真实连接行为
实际 HTTP client/server 协作httptest.NewServer多一层网络与生命周期成本;仍不等于生产部署
性能和分配BenchmarkXxx、b.Loop结果依赖输入、机器与测量设计
自动探索更多输入FuzzXxx、f.Fuzz需要有效性质和资源限制,不是穷举证明
哪些语句被执行-coverprofile、go tool cover找缺口,不判断断言是否充分

接口与执行约定来自 testing、httptest、fuzzing 文档。这些入口可以组合,但没有固定比例适合所有服务。

表驱动测试:把行为契约写成能读的用例 ​

被测函数先拒绝非法数量和单价,再检查乘法是否会超出 int64:

go
if qty <= 0 || qty > 100 || unit < 0 {
    return 0, errors.New("invalid quantity or price")
}
if unit > math.MaxInt64/int64(qty) {
    return 0, errors.New("total overflow")
}
return unit * int64(qty), nil

先检查数量大于零很重要,否则溢出检查中的除法会遇到零除。把乘法执行完再看结果也不可靠:Go 的有符号整数溢出并不会自动为你报业务错误。整数运算

测试表明确列出输入、预期结果和是否应该报错:

go
{"two_items", 250, 2, 500, false},
{"free", 0, 1, 0, false},
{"zero_quantity", 250, 0, 0, true},
{"negative_price", -1, 1, 0, true},
{"too_many", 1, 101, 0, true},
{"overflow", math.MaxInt64, 2, 0, true},

每行交给 t.Run(tc.name, ...),实际输出包含:

text
--- PASS: TestTotal/two_items (0.00s)
--- PASS: TestTotal/zero_quantity (0.00s)
--- PASS: TestTotal/overflow (0.00s)

失败时名称能直接指出哪条规则;只重跑数量用例可用 go test -run='TestTotal/zero_quantity'。子测试名称和斜线匹配规则见 子测试说明。比起把所有输入塞进一个长函数,这样更容易添加回归用例,但不是所有测试都必须套成表:复杂流程有不同准备步骤时,独立测试反而更清楚。

Go 1.22 起,对由循环声明的新变量,每次迭代有独立变量语义;本例 go.mod 的语言版本已满足,不需要为了旧版本习惯再写一遍 tc := tc。若维护较旧语言版本的模块,要按其实际语义判断。Go 1.22 循环变量变化

Helper 让报错回到调用处,Cleanup 让资源跟测试范围一起结束 ​

go
func equalTotal(t *testing.T, got, want int64) {
    t.Helper()
    if got != want {
        t.Fatalf("total=%d want=%d", got, want)
    }
}

t.Helper() 标记辅助函数,报告文件位置时跳过它。附录故意调用 equalTotal(t, 500, 999),得到:

text
failure_test.go:8: total=500 want=999

位置指向测试调用点,读者不用先跳进通用断言函数。Fatal 会终止当前测试 goroutine;不能从另一个工作 goroutine 里随便调用它,后台错误应传回测试主流程。T.Helper、T.Fatal

t.Cleanup 在该测试及其子测试完成后执行,多个清理按后登记先执行。本例在子测试中登记 first、second,父测试收到的结果是:

text
cleanup=[second first]

这适合服务器、临时资源等需要活到子测试结束的对象。defer 随当前函数返回执行,两者的生命周期不同,尤其是在使用并行子测试时。T.Cleanup

隔离不只靠 cleanup:文件用 t.TempDir();修改环境变量用 t.Setenv() 并遵守它对并行测试的限制;时钟和随机数需要时注入,让测试决定输入,而非靠 time.Sleep 猜时序。后两条是本文的确定性测试建议,标准库会为 TempDir 与 Setenv 注册恢复工作。T.TempDir、T.Setenv

fake:只替换这次测试不打算验证的边界 ​

业务只需要按 SKU 查单价,于是消费方定义:

go
type Prices interface {
    UnitPrice(context.Context, string) (int64, error)
}

fake 返回指定价格、记录收到的 SKU,也可以返回指定错误。测试因此能同时检查:报价为 500;查询的确是 pen;查价失败经过包装后仍能用 errors.Is 找回原错误。

这里先选手写 fake,是因为接口只有一个方法,行为几行就能说清。不要为了“可 mock”让所有结构体都实现一份同名大接口;官方审查惯例也建议接口通常由消费方定义,而不是实现方提前猜用途。接口惯例

替身在本场景能做什么应警惕什么
stub固定返回单价或错误不记录是否查错了 SKU
fake保存少量状态,模拟需要的行为模拟语义可能漂移,需要真实依赖的契约测试
mock声明预期调用、参数、次数或顺序过度绑定实现细节,重构也引发无意义失败

这些术语在不同库中用法并不完全统一;这里采用 Martin Fowler 对 Test Double 的区分。选择重点是你要证实的行为,而不是某个框架的名称。并发测试共享 fake 时,其状态同样需要同步;本例的 fake 每个测试单独创建,没有跨并发用例复用。

httptest:同一个 handler,验证两种边界 ​

第一种直接调用 handler:

go
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/quote?sku=pen&qty=2", nil)
Handler(NewService(&priceFake{unit: 250})).ServeHTTP(rec, req)

实际测试日志:

text
status=200 body="total=500\n"

ResponseRecorder 记录写入状态、头和响应体,没有真正打开 TCP 监听端口。它很适合解析和响应逻辑,却不能检查网络连接复用、代理、TLS 握手或生产路由配置。ResponseRecorder

第二种用 httptest.NewServer 启动测试服务器,srv.Client().Get(...) 真正发送请求。附录将它放在 //go:build integration 的文件中,执行 go test -tags=integration -v ./... 后得到:

text
network status=200 body="total=500\n"

t.Cleanup(srv.Close) 管服务器,响应体由请求调用方关闭。integration 只是本项目选择的构建标签,Go 没有内建“凡是这个标签就自动部署环境”的魔法。NewServer、构建约束

若要验证真实 PostgreSQL 事务、约束或驱动错误,应再做真正连接该数据库的测试,准备独立数据、明确就绪条件并清理。现有 HTTP 集成测试仍使用 fake 价格库,不能替代它。所谓测试金字塔在这里是控制成本的经验:多用便宜而明确的规则测试,再用必要的边界测试兜住替身无法代表的部分,不是照搬一个数量比例。

覆盖率:先看哪一块没走到 ​

实际执行:

bash
go test -count=1 -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
函数本次语句覆盖率
Total100.0%
NewService100.0%
Quote100.0%
Handler60.0%
合计80.0%

handler 的非法数量与报价错误响应还没有被当前测试走到,这给出了很具体的补测方向。另一方面,Total 的 100% 只说明这些语句执行过,不代表所有 int64 输入、错误信息或不变量都正确。测试把错误的预期也写成 490,仍可能得到高覆盖率。覆盖工具原理

不要把门槛设成一个数字就停止审查。更有价值的问题是:每条重要分支有没有合理输入触发?断言是否检查了对使用者重要的结果?错误路径有没有保持数据一致?

基准测试:先说测的是什么,再看 ns/op ​

go
func BenchmarkTotal(b *testing.B) {
    for b.Loop() {
        got, err := Total(250, 2)
        if err != nil { b.Fatal(err) }
        sink = got
    }
}

b.Loop 由 testing 管理迭代和计时,适合当前工具链;旧代码常见的 for i := 0; i < b.N; i++ 仍要认识。本例在循环里只做一次固定输入的算术与校验,没有网络或数据库。B.Loop

-benchtime=100ms -count=3 -benchmem 的三次本机输出为:

text
1.205 ns/op    0 B/op    0 allocs/op
1.179 ns/op    0 B/op    0 allocs/op
1.184 ns/op    0 B/op    0 allocs/op

机器是 AMD EPYC 9V74、Linux/amd64。这是演示指标如何读取,没有对比两个实现,也不能据此估算报价服务吞吐量。ns/op 是每次被测操作耗时,B/op 与 allocs/op 是内存分配指标;输入固定且运算极小,编译器优化和测量噪声都影响数值。正式优化要保留前后版本、输入分布、工具链和环境,多次采样,再用统计工具比较,而不是拿一次最好成绩下结论。testing 基准说明、benchstat

模糊测试:程序生成输入,你负责提供判据 ​

本例给两组 seed,然后让引擎生成更多单价与数量。判据包括合法输入是否成功,以及成功时 total / qty == unit。不能只写“别 panic”,因为一个永远返回 0 的实现也可能不 panic。

bash
go test -run='^$' -fuzz=FuzzTotal -fuzztime=2s -parallel=2

本次实际运行:

text
fuzz: elapsed: 2s, execs: 71229 (35299/sec), new interesting: 2 (total: 4)
PASS

执行次数、速率和新输入数量会变化;普通 go test 只运行 seed 等回归输入,不等于开启持续 fuzz。引擎发现失败后可最小化并保存输入,修复后应把它留作回归。Go fuzzing

性质本身也要审查:附录的合法性判定与实现共享部分规则,可能共同犯同一种错误,所以表中的明确业务例子仍有价值。对于解析器、解压器等,还要限制输入长度、内存和时间;-fuzztime 限制整个探索时间,不会神奇地阻止单个输入申请海量内存。

把反馈放进日常开发 ​

本文建议本地先跑失败相关子测试,提交前跑 go test ./... 与 go vet ./...,在支持的环境跑 go test -race;外部依赖集成测试按成本安排到合适的 CI 阶段。race 只能发现实际执行路径中观察到的数据竞争,不能证明所有调度都安全。race 检测器

有缓存疑问时用 -count=1 强制重跑;并行执行前先确认全局变量、环境变量、端口和共享 fake 是否隔离。一次失败应该留下输入、预期、实际值与资源上下文,维护者才有机会从测试直接走到修复。

依据与本次范围 ​

主要契约来自 testing、httptest、go test、fuzzing、race。同时读取 Go 1.27.2 的本地文档。

2026-10-13 实际运行表驱动、子测试、fake、Recorder、真实 HTTP、Cleanup 顺序、race、vet、覆盖率、三次基准、两秒 fuzz 和 Helper 故意失败。没有进行数据库测试、TLS/代理测试、性能优劣统计比较。演示 HTTP 代码将报价错误统一映射到 400,是简化边界,不是生产系统对存储故障的推荐状态码。

完整实验 ​

按以下路径保存,使用 Go 1.27.2,设置 GOTOOLCHAIN=local、GOFLAGS=-buildvcs=false,执行 bash run_testing.sh。race 需要支持的平台和 C 工具链;覆盖文件由命令生成;故意失败只在专门标签下启用。

run_testing.sh ​

bash
#!/usr/bin/env bash
set -eu
cd "$(dirname "$0")/testing"
echo '== unit, fake, recorder, cleanup =='
go test -count=1 -v ./...
echo '== real HTTP boundary, fake storage =='
go test -count=1 -tags=integration -v ./...
echo '== race and vet =='
go test -count=1 -race -tags=integration ./...
go vet -tags=integration ./...
echo '== coverage =='
go test -count=1 -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
echo '== benchmark: three local observations, not a comparison =='
go test -run='^$' -bench=BenchmarkTotal -benchmem -benchtime=100ms -count=3
echo '== bounded fuzz =='
go test -run='^$' -fuzz=FuzzTotal -fuzztime=2s -parallel=2
echo '== helper callsite: expected failure =='
if go test -tags=demonstratefailure -run=TestHelperLocation -count=1; then exit 1; else echo expected_failure=true; fi

testing/failure_test.go ​

go
//go:build demonstratefailure

package quote

import "testing"

func TestHelperLocation(t *testing.T) {
	equalTotal(t, 500, 999)
}

testing/go.mod ​

go.mod
module example.com/v2/quotetest

go 1.27.2

testing/integration_test.go ​

go
//go:build integration

package quote

import (
	"io"
	"net/http/httptest"
	"testing"
)

func TestHTTPIntegration(t *testing.T) {
	srv := httptest.NewServer(Handler(NewService(&priceFake{unit: 250})))
	t.Cleanup(srv.Close)
	resp, err := srv.Client().Get(srv.URL + "/quote?sku=pen&qty=2")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != 200 || string(body) != "total=500\n" {
		t.Fatalf("status=%d body=%q", resp.StatusCode, body)
	}
	t.Logf("network status=%d body=%q", resp.StatusCode, body)
}

testing/quote.go ​

代码块 · 49 行 · 点击收起 / 展开
go
package quote

import (
	"context"
	"errors"
	"fmt"
	"math"
	"net/http"
	"strconv"
)

func Total(unit int64, qty int) (int64, error) {
	if qty <= 0 || qty > 100 || unit < 0 {
		return 0, errors.New("invalid quantity or price")
	}
	if unit > math.MaxInt64/int64(qty) {
		return 0, errors.New("total overflow")
	}
	return unit * int64(qty), nil
}

type Prices interface {
	UnitPrice(context.Context, string) (int64, error)
}
type Service struct{ prices Prices }

func NewService(p Prices) *Service { return &Service{prices: p} }
func (s *Service) Quote(ctx context.Context, sku string, qty int) (int64, error) {
	unit, err := s.prices.UnitPrice(ctx, sku)
	if err != nil {
		return 0, fmt.Errorf("lookup price: %w", err)
	}
	return Total(unit, qty)
}
func Handler(s *Service) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		qty, err := strconv.Atoi(r.URL.Query().Get("qty"))
		if err != nil {
			http.Error(w, "invalid quantity", http.StatusBadRequest)
			return
		}
		total, err := s.Quote(r.Context(), r.URL.Query().Get("sku"), qty)
		if err != nil {
			http.Error(w, "quote failed", http.StatusBadRequest)
			return
		}
		fmt.Fprintf(w, "total=%d\n", total)
	})
}

testing/quote_test.go ​

代码块 · 119 行 · 点击收起 / 展开
go
package quote

import (
	"context"
	"errors"
	"math"
	"net/http/httptest"
	"reflect"
	"testing"
)

type priceFake struct {
	unit int64
	err  error
	sku  string
}

func (f *priceFake) UnitPrice(ctx context.Context, sku string) (int64, error) {
	f.sku = sku
	if err := ctx.Err(); err != nil {
		return 0, err
	}
	return f.unit, f.err
}
func equalTotal(t *testing.T, got, want int64) {
	t.Helper()
	if got != want {
		t.Fatalf("total=%d want=%d", got, want)
	}
}
func TestTotal(t *testing.T) {
	cases := []struct {
		name string
		unit int64
		qty  int
		want int64
		bad  bool
	}{
		{"two_items", 250, 2, 500, false},
		{"free", 0, 1, 0, false},
		{"zero_quantity", 250, 0, 0, true},
		{"negative_price", -1, 1, 0, true},
		{"too_many", 1, 101, 0, true},
		{"overflow", math.MaxInt64, 2, 0, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := Total(tc.unit, tc.qty)
			if (err != nil) != tc.bad {
				t.Fatalf("error=%v wantError=%v", err, tc.bad)
			}
			if !tc.bad {
				equalTotal(t, got, tc.want)
			}
		})
	}
}
func TestService(t *testing.T) {
	fake := &priceFake{unit: 250}
	svc := NewService(fake)
	got, err := svc.Quote(t.Context(), "pen", 2)
	if err != nil {
		t.Fatal(err)
	}
	equalTotal(t, got, 500)
	if fake.sku != "pen" {
		t.Fatalf("lookup sku=%q", fake.sku)
	}
	fake.err = errors.New("offline")
	if _, err = svc.Quote(t.Context(), "pen", 2); !errors.Is(err, fake.err) {
		t.Fatal(err)
	}
}
func TestHandler(t *testing.T) {
	rec := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/quote?sku=pen&qty=2", nil)
	Handler(NewService(&priceFake{unit: 250})).ServeHTTP(rec, req)
	if rec.Code != 200 || rec.Body.String() != "total=500\n" {
		t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
	}
	t.Logf("status=%d body=%q", rec.Code, rec.Body.String())
}
func TestCleanupOrder(t *testing.T) {
	var got []string
	t.Run("scope", func(t *testing.T) {
		t.Cleanup(func() { got = append(got, "first") })
		t.Cleanup(func() { got = append(got, "second") })
	})
	if !reflect.DeepEqual(got, []string{"second", "first"}) {
		t.Fatal(got)
	}
	t.Logf("cleanup=%v", got)
}
func FuzzTotal(f *testing.F) {
	f.Add(int64(250), 2)
	f.Add(int64(0), 1)
	f.Fuzz(func(t *testing.T, unit int64, qty int) {
		total, err := Total(unit, qty)
		valid := qty > 0 && qty <= 100 && unit >= 0 && unit <= math.MaxInt64/int64(qty)
		if valid != (err == nil) {
			t.Fatalf("valid=%v error=%v", valid, err)
		}
		if valid && total/int64(qty) != unit {
			t.Fatalf("roundtrip: %d * %d = %d", unit, qty, total)
		}
	})
}

var sink int64

func BenchmarkTotal(b *testing.B) {
	for b.Loop() {
		got, err := Total(250, 2)
		if err != nil {
			b.Fatal(err)
		}
		sink = got
	}
}

保留各书来源与许可 · 示例运行条件以正文为准