Go 测试入门:table-driven 与 httptest

《Go 测试入门:table-driven 与 httptest》

要做什么,不做什么

给”测试”这件事在 Go 里立个最小区:一条 table-driven 单元测试、一个 httptest 的 HTTP handler 测试、t.Parallel 与 -race 的正确打开方式,最后落到测试文件怎么组织。边界先说死:只走标准库 testing 主线,testify 单独一节讲”什么时候值得上”;gomock 这类 mock 生成器不讲;fuzz 模糊测试不讲;benchmark 本文只当工具用不展开;集成测试与端到端测试不做。版本口径:Go 1.22(2024-02-06 GA),testify 1.9(2024-02 发布)。看完能带走两段可直接复制的测试样板,和一条”新代码先写哪个测试”的判断。

第一条测试与 go test 的运行模型

Go 的测试没有仪式感:测试函数写在文件名以 _test.go 结尾的文件里,签名固定 func TestXxx(t *testing.T),go test 扫描同目录把它们全部跑起来。没有注册环节,没有配置文件,编译器本身就是测试的发现机制——这也是不少人从别的语言过来觉得”Go 测试怎么这么朴素”的原因。

拿一个会埋坑的小函数当被测对象:

// stringsx/truncate.go
package stringsx

// Truncate 把超过 max 个 rune 的字符串截断,以 … 结尾
func Truncate(s string, max int) string {
    r := []rune(s)
    if max <= 0 || len(r) <= max {
        return s
    }
    if max == 1 {
        return "…"
    }
    return string(r[:max-1]) + "…"
}

这个函数我第一版用 len(s) <= max 做判断——len 数的是字节,一个汉字三个字节,”你好世界”按 5 截断就会在半个汉字处下刀。这类 bug 手测很难稳定碰到,写进用例就是一辈子的事。

table-driven:把用例当数据

Go 社区约定俗成的写法是把用例摆成一个结构体切片,一个循环跑完:

// stringsx/truncate_test.go
package stringsx

import "testing"

func TestTruncate(t *testing.T) {
    tests := []struct {
        name string
        in   string
        max  int
        want string
    }{
        {"短于上限原样返回", "hello", 10, "hello"},
        {"等于上限原样返回", "hello", 5, "hello"},
        {"超长截断补省略号", "hello, world", 8, "hello, …"},
        {"中文按 rune 计数不切碎", "你好世界", 5, "你好世…"},
        {"max 为零原样返回", "abc", 0, "abc"},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := Truncate(tc.in, tc.max)
            if got != tc.want {
                t.Errorf("Truncate(%q, %d) = %q, want %q", tc.in, tc.max, got, tc.want)
            }
        })
    }
}

几个咬死的细节:t.Run 给每个用例开子测试,名字就是表里的 name,中文完全可用;断言失败用 t.Errorf 而不是 panic,一条用例失败不影响其余;%q 带引号打印,空串和带空格的串一眼分清。跑起来:

$ go test -v ./stringsx/
=== RUN   TestTruncate
=== RUN   TestTruncate/短于上限原样返回
=== RUN   TestTruncate/等于上限原样返回
=== RUN   TestTruncate/超长截断补省略号
=== RUN   TestTruncate/中文按rune计数不切碎
=== RUN   TestTruncate/max为零原样返回
--- PASS: TestTruncate (0.00s)
    --- PASS: TestTruncate/短于上限原样返回 (0.00s)
    ...
PASS
ok      example.com/stringsx    0.014s

(输出节选。注意带空格的用例名在子测试里显示为下划线。)我的填表顺序是先钉边界再补常规——”中文按 rune 计数”这条在我第一版实现下是红的,修完才绿。失败信息长这样,定位成本几乎为零:

    truncate_test.go:22: Truncate("你好世界", 5) = "你好世xbd…", want "你好世…"
FAIL

