Go 测试
Go 内置测试框架,测试文件以 _test.go 结尾,测试函数以 Test 开头。真正的 Go 测试不只是会运行 go test,还要会写表格驱动测试、子测试、HTTP Handler 测试、Fake 依赖、Benchmark、Race 检测和 CI 门禁。
测试的目标不是“追求覆盖率数字”,而是把核心业务规则、边界条件、并发风险和线上 bug 固化成自动化验证,让后续重构、升级依赖、修改需求时不靠手工猜。
学习目标
学完本页你应该能回答:
- Go 测试文件和测试函数如何被发现。
testing.T、t.Run、t.Fatal、t.Helper分别怎么用。- 表格驱动测试为什么是 Go 常见写法。
- 如何测试业务规则、错误分支、HTTP Handler、Repository。
- Fake、Mock、接口隔离分别适合什么场景。
go test -race能发现什么问题。- Benchmark 如何写,结果怎么看。
- CI 中如何把测试作为合并门禁。
Go 测试如何被发现
Go 测试规则:
| 规则 | 说明 |
|---|---|
| 文件名 | 必须以 _test.go 结尾 |
| 测试函数 | func TestXxx(t *testing.T) |
| 基准测试 | func BenchmarkXxx(b *testing.B) |
| 示例测试 | func ExampleXxx() |
| 包名 | 可以是同包,也可以是 xxx_test 外部测试包 |
最小示例:
calculator.go:
package calculator
func Add(a, b int) int {
return a + b
}calculator_test.go:
package calculator
import "testing"
func TestAdd(t *testing.T) {
got := Add(1, 2)
if got != 3 {
t.Fatalf("got %d, want %d", got, 3)
}
}运行:
go test
go test ./...
go test -v ./..../... 表示当前模块下所有包。
测试执行流程
flowchart TD
A["go test ./..."] --> B["发现包"]
B --> C["编译业务代码和 _test.go"]
C --> D["执行 Test 函数"]
D --> E["执行 Benchmark 或 Example 可选"]
E --> F{"是否失败"}
F -- "失败" --> G["返回非 0 退出码"]
F -- "成功" --> H["返回 ok"]Go 测试会先编译,所以很多类型错误、未使用变量、导入错误在执行前就能发现。
testing.T 常用方法
| 方法 | 作用 |
|---|---|
t.Fatal / t.Fatalf | 失败并立即停止当前测试 |
t.Error / t.Errorf | 标记失败但继续执行 |
t.Run | 子测试 |
t.Helper | 标记辅助函数,让失败行号指向调用处 |
t.Cleanup | 测试结束后清理资源 |
t.Parallel | 并行测试 |
t.TempDir | 创建临时目录,测试后自动删除 |
示例:
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Fatalf("got %d, want %d", got, want)
}
}t.Helper() 的意义是:测试失败时,错误行号指向调用 assertEqual 的测试用例,而不是辅助函数内部。
表格驱动测试
Go 社区非常常见的写法是表格驱动测试。它把输入、期望和用例名称放在一个 slice 中。
func ValidateSourceSystem(source string) error {
switch source {
case "HIS", "LIS", "PACS", "EMR":
return nil
default:
return fmt.Errorf("unsupported source system: %s", source)
}
}
func TestValidateSourceSystem(t *testing.T) {
tests := []struct {
name string
source string
wantErr bool
}{
{"his ok", "HIS", false},
{"lis ok", "LIS", false},
{"empty", "", true},
{"unknown", "CRM", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSourceSystem(tt.source)
if (err != nil) != tt.wantErr {
t.Fatalf("wantErr=%v err=%v", tt.wantErr, err)
}
})
}
}为什么这样写:
- 成功、失败、边界值放在一起。
- 新增用例只加一行。
t.Run能看到具体哪个场景失败。- 面试时能体现你会系统性覆盖规则。
测试错误分支
Go 常见错误测试有两类:只关心是否出错,或关心具体错误类型。
只关心是否出错:
if err == nil {
t.Fatal("want error, got nil")
}使用 errors.Is 判断错误链:
var ErrAssetNotFound = errors.New("asset not found")
func TestFindAssetNotFound(t *testing.T) {
_, err := FindAsset(100)
if !errors.Is(err, ErrAssetNotFound) {
t.Fatalf("want ErrAssetNotFound, got %v", err)
}
}使用 errors.As 判断错误类型:
var appErr *AppError
if !errors.As(err, &appErr) {
t.Fatalf("want AppError, got %T", err)
}
if appErr.Code != "ASSET_NOT_FOUND" {
t.Fatalf("code=%s", appErr.Code)
}不要只断言错误字符串完全相等,除非错误文本就是稳定契约。字符串改一个字测试就失败,维护成本会很高。
Fake 依赖
Go 项目里测试 Service 时,通常用接口隔离 Repository 或 Client。
业务代码:
type Asset struct {
ID int64
Name string
}
type assetRepository interface {
Save(ctx context.Context, asset Asset) (Asset, error)
}
type AssetService struct {
repo assetRepository
}
func (s *AssetService) Create(ctx context.Context, name string) (Asset, error) {
if name == "" {
return Asset{}, errors.New("asset name required")
}
return s.repo.Save(ctx, Asset{Name: name})
}测试 Fake:
type fakeAssetRepository struct {
saved Asset
err error
}
func (f *fakeAssetRepository) Save(ctx context.Context, asset Asset) (Asset, error) {
if f.err != nil {
return Asset{}, f.err
}
asset.ID = 1
f.saved = asset
return asset, nil
}
func TestAssetServiceCreate(t *testing.T) {
repo := &fakeAssetRepository{}
service := &AssetService{repo: repo}
asset, err := service.Create(context.Background(), "检验报告")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if asset.ID != 1 || repo.saved.Name != "检验报告" {
t.Fatalf("unexpected asset: %+v", asset)
}
}Fake 是手写的简单实现,适合大多数业务测试。Mock 框架适合复杂交互,但不要为了 Mock 而把简单代码复杂化。
HTTP Handler 测试
标准库 httptest 可以测试 Handler,不需要真的启动端口。
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "UP") {
t.Fatalf("body=%s", rec.Body.String())
}
}测试 JSON 请求:
func TestCreateAssetHandler(t *testing.T) {
body := strings.NewReader(`{"name":"检验报告","source_system":"LIS"}`)
req := httptest.NewRequest(http.MethodPost, "/assets", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.Create(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}Handler 测试重点:
- 请求方法和路径。
- Header。
- JSON Body。
- 状态码。
- 响应结构。
- 参数错误和业务错误。
临时目录和文件测试
测试文件处理时不要写死本机路径。使用 t.TempDir()。
func TestWriteFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.txt")
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "hello" {
t.Fatalf("data=%s", string(data))
}
}t.TempDir() 创建的目录会在测试结束后自动清理。
数据库测试
Repository 测试要考虑数据隔离和清理。
常见方案:
| 方案 | 适合 |
|---|---|
| SQLite/in-memory | 简单 SQL、轻量测试 |
| 测试库 | 与生产数据库接近 |
| Testcontainers | CI 中启动真实数据库 |
| 事务回滚 | 每个测试后清理 |
原则:
- 不连接生产库。
- 测试数据可重复创建。
- 每个测试相互隔离。
- 失败时能看到具体 SQL 或数据。
并发测试和 race
Go 并发代码必须关注数据竞争。
错误示例:
func TestRace(t *testing.T) {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
}运行:
go test -race ./...Race Detector 可以发现多个 goroutine 同时访问同一变量且至少一个写,并且没有同步保护。
修复:
var mu sync.Mutex
counter := 0
// goroutine 中
mu.Lock()
counter++
mu.Unlock()或使用 atomic,或通过 channel 串行化写入。
Benchmark 基准测试
Benchmark 用于比较性能,不是普通功能测试。
func BenchmarkNormalizeAssetName(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.TrimSpace(" 检验报告 ")
}
}运行:
go test -bench . -benchmem输出示例:
BenchmarkNormalizeAssetName-8 100000000 10.5 ns/op 0 B/op 0 allocs/op含义:
| 字段 | 含义 |
|---|---|
ns/op | 每次操作耗时 |
B/op | 每次操作分配多少字节 |
allocs/op | 每次操作分配次数 |
Benchmark 要避免把无关开销测进去。例如准备数据可以放在循环外,循环里只放要测的核心逻辑。
覆盖率怎么看
运行:
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out覆盖率只说明代码是否被执行,不说明断言是否有效。
坏测试:
func TestCreateAsset(t *testing.T) {
service.Create(context.Background(), "检验报告")
}没有断言,执行了也不知道对不对。
好测试:
asset, err := service.Create(context.Background(), "检验报告")
if err != nil {
t.Fatal(err)
}
if asset.Name != "检验报告" {
t.Fatalf("name=%s", asset.Name)
}CI 门禁
flowchart TD
A["提交代码"] --> B["go fmt 检查"]
B --> C["go vet"]
C --> D["go test ./..."]
D --> E["go test -race ./..."]
E --> F{"是否通过"}
F -- "通过" --> G["允许合并"]
F -- "失败" --> H["阻止合并"]常用命令:
go fmt ./...
go vet ./...
go test ./...
go test -race ./...CI 里跑 -race 会更慢,但对并发服务很有价值。至少在主分支合并或夜间构建中运行。
商业场景:采集任务测试
医疗数据采集 Agent 至少要测试:
| 场景 | 测试方式 |
|---|---|
| 来源系统校验 | 表格驱动测试 |
| 单条资产清洗 | 单元测试 |
| 批量采集限流 | 并发测试 |
| 任务取消 | Context 测试 |
| 医院接口失败 | Fake Client |
| 写入数据库失败 | Fake Repository 或集成测试 |
| HTTP 创建任务 | httptest |
| 数据竞争 | go test -race |
测试不是为了覆盖所有代码,而是保护最容易出事故的业务链路。
常见坑
| 问题 | 后果 | 正确做法 |
|---|---|---|
| 只测成功场景 | 异常分支上线才暴露 | 成功、失败、边界都测 |
| 依赖真实外部接口 | 测试慢且不稳定 | 用 Fake 或测试环境 |
| 测试连接生产库 | 污染真实数据 | 使用测试库 |
| 没有断言 | 覆盖率虚高 | 明确断言结果 |
| 并发不跑 race | 数据竞争漏掉 | go test -race |
| 为了测试提前抽大接口 | 过度设计 | 接口由使用方按需定义 |
| 测试之间共享状态 | 单测顺序影响结果 | 每个测试准备独立数据 |
面试标准回答
Go 测试怎么写?
Go 测试文件以 _test.go 结尾,测试函数以 TestXxx(t *testing.T) 命名。常用表格驱动测试覆盖多组输入,用 t.Run 标识子用例。核心业务、错误分支、边界条件、HTTP Handler 和并发代码都应该有测试。
表格驱动测试有什么好处?
表格驱动测试把用例名称、输入、期望集中在一个 slice 里。新增场景只要加一行,成功、失败、边界值都能清楚展示,配合 t.Run 可以快速定位哪个场景失败。
Go 如何测试 HTTP Handler?
使用标准库 httptest.NewRequest 构造请求,用 httptest.NewRecorder 记录响应,然后调用 Handler,断言状态码、Header、响应体和错误结构。不需要真的启动端口。
go test -race 做什么?
Race Detector 用于发现数据竞争,即多个 goroutine 同时访问同一变量且至少一个写,并且没有同步保护。并发服务上线前应运行 go test -race ./...,发现后用 Mutex、atomic、channel 或减少共享状态修复。
关联知识点
小结
Go 测试的核心是让行为可验证。零基础先掌握 _test.go、testing.T 和表格驱动;进阶要会 Fake 依赖、测试 Handler、测试错误链、跑 race 和 benchmark;商业项目要把 go test ./...、go vet ./...、go test -race ./... 放进 CI,让测试成为质量门禁。
