Skip to content

Latest commit

 

History

History
197 lines (145 loc) · 5.64 KB

03-testing.md

File metadata and controls

197 lines (145 loc) · 5.64 KB

测试体系

Go 的测试是语言工具链的一等公民:go test 内建,无需 JUnit 依赖,也没有注解处理器。这一节把社区惯例一次讲清——后面每个项目的 *_test.go 都在用这些模式。

基本形态

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    if got != 5 {
        t.Errorf("Add(2,3) = %d, want 5", got)
    }
}

文件名必须以 _test.go 结尾。包名可以是:

  • 同包 package foo:可测未导出符号
  • 外部包 package foo_test:只测公开 API,更接近真实调用方

本教程项目多用同包,方便测内部辅助函数。

Table-driven tests

tests := []struct {
    name string
    in   string
    want int
    ok   bool
}{
    {"empty", "", 0, false},
    {"number", "42", 42, true},
}
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        got, err := Parse(tt.in)
        if tt.ok && err != nil {
            t.Fatalf("unexpected err: %v", err)
        }
        if got != tt.want {
            t.Errorf("got %d, want %d", got, tt.want)
        }
    })
}

对比 @ParameterizedTest:没有反射魔法,就是一个切片 + t.Run。失败时子测试名字会出现在输出里,定位很快。

t.Helper / t.Cleanup / t.TempDir

func newServer(t *testing.T) *httptest.Server {
    t.Helper()
    srv := httptest.NewServer(handler)
    t.Cleanup(srv.Close)
    return srv
}

dir := t.TempDir() // 测试结束自动删

t.Helper() 让失败行号指到调用方。t.TempDir() 替代手写临时目录清理。

httptest

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, `{"ok":true}`)
}))
defer srv.Close()

resp, err := http.Get(srv.URL + "/ping")

Handler 层测试也可以不启服务器,直接:

req := httptest.NewRequest(http.MethodGet, "/bookmarks/1", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)

Part 5 的 API 项目会大量使用这两种方式。

Benchmark 与 B.Loop

func BenchmarkParse(b *testing.B) {
    for b.Loop() { // Go 1.24+,比 for i := 0; i < b.N; i++ 更不易被优化掉
        Parse("42")
    }
}
go test -bench=BenchmarkParse -benchmem ./...

对比 JMH:没有 warmup 注解套件,但 -count、-benchtime、-cpu 已经够用。项目 09 用它对比三种锁策略。

Fuzzing

func FuzzParse(f *testing.F) {
    f.Add("42")
    f.Fuzz(func(t *testing.T, s string) {
        _, _ = Parse(s) // 不应 panic
    })
}
go test -fuzz=FuzzParse -fuzztime=10s

对比 jazzer:内建、与 go test 一体。发现的坏输入会落盘到 testdata/fuzz/,成为永久回归用例。

竞态与静态检查

go test -race ./...
go vet ./...
golangci-lint run

并发项目(Part 3)一律带 -race。CI 里本仓库用 scripts/check.sh 统一跑 fmt / build / vet / lint / solution 测试。

和 JUnit 对照

点 JUnit 5 Go testing
断言库 AssertJ / Hamcrest 手写 if + t.Errorf(或 testify)
参数化 注解 table-driven 切片
生命周期 @BeforeEach 普通辅助函数 + t.Cleanup
Mock Mockito 接口 + 手写 fake(更常见)
覆盖率 JaCoCo go test -cover

Go 社区对「断言库 / mock 框架」比 Java 克制得多:能用接口 + 假实现就不用 mock 生成器。本教程项目也遵循这个风格。

本教程的项目测试约定

  1. 骨架的测试会失败——那是题目,不是坏掉的仓库
  2. solution/ 的测试必须全绿——CI 只卡 solution
  3. 并发相关加 -race
  4. 确定性优先:用 fstest.MapFS、屏障 channel,少用 time.Sleep 碰运气

覆盖率不是正确性证明

go test -coverprofile=cover.out ./...
go tool cover -func=cover.out
go tool cover -html=cover.out

100% 覆盖率只说明每条语句被执行过,不说明断言正确。一个什么都不检查的测试也可以覆盖所有代码。覆盖率门槛的价值是防止大块新逻辑完全没测,而不是替代评审测试语义。

测试并发代码

优先用 channel 构造可观察的时序:

started := make(chan struct{})
release := make(chan struct{})

go func() {
    close(started)
    <-release
}()

<-started
// 此时可以确定 goroutine 已进入指定状态
close(release)

time.Sleep 只能说“大概等够了”,慢机器和 race detector 会让这种测试变得脆弱。如果必须等待,使用带超时的 select,不要无限阻塞。

如何选择 fake、stub 和真实组件

  • 内存 Store 或 fstest.MapFS:便宜、确定,适合单元测试。
  • httptest.Server:需要真实 HTTP 客户端行为时使用。
  • httptest.ResponseRecorder:只测 Handler 边界时更快。
  • 真实 SQLite :memory::当 SQL 本身就是契约时,比 mock database/sql 更有价值。

练习

配套材料:题目骨架 · 参考答案

  1. 把一个使用五次 time.Sleep 的并发测试改为 channel 屏障。
  2. 为 HTTP Handler 分别写 Recorder 测试和 httptest.Server 测试,解释各自多覆盖了什么。
  3. 写一个只覆盖语句但没有有效断言的测试,再通过改错生产代码证明它无法捕获回归。

上一节:CLI 与 slog | 下一部分:HTTP 服务基础

配套项目:10 · 11 · 12