table-driven 的真正回报在加用例的那一刻:加一行表项,不碰断言逻辑。项目里见到手写十几条 if got != want 的测试函数,我的第一反应就是重构成表。

Error 与 Fatal 的分工

需要”失败即停”的场合用 t.Fatalf:前置的构造失败了,后面的断言全无意义,继续跑只会输出一串噪声。规律很简单——断言本身失败用 Error,前置条件失败用 Fatal。

httptest:把 handler 当函数测

HTTP 层是 Go 测试体验最好的部分:net/http/httptest 让你不开端口、不走网络,直接把 handler 当普通函数调。被测的 handler:

// api/health.go
package api

import "net/http"

func Health(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"status":"ok"}`))
}

测试三步:构造请求、准备一个假 ResponseWriter、把 handler 调一遍,然后检查记录下的响应:

// api/health_test.go
package api

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

func TestHealth(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
    rec := httptest.NewRecorder()

    Health(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("status = %d, want 200", rec.Code)
    }
    if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
        t.Errorf("Content-Type = %q", ct)
    }
    if got := rec.Body.String(); got != `{"status":"ok"}` {
        t.Errorf("body = %q", got)
    }
}

func TestHealthWrongMethod(t *testing.T) {
    req := httptest.NewRequest(http.MethodPost, "/healthz", nil)
    rec := httptest.NewRecorder()

    Health(rec, req)

    if rec.Code != http.StatusMethodNotAllowed {
        t.Errorf("status = %d, want 405", rec.Code)
    }
}

httptest.NewRequest 造的是标准 *http.Request,NewRecorder 实现了 http.ResponseWriter 并把写进去的状态码、头、体全部记下——任何符合签名的 handler 都能这样测,包括包了中间件之后的最终形态。反过来测 HTTP 客户端时,用 httptest.NewServer 起一个真监听的本地服务,把它的 URL 塞给客户端——是另一个工具,别和 NewRecorder 记混。一个我真实抓过的 bug:客户端把 URL 拼错一位,本地起 NewServer 一测立刻 404,比在联调环境里猜快得多。

t.Parallel:并行要显式声明

默认情况下,一个测试函数内部的子测试是串行跑完的。想要并行,必须在子测试里显式调用 t.Parallel():

for _, tc := range tests {
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        got := Truncate(tc.in, tc.max)
        if got != tc.want {
            t.Errorf("Truncate(%q, %d) = %q, want %q", tc.in, tc.max, got, tc.want)
        }
    })
}

预期输出里会出现 PAUSE 与 CONT:=== RUN 之后 === PAUSE 让出执行权,所有并行用例就绪后逐个 === CONT 恢复——输出里有这对字样,才算真的并行了。Go 1.22 起循环变量每轮迭代都是新变量,闭包里直接用 tc 不再有共享陷阱,沿用多年的 tc := tc 影子拷贝可以退休(前提是 go.mod 声明 go 1.22+)。

什么时候用:用例间无共享状态、各自耗时不短(IO、重计算)就值得并行;纯内存小断言并行收益趋近于零,还把输出顺序搅乱,我不加。共享同一个数据库实例的集成测试慎用——并行了反而互相污染。

race 下跑测试:-race 是硬验收

并发代码的测试只有一条铁律:开着 race detector 跑。先写一段故意有数据竞争的代码:

func TestBrokenCounter(t *testing.T) {
    var n int
    var wg sync.WaitGroup
    for range 50 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for range 100 {
                n++ // 没加锁,故意写错
            }
        }()
    }
    wg.Wait()
}

普通 go test 这条大概率”通过”——n++ 撞得再多也未必崩;go test -race 会直接把它揪出来:

==================
WARNING: DATA RACE
Read at 0x00c0000a4000 by goroutine 8:
  example.com/x.TestBrokenCounter.func1()
      /path/counter_test.go:14 +0x38
...
==================

race detector 的原理是记录内存访问的 happens-before 关系,只对”测试实际跑到的那条执行路径”负责——它证明不了没有竞争,只能证明跑到过的地方没有。所以并发放大很关键:用例里多起 goroutine、多跑几轮(-count=10),让竞争路径更有机会被踩到。这层底子(goroutine、WaitGroup、happens-before)在《Go 并发基础》里铺过,不展开。

我的习惯是 CI 里测试永远带 -race:它让测试慢上几倍(公开口径的定性区间,量级与程序形态有关),但比上线后查一个复现不出来的诡异 bug 便宜太多。

testify:什么时候值得上

2024 年我的口径:能用标准库就先不引 testify。table-driven 加 t.Errorf 覆盖九成场景,还少一个依赖。真正值得上的是断言糖与对象比较密集的场景:

go get github.com/stretchr/testify@v1.9.0
import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestTruncateTestify(t *testing.T) {
    assert.Equal(t, "hello, …", Truncate("hello, world", 8))
    assert.Equal(t, "你好世…", Truncate("你好世界", 5))
    // require 失败立即终止当前测试;assert 失败后继续往下走
    require.Equal(t, "abc", Truncate("abc", 0))
}

assert 与 require 的分工记一句话:后面的断言依赖前面成立时用 require,否则用 assert。testify 1.9 是 2024-02 发布的版本,v1 系列 API 多年稳定,升级没有心智负担。

测试文件怎么摆

三条惯例,都是业内默认:

  • 测试与被测同目录。truncate.go 和 truncate_test.go 并排放,go test ./... 按目录为单位全量跑。包名两种选择:同名 package stringsx 是白盒,能测未导出函数;package stringsx_test 是黑盒,只能走导出 API,强迫你从调用方视角写用例。我的默认是同包,公开库的对外行为测试用 _test 包。
  • 辅助函数必须调 t.Helper()。不调的话,断言失败报的行号是辅助函数那一行,而不是真正的用例行——排查时白绕一圈。另外 Test、Benchmark、Fuzz 开头的函数会被当作测试入口,辅助函数刻意避开这些前缀。
  • 测试数据进 testdata/。工具链对 testdata 目录有豁免——不参与编译、不打包。大 JSON、证书、协议样例都放这儿,用 os.ReadFile 读。

日常命令就四条:go test ./... 全量;-run 'TestTruncate/中文' 按正则过滤子测试;-count=1 绕过结果缓存(调试必用,缓存命中会让你误以为跑过);-v 看每条用例的名字与耗时。

验收清单

  • table-driven 样板复制进自己的项目,go test -v 输出里每个中文用例名独立 PASS;
  • 把”中文按 rune 计数”那条的 want 改成错误值,重跑变红且失败信息带 %q 引号——确认断言在工作;
  • httptest 两条用例(200 与 405)都过,Content-Type 断言在;
  • 子测试加了 t.Parallel 后输出出现 PAUSE/CONT 字样;
  • 故意写错的并发计数器在 go test -race 下报 DATA RACE,加 mutex 或 atomic 后转绿;
  • go test -count=1 ./... 作为 CI 默认命令跑通。

学完之后

下一步把测试放进实战:《用 Go 写一个并发安全的内存缓存》那条 -race 全绿的验收,用本文的 table-driven 加并行套路可以直接搭出来;错误处理那套包装与判断语义,测试断言照《Go 错误处理》的口径对齐——用 errors.Is/As 断言错误类型,比比错误字符串稳得多。再往后两个方向:benchmark 与性能诊断、mock 依赖替身,属于进阶话题,本文按下不表。

本文数据核对时间:2024-07-08:Go 1.22(2024-02-06 GA,go.mod 声明 go 1.22+ 后 for 循环变量每轮迭代为新变量、range over int 可用);testify v1.9.0(2024-02-02 发布,2024-07 口径 v1.9.x 为最新系列);httptest 属标准库 net/http/httptest,随 Go 分发;race detector 的减速幅度为公开口径定性描述,非实测。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