Skip to content

Go 测试

Go 内置测试框架,测试文件以 _test.go 结尾,测试函数以 Test 开头。真正的 Go 测试不只是会运行 go test,还要会写表格驱动测试、子测试、HTTP Handler 测试、Fake 依赖、Benchmark、Race 检测和 CI 门禁。

测试的目标不是“追求覆盖率数字”,而是把核心业务规则、边界条件、并发风险和线上 bug 固化成自动化验证,让后续重构、升级依赖、修改需求时不靠手工猜。

学习目标

学完本页你应该能回答:

  1. Go 测试文件和测试函数如何被发现。
  2. testing.Tt.Runt.Fatalt.Helper 分别怎么用。
  3. 表格驱动测试为什么是 Go 常见写法。
  4. 如何测试业务规则、错误分支、HTTP Handler、Repository。
  5. Fake、Mock、接口隔离分别适合什么场景。
  6. go test -race 能发现什么问题。
  7. Benchmark 如何写,结果怎么看。
  8. CI 中如何把测试作为合并门禁。

Go 测试如何被发现

Go 测试规则:

规则说明
文件名必须以 _test.go 结尾
测试函数func TestXxx(t *testing.T)
基准测试func BenchmarkXxx(b *testing.B)
示例测试func ExampleXxx()
包名可以是同包,也可以是 xxx_test 外部测试包

最小示例:

calculator.go

go
package calculator

func Add(a, b int) int {
    return a + b
}

calculator_test.go

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

运行:

bash
go test
go test ./...
go test -v ./...

./... 表示当前模块下所有包。

测试执行流程

mermaid
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创建临时目录,测试后自动删除

示例:

go
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 中。

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

为什么这样写:

  1. 成功、失败、边界值放在一起。
  2. 新增用例只加一行。
  3. t.Run 能看到具体哪个场景失败。
  4. 面试时能体现你会系统性覆盖规则。

测试错误分支

Go 常见错误测试有两类:只关心是否出错,或关心具体错误类型。

只关心是否出错:

go
if err == nil {
    t.Fatal("want error, got nil")
}

使用 errors.Is 判断错误链:

go
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 判断错误类型:

go
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。

业务代码:

go
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:

go
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,不需要真的启动端口。

go
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 请求:

go
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 测试重点:

  1. 请求方法和路径。
  2. Header。
  3. JSON Body。
  4. 状态码。
  5. 响应结构。
  6. 参数错误和业务错误。

临时目录和文件测试

测试文件处理时不要写死本机路径。使用 t.TempDir()

go
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、轻量测试
测试库与生产数据库接近
TestcontainersCI 中启动真实数据库
事务回滚每个测试后清理

原则:

  1. 不连接生产库。
  2. 测试数据可重复创建。
  3. 每个测试相互隔离。
  4. 失败时能看到具体 SQL 或数据。

并发测试和 race

Go 并发代码必须关注数据竞争。

错误示例:

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

运行:

bash
go test -race ./...

Race Detector 可以发现多个 goroutine 同时访问同一变量且至少一个写,并且没有同步保护。

修复:

go
var mu sync.Mutex
counter := 0
// goroutine 中
mu.Lock()
counter++
mu.Unlock()

或使用 atomic,或通过 channel 串行化写入。

Benchmark 基准测试

Benchmark 用于比较性能,不是普通功能测试。

go
func BenchmarkNormalizeAssetName(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strings.TrimSpace("  检验报告  ")
    }
}

运行:

bash
go test -bench . -benchmem

输出示例:

text
BenchmarkNormalizeAssetName-8    100000000    10.5 ns/op    0 B/op    0 allocs/op

含义:

字段含义
ns/op每次操作耗时
B/op每次操作分配多少字节
allocs/op每次操作分配次数

Benchmark 要避免把无关开销测进去。例如准备数据可以放在循环外,循环里只放要测的核心逻辑。

覆盖率怎么看

运行:

bash
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

覆盖率只说明代码是否被执行,不说明断言是否有效。

坏测试:

go
func TestCreateAsset(t *testing.T) {
    service.Create(context.Background(), "检验报告")
}

没有断言,执行了也不知道对不对。

好测试:

go
asset, err := service.Create(context.Background(), "检验报告")
if err != nil {
    t.Fatal(err)
}
if asset.Name != "检验报告" {
    t.Fatalf("name=%s", asset.Name)
}

CI 门禁

mermaid
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["阻止合并"]

常用命令:

bash
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.gotesting.T 和表格驱动;进阶要会 Fake 依赖、测试 Handler、测试错误链、跑 race 和 benchmark;商业项目要把 go test ./...go vet ./...go test -race ./... 放进 CI,让测试成为质量门禁。