表驱动与接口测试
表驱动与接口测试 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课给书签服务补上安全网:表驱动测试覆盖校验函数的成功/边界/错误输入,httptest 测 HTTP handler 的状态码与响应体,让 go test ./... 一条命令验证全部行为。
问题场景
G06 的 parseBookmark 要改一版校验规则,你怎么确认没改坏别的情况?手敲 curl 十几种输入既慢又会漏。测试把这些「输入-期望」固化成代码,之后每次改动机器自动重放——测试不是负担,是把验证从人工动作变成项目资产的手段。
核心解释
表驱动是 Go 社区的标准测试形态:用例是数据,测试逻辑只有一份:
// validate_test.go(与 parseBookmark 同包)
func TestParseBookmark(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
}{
{"正常输入", `{"title":"Go","url":"https://go.dev"}`, false},
{"缺 title", `{"url":"https://go.dev"}`, true},
{"空白 title", `{"title":" ","url":"https://go.dev"}`, true},
{"url 非 http", `{"title":"x","url":"ftp://go.dev"}`, true},
{"超长 title", `{"title":"` + strings.Repeat("汉", 101) + `","url":"https://go.dev"}`, true},
{"未知字段", `{"title":"x","url":"https://go.dev","extra":1}`, true},
{"坏 JSON", `{bad`, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/bookmarks", strings.NewReader(tt.body))
_, err := parseBookmark(r)
if (err != nil) != tt.wantErr {
t.Fatalf("parseBookmark() err = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
三个要点:每个 case 带 name,t.Run 产出带名字的子测试,失败输出直接指到「超长 title」这种人话;用例表刻意混了成功、边界(101 个字)、错误三类——只有 happy path 的测试没有防御力;httptest.NewRequest 构造的请求不需要真实端口,body 用 strings.Reader 塞进去,parseBookmark 又是纯函数,整个测试毫秒级跑完。
HTTP 层测试用 httptest.NewRecorder 截获响应:
func TestGetBookmarkHandler(t *testing.T) {
store := NewMemoryStore()
id, _ := store.Add(Bookmark{Title: "Go", URL: "https://go.dev"})
handler := newMux(store) // G05 把路由注册提成了函数
t.Run("存在返回 200", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/bookmarks/%d", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var b Bookmark
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil || b.Title != "Go" {
t.Fatalf("body = %s, err = %v", rec.Body.String(), err)
}
})
t.Run("不存在返回 404", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bookmarks/999", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
})
}
测试打的是 newMux 返回的真实路由(含中间件),覆盖的是「请求进来会发生什么」这个用户视角,而不是内部函数调用——G05 把路由提成 newMux 的决定在这里兑现回报。
示例拆解
go test ./... -v
# === RUN TestParseBookmark
# === RUN TestParseBookmark/正常输入
# --- PASS: TestParseBookmark (0.00s)
# ...
go test ./... -race -count=1 # 带竞争检测,G11 的习惯
go test -cover ./...
# coverage: 7x.x% of statements —— 数字只作参考,错误分支有没有覆盖更重要
新行为的顺序建议:先在表里加一行期望(跑红),再改实现(跑绿)。比如给 parseBookmark 加 tags 去重,先加用例 {"重复 tags", ..., false} 断言清洗后的结果,红了再写代码——测试先行的轻量版。只想重放单个用例时用 -run 过滤:go test -run "TestParseBookmark/超长" 只跑那一条子测试,调边界时最快。
动手任务
- 给 TestParseBookmark 表加两个边界用例:恰好 100 个字的 title(应通过)和恰好 10 个 tags(应通过),跑绿。
- 写 TestDeleteBookmark:存在返回 204、不存在返回 404,两条子测试走真实路由。
- 跑 go test -cover 找出未覆盖的函数,给其中一个错误路径补一条用例。
常见错误
- 只测成功路径:错误分支才是 bug 老巢,每个分支都要有用例。
- 断言消息只写 should work:失败时不知所云,消息写清 got 和 want。
- 测试之间共享可变全局状态:每次新建 store 或用 t.Cleanup 清理,测试必须互相独立。
- 追求覆盖率数字而写无断言的测试:跑过等于没测,覆盖率是体检指标不是目标。
检查与复盘
完成下方检查题。自查清单:
- 校验、解析这类逻辑全部表驱动,用例含成功/边界/错误三类
- handler 测试走 newMux 真实路由 + httptest,断言状态码和响应体
- 本地与提交前都跑 go test -race,测试之间无共享状态
参考资料
- 标准库 testing —— t.Run 子测试与断言惯例
- 标准库 net/http/httptest —— NewRequest/NewRecorder/Server 三件套
- Go Wiki:TableDrivenTests —— 表驱动测试官方范式
读完这一课了?
标记完成后,工作台会安排接下来的学习。