G13第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

表驱动与接口测试

表驱动与接口测试 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这课给书签服务补上安全网:表驱动测试覆盖校验函数的成功/边界/错误输入,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/超长" 只跑那一条子测试,调边界时最快。

动手任务

  1. 给 TestParseBookmark 表加两个边界用例:恰好 100 个字的 title(应通过)和恰好 10 个 tags(应通过),跑绿。
  2. 写 TestDeleteBookmark:存在返回 204、不存在返回 404,两条子测试走真实路由。
  3. 跑 go test -cover 找出未覆盖的函数,给其中一个错误路径补一条用例。

常见错误

  • 只测成功路径:错误分支才是 bug 老巢,每个分支都要有用例。
  • 断言消息只写 should work:失败时不知所云,消息写清 got 和 want。
  • 测试之间共享可变全局状态:每次新建 store 或用 t.Cleanup 清理,测试必须互相独立。
  • 追求覆盖率数字而写无断言的测试:跑过等于没测,覆盖率是体检指标不是目标。

检查与复盘

完成下方检查题。自查清单:

  • 校验、解析这类逻辑全部表驱动,用例含成功/边界/错误三类
  • handler 测试走 newMux 真实路由 + httptest,断言状态码和响应体
  • 本地与提交前都跑 go test -race,测试之间无共享状态

参考资料

检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码简单

阅读代码(Go 1.22+,calc_test.go 与被测代码同目录、package main)。怎么运行?共有几个用例?

func TestAbs(t *testing.T) {
	tests := []struct {
		name string
		in   int
		want int
	}{
		{"positive", 3, 3},
		{"negative", -3, 3},
		{"zero", 0, 0},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Abs(tt.in); got != tt.want {
				t.Errorf("Abs(%d) = %d, want %d", tt.in, got, tt.want)
			}
		})
	}
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...