wavelet init

This commit is contained in:
ryan
2026-06-18 15:24:48 +08:00
parent d6a7011885
commit 99738bbc17
714 changed files with 139987 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
---
name: go-testing
description: Use when writing, reviewing, or improving Go test code — including table-driven tests, subtests, parallel tests, test helpers, test doubles, and assertions with cmp.Diff. Also use when a user asks to write a test for a Go function, even if they don't mention specific patterns like table-driven tests or subtests. Does not cover benchmark performance testing (see go-performance).
license: Apache-2.0
compatibility: Uses github.com/google/go-cmp for cmp.Diff comparisons
metadata:
sources: "Google Style Guide, Uber Style Guide"
allowed-tools: Bash(bash:*)
---
# Go 测试
## 快速参考
| 模式 | 使用场景 |
|------|----------|
| `t.Error` | 默认 — 报告失败,继续运行 |
| `t.Fatal` | 设置失败或继续运行没有意义 |
| `cmp.Diff` | 比较 struct、slice、map、proto |
| 表驱动 | 多个用例共享相同逻辑 |
| 子测试 | 需要过滤、并行执行或命名 |
| `t.Helper()` | 任何测试辅助函数(作为第一条语句调用) |
| `t.Cleanup()` | 在辅助函数中进行清理,替代 defer |
---
## 有用的测试失败信息
> **规范**:测试失败必须在不阅读测试源码的情况下可诊断。
每条失败信息必须包含:函数名、输入、实际值(got)和期望值(want)。使用格式 `YourFunc(%v) = %v, want %v`。
```go
// 好:
t.Errorf("Add(2, 3) = %d, want %d", got, 5)
// 不好:缺少函数名和输入
t.Errorf("got %d, want %d", got, 5)
```
始终先打印 got 再打印 want:`got %v, want %v` — 绝不反转。
---
## 不使用断言库
> **规范**:不要使用断言库。对于复杂比较使用 `cmp.Diff`。
```go
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetPost() mismatch (-want +got):\n%s", diff)
}
```
对于 protocol buffers,添加 `protocmp.Transform()` 作为 cmp 选项。始终在 diff 信息中包含方向键 `(-want +got)`。避免比较 JSON/序列化输出 — 改为语义比较。
> 在编写自定义比较辅助函数或领域特定测试工具时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。
---
## t.Error vs t.Fatal
> **规范**:默认使用 `t.Error` 以在一次运行中报告所有失败。仅在无法继续时使用 `t.Fatal`。
**选择 `t.Fatal` 的场景:**
- 设置失败(数据库连接、文件加载)
- 下一个断言依赖于上一个断言成功(例如,编码后的解码)
**绝不在测试 goroutine 以外的 goroutine 中调用 `t.Fatal`/`t.FailNow`** — 改为使用 `t.Error`。
> 在编写需要在 t.Error 和 t.Fatal 之间选择的辅助函数时,或需要两者的详细示例时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。
---
## 表驱动测试
> 在搭建新的表驱动测试并需要标准的 struct、循环和子测试布局时,请参阅 `assets/table-test-template.go`。
> **建议**:当多个用例共享相同逻辑时使用表驱动测试。
**使用表测试的场景:** 所有用例运行相同的代码路径,没有条件设置、mock 或断言。单个 `shouldErr` bool 是可以接受的。
**不使用表测试的场景:** 用例需要复杂设置、条件 mock 或多个分支 — 改为编写单独的测试函数。
**关键规则:**
- 当用例跨越多行或有相同类型的相邻字段时,使用字段名
- 在失败信息中包含输入 — 绝不通过索引标识行
> 在编写表驱动测试、子测试或并行测试时,请阅读 [references/TABLE-DRIVEN-TESTS.md](references/TABLE-DRIVEN-TESTS.md)。
> **验证**:在生成或修改测试后,运行 `go test -run TestXxx -v` 验证测试能编译并通过。在继续之前修复任何编译错误。
---
## 测试辅助函数
> **规范**:测试辅助函数必须首先调用 `t.Helper()` 并使用 `t.Cleanup()` 进行清理。
```go
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Could not open database: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
```
> 在编写测试辅助函数、清理函数或自定义比较工具时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。
---
## 测试错误语义
> **建议**:测试错误语义,而非错误消息字符串。
```go
// 不好:脆弱的字符串比较
if err.Error() != "invalid input" { ... }
// 好:语义检查
if !errors.Is(err, ErrInvalidInput) { ... }
```
对于不需要特定语义的简单存在性检查:
```go
if gotErr := err != nil; gotErr != tt.wantErr {
t.Errorf("f(%v) error = %v, want error presence = %t", tt.input, err, tt.wantErr)
}
```
---
## 测试组织
> 在使用测试替身、选择测试包位置或规划测试设置范围时,请阅读 [references/TEST-ORGANIZATION.md](references/TEST-ORGANIZATION.md)。
> 在设计可重用的测试验证函数时,请阅读 [references/VALIDATION-APIS.md](references/VALIDATION-APIS.md)。
---
## 集成测试
> 在编写 TestMain、验收测试或需要真实 HTTP/RPC 传输层的测试时,请阅读 [references/INTEGRATION.md](references/INTEGRATION.md)。
---
## 可用脚本
- **`scripts/gen-table-test.sh`** — 生成表驱动测试脚手架
```bash
bash scripts/gen-table-test.sh ParseConfig config > config/parse_config_test.go
bash scripts/gen-table-test.sh --parallel ParseConfig config # 带 t.Parallel()
bash scripts/gen-table-test.sh --output config/parse_config_test.go ParseConfig config
```
---
## 相关 Skill
- **错误测试**:在使用 `errors.Is`/`errors.As` 或哨兵错误测试错误语义时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
- **接口 mock**:在消费端通过实现接口创建测试替身时,请参阅 [go-interfaces](../go-interfaces/SKILL.md)
- **测试函数命名**:在命名测试函数、子测试或测试辅助工具时,请参阅 [go-naming](../go-naming/SKILL.md)
- **Linter 集成**:在 CI 或 pre-commit hooks 中与测试一起运行 linter 时,请参阅 [go-linting](../go-linting/SKILL.md)
@@ -0,0 +1,30 @@
package example_test
import "testing"
func TestExample(t *testing.T) {
tests := []struct {
name string
// TODO: add input fields
// TODO: add expected output fields
}{
{
name: "basic case",
// TODO: fill in
},
{
name: "edge case",
// TODO: fill in
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// TODO: call function under test
// TODO: compare got vs want
// if diff := cmp.Diff(want, got); diff != "" {
// t.Errorf("Example() mismatch (-want +got):\n%s", diff)
// }
})
}
}
@@ -0,0 +1,144 @@
# Go 测试:集成和高级模式
TestMain、验收测试和真实传输层测试的详细参考。
来源:Google Go Style Guide(最佳实践)。
---
## TestMain
> **来源**:Google Go Style Guide(最佳实践)
当 **包中的所有测试** 都需要共同的设置且需要清理时(例如,共享数据库),使用 `func TestMain(m *testing.M)`。这 **不应该是你的首选** — 尽可能优先使用作用域测试辅助函数或 `t.Cleanup`。
```go
var db *sql.DB
func TestInsert(t *testing.T) { /* 使用 db */ }
func TestSelect(t *testing.T) { /* 使用 db */ }
func runMain(ctx context.Context, m *testing.M) (code int, err error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
d, err := setupDatabase(ctx)
if err != nil {
return 0, err
}
defer d.Close()
db = d
return m.Run(), nil
}
func TestMain(m *testing.M) {
code, err := runMain(context.Background(), m)
if err != nil {
log.Fatal(err)
}
// defer 语句在 os.Exit 之后不会执行
os.Exit(code)
}
```
关键点:
- 将设置提取到辅助函数(`runMain`)中,使 `defer` 能正确工作
- 通过 `log.Fatal` 将失败信息写入 stderr
- 确保各个测试用例保持独立 — 重置它们修改的任何全局状态
---
## 验收测试
> **来源**:Google Go Style Guide(最佳实践)
验收测试验证实现是否遵循契约,将其视为黑盒。当用户实现你的接口并且你想提供可重用的验证套件时,这种模式很有用。
### 结构
1. 创建测试辅助包(例如,为 `chess` 包创建 `chesstest`)
2. 导出一个接受被测实现的验证函数:
```go
// Package chesstest 为 chess.Player 实现提供验收测试。
package chesstest
// ExercisePlayer 在单回合中测试 Player 实现。
// 如果玩家走了正确的一步,返回 nil,否则返回描述违规行为的错误。
func ExercisePlayer(b *chess.Board, p chess.Player) error {
move := p.Move()
if putsOwnKingIntoCheck(b, move) {
return &IllegalMoveError{Move: move, Reason: "puts own king in check"}
}
return nil
}
```
3. 最终用户针对验证函数编写简单测试:
```go
func TestAcceptance(t *testing.T) {
player := deepblue.New()
if err := chesstest.ExerciseGame(t, chesstest.SimpleGame, player); err != nil {
t.Errorf("Deep Blue player failed acceptance test: %v", err)
}
}
```
仅在设置失败时使用 `t.Fatal` — 验证错误应该返回,而非 fatal。
---
## 使用真实传输层
> **来源**:Google Go Style Guide(最佳实践)
在测试基于 HTTP 或 RPC 的组件集成时,优先使用真实传输层往返而非手动实现的客户端 mock:
```go
func TestAPIIntegration(t *testing.T) {
// 使用假后端启动测试服务器
srv := httptest.NewServer(newFakeHandler())
t.Cleanup(srv.Close)
// 对测试服务器使用真实 HTTP 客户端
client := api.NewClient(srv.URL)
result, err := client.GetUser(context.Background(), "user-123")
if err != nil {
t.Fatalf("GetUser() error: %v", err)
}
if result.Name != "Test User" {
t.Errorf("GetUser().Name = %q, want %q", result.Name, "Test User")
}
}
```
使用生产客户端配合测试服务器,可以确保测试尽可能多地覆盖真实代码,避免模拟客户端行为的复杂性。
---
## 常见错误
### 在 TestMain 中直接调用 os.Exit
`os.Exit` 立即终止进程 — defer 的清理函数永远不会执行。将设置/清理提取到辅助函数中,使 `defer` 能正确工作:
```go
// 不好:defer 不会执行
func TestMain(m *testing.M) {
setup()
defer cleanup()
os.Exit(m.Run()) // cleanup() 永远不会执行
}
// 好:提取到辅助函数中,使 defer 在 os.Exit 之前执行
func runTests(m *testing.M) int {
setup()
defer cleanup()
return m.Run()
}
func TestMain(m *testing.M) {
os.Exit(runTests(m))
}
```
@@ -0,0 +1,154 @@
# 表驱动测试、子测试和并行测试
在 Go 中组织表驱动测试和子测试的详细参考。
来源:Google Go Style Guide、Uber Go Style Guide。
---
## 基本结构
```go
func TestCompare(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "a", -1},
{"abc", "abc", 0},
}
for _, tt := range tests {
got := Compare(tt.a, tt.b)
if got != tt.want {
t.Errorf("Compare(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
}
}
}
```
---
## 最佳实践
当测试用例跨越多行或有相同类型的相邻字段时,**使用字段名**:
```go
tests := []struct {
name string
input string
want int
}{
{name: "empty", input: "", want: 0},
{name: "single", input: "a", want: 1},
}
```
**不要通过索引标识行** — 在失败信息中包含输入,而非使用 `Case #%d failed`。
---
## 避免表测试中的复杂性
当测试用例需要复杂设置、条件 mock 或多个分支时,优先使用单独的测试函数而非表测试。
```go
// 不好:太多条件字段使测试难以理解
tests := []struct {
give string
want string
wantErr error
shouldCallX bool
shouldCallY bool
giveXResponse string
giveXErr error
giveYResponse string
giveYErr error
}{...}
for _, tt := range tests {
t.Run(tt.give, func(t *testing.T) {
if tt.shouldCallX {
xMock.EXPECT().Call().Return(tt.giveXResponse, tt.giveXErr)
}
if tt.shouldCallY {
yMock.EXPECT().Call().Return(tt.giveYResponse, tt.giveYErr)
}
// ...
})
}
// 好:单独的专注测试更清晰
func TestShouldCallX(t *testing.T) {
xMock.EXPECT().Call().Return("XResponse", nil)
got, err := DoComplexThing("inputX", xMock, yMock)
// 断言...
}
func TestShouldCallYAndFail(t *testing.T) {
yMock.EXPECT().Call().Return("YResponse", nil)
_, err := DoComplexThing("inputY", xMock, yMock)
// 断言错误...
}
```
**表测试最适合以下场景:**
- 所有用例运行相同逻辑(无条件断言)
- 所有用例的设置相同
- 没有基于测试用例字段的条件 mock
- 所有表字段在所有测试中都被使用
如果测试体短且直接,单个 `shouldErr` 字段用于成功/失败检查是可以接受的。
---
## 子测试
使用 `t.Run` 实现更好的组织、过滤和并行执行。
### 子测试命名
- 使用清晰、简洁的名称:`t.Run("empty_input", ...)`、`t.Run("hu_to_en", ...)`
- 避免冗长的描述或斜杠(斜杠会破坏测试过滤)
- 子测试必须独立 — 不共享状态或执行顺序依赖
### 带子测试的表测试
```go
func TestTranslate(t *testing.T) {
tests := []struct {
name, srcLang, dstLang, input, want string
}{
{"hu_en_basic", "hu", "en", "köszönöm", "thank you"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Translate(tt.srcLang, tt.dstLang, tt.input); got != tt.want {
t.Errorf("Translate(%q, %q, %q) = %q, want %q",
tt.srcLang, tt.dstLang, tt.input, got, tt.want)
}
})
}
}
```
---
## 并行测试
在表测试中使用 `t.Parallel()` 时,注意循环变量捕获:
```go
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Go 1.22+:tt 在每次迭代中被正确捕获
// Go 1.21-:在此处添加 "tt := tt" 来捕获变量
got := Process(tt.give)
if got != tt.want {
t.Errorf("Process(%q) = %q, want %q", tt.give, got, tt.want)
}
})
}
```
@@ -0,0 +1,131 @@
# 测试辅助函数、断言和比较
编写测试辅助函数、避免断言库以及在 t.Error 和 t.Fatal 之间选择的详细参考。
来源:Google Go Style Guide、Uber Go Style Guide。
---
## 测试辅助函数模式
测试辅助函数必须首先调用 `t.Helper()`,使失败指向调用者。
对设置失败使用 `t.Fatal`,对清理使用 `t.Cleanup`。
```go
func mustLoadTestData(t *testing.T, filename string) []byte {
t.Helper()
data, err := os.ReadFile(filename)
if err != nil {
t.Fatalf("Setup failed: could not read %s: %v", filename, err)
}
return data
}
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Could not open database: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
```
**关键规则:**
- 将 `t.Helper()` 作为第一条语句调用,将失败归因于调用者
- 对设置失败使用 `t.Fatal`(不要从辅助函数返回错误)
- 使用 `t.Cleanup()` 进行清理而非 defer — 即使测试调用 `t.FailNow` 它也会执行
---
## 避免断言库
> **规范**:不要创建或使用断言库。
断言库会碎片化开发者体验,并且经常产生无用的失败信息。
```go
// 不好:
assert.IsNotNil(t, "obj", obj)
assert.StringEq(t, "obj.Type", obj.Type, "blogPost")
assert.IntEq(t, "obj.Comments", obj.Comments, 2)
// 好:使用 cmp 包和标准比较
want := BlogPost{
Type: "blogPost",
Comments: 2,
Body: "Hello, world!",
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetPost() mismatch (-want +got):\n%s", diff)
}
```
### 领域特定比较
对于领域特定比较,返回值或错误而非调用 `t.Error`:
```go
func postLength(p BlogPost) int { return len(p.Body) }
func TestBlogPost(t *testing.T) {
post := BlogPost{Body: "Hello"}
if got, want := postLength(post), 5; got != want {
t.Errorf("postLength(post) = %v, want %v", got, want)
}
}
```
---
## 比较和 Diff
对于复杂类型,优先使用 `cmp.Equal` 和 `cmp.Diff`。始终在 diff 信息中包含方向键 `(-want +got)`。
```go
// struct 比较
want := &Doc{Type: "blogPost", Authors: []string{"isaac", "albert"}}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("AddPost() mismatch (-want +got):\n%s", diff)
}
// Protocol buffers
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("Foo() mismatch (-want +got):\n%s", diff)
}
```
**避免不稳定的比较** — 不要比较可能变化的 JSON/序列化输出。改为语义比较。
---
## t.Error vs t.Fatal:详细指南
使用 `t.Error` 保持测试继续运行,在一次运行中报告所有失败:
```go
// 好:报告所有不匹配
if diff := cmp.Diff(wantMean, gotMean); diff != "" {
t.Errorf("Mean mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(wantVariance, gotVariance); diff != "" {
t.Errorf("Variance mismatch (-want +got):\n%s", diff)
}
```
当后续检查无意义时使用 `t.Fatal`:
```go
gotEncoded := Encode(input)
if gotEncoded != wantEncoded {
t.Fatalf("Encode(%q) = %q, want %q", input, gotEncoded, wantEncoded)
}
gotDecoded, err := Decode(gotEncoded)
if err != nil {
t.Fatalf("Decode(%q) error: %v", gotEncoded, err)
}
```
### 不要从 Goroutine 中调用 t.Fatal
> **规范**:绝不在测试 goroutine 以外的 goroutine 中调用 `t.Fatal`、`t.Fatalf` 或 `t.FailNow`。改为使用 `t.Error` 并让 goroutine 自然返回。
@@ -0,0 +1,167 @@
# 测试组织参考
来源:Google Go Style Guide(最佳实践、决策)。
---
## 测试替身类型
| 替身 | 用途 | 有状态? | 验证调用? |
|------|------|----------|-----------|
| Stub | 返回预设数据 | 否 | 否 |
| Fake | 可工作但简化的实现 | 是 | 否 |
| Spy | 记录调用以供后续检查 | 是 | 是 |
**优先使用 fake 而非 mock。** Fake 更具可读性且不需要 mock 框架。仅在验证副作用(例如,分析事件)时使用 spy。
```go
// Fake:可工作的内存实现
type FakeUserStore struct {
users map[string]*User
}
func (f *FakeUserStore) GetUser(id string) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
// Spy:记录调用以供后续断言
type SpyEmailSender struct{ Sent []string }
func (s *SpyEmailSender) Send(to, body string) error {
s.Sent = append(s.Sent, to)
return nil
}
```
---
## 测试替身命名约定
> **建议**:为测试替身(stub、fake、spy)遵循一致的命名。
**包命名**:在生产代码旁边创建一个 `*test` 包(例如,为 `creditcard` 包创建 `creditcardtest`,为独立的 fake 服务创建 `fakeauthservice`)。
```go
// 好:在 creditcardtest 包中
// 单个替身 — 使用简单名称
type Stub struct{}
func (Stub) Charge(*creditcard.Card, money.Money) error { return nil }
// 多种行为 — 按行为命名
type AlwaysCharges struct{}
type AlwaysDeclines struct{}
// 多种类型 — 包含类型名
type StubService struct{}
type StubStoredValue struct{}
```
**局部变量**:为测试替身变量添加替身类型前缀,使调用处更清晰:
```go
// 好:替身类型立即可见
spyCC := &creditcardtest.Spy{}
stubDB := &dbtest.Stub{Balance: 100}
// 不好:模糊 — 这是真实的还是替身?
cc := &creditcardtest.Spy{}
db := &dbtest.Stub{Balance: 100}
```
---
## 独立测试辅助包
当多个包需要相同的替身、辅助函数有足够的逻辑需要自己的测试、或者你想为接口实现者提供验收测试套件时,创建独立的测试辅助包。
| 模式 | 使用场景 | 示例 |
|------|----------|------|
| `footest` | `foo` 包的通用测试辅助 | `creditcardtest`、`usertest` |
| `fakeX` | 独立的 fake 服务包 | `fakeauthservice`、`fakestorage` |
```go
package usertest
func NewFakeStore(t *testing.T, users ...*user.User) *FakeUserStore {
t.Helper()
store := &FakeUserStore{users: make(map[string]*user.User)}
for _, u := range users {
store.users[u.ID] = u
}
return store
}
```
导出接受 `*testing.T` 的构造函数,以便调用 `t.Helper()` 和 `t.Cleanup()`。
---
## 测试包
| 包声明 | 使用场景 |
|--------|----------|
| `package foo` | 同包测试,可以访问非导出标识符 |
| `package foo_test` | 黑盒测试,避免循环依赖 |
两者都放在同一目录下的 `foo_test.go` 文件中。
**使用 `package foo`(白盒)** 当你需要测试非导出函数或内部状态时。
**使用 `package foo_test`(黑盒)** 当仅测试公共 API、打破导入循环或验证外部可用性时。
```go
package parser_test // 黑盒:仅测试导出的 API
import "mymodule/parser"
func TestParse(t *testing.T) {
got, err := parser.Parse("input")
// ...
}
```
如果黑盒测试需要非导出符号,在 `package foo`(非 `foo_test`)中创建 `export_test.go` 来暴露它。谨慎使用。
---
## 设置作用域
> **建议**:保持设置仅限于需要它的测试。
每个测试中的显式设置更清晰,避免惩罚不相关的测试:
```go
// 好:在需要它的测试中显式设置
func TestParseData(t *testing.T) {
data := mustLoadDataset(t)
// ...
}
func TestUnrelated(t *testing.T) {
// 不需要为数据集加载付出代价
}
```
**避免使用全局 `init` 进行测试设置** — 它会对文件中的每个测试运行,即使是不相关的测试。
**子测试设置**:当一组子测试共享设置时,使用带 `t.Run` 的父测试:
```go
func TestDatabase(t *testing.T) {
db := setupTestDB(t)
t.Run("Insert", func(t *testing.T) {
// 使用 db
})
t.Run("Select", func(t *testing.T) {
// 使用 db
})
}
```
这将数据库的生命周期限定在需要它的子测试范围内。仅在万不得已时使用 `TestMain`(参见 [INTEGRATION.md](INTEGRATION.md))。
@@ -0,0 +1,108 @@
# 可扩展验证 API
设计可重用测试验证函数的详细参考,调用者可以将其用于验收测试。来源:Google Go Style Guide(最佳实践)。
---
## `*test` 包导出模式
当你拥有一个由他人实现的接口时,在配套的 `*test` 包中导出一个验证函数。这使实现者无需复制你的测试逻辑即可验证正确性。
```go
// Package storagetest 为 storage.Backend 提供验收测试。
package storagetest
// Verify 对任何 storage.Backend 运行验证套件。
// 返回描述第一个违规行为的错误,成功时返回 nil。
func Verify(b storage.Backend) error {
if err := verifyRoundTrip(b); err != nil {
return fmt.Errorf("round-trip: %w", err)
}
if err := verifyNotFound(b); err != nil {
return fmt.Errorf("not-found: %w", err)
}
return nil
}
```
调用者编写一个薄测试来接入他们的实现:
```go
func TestMyBackend(t *testing.T) {
b := mybackend.New(t)
if err := storagetest.Verify(b); err != nil {
t.Errorf("MyBackend failed acceptance: %v", err)
}
}
```
---
## 设计可扩展的验证函数
**返回错误,而非 `*testing.T` 失败。** 这使验证函数可作为普通 Go 函数使用 — 调用者决定违规是 `t.Error` 还是 `t.Fatal`。
```go
// 好:返回错误 — 调用者控制测试流程
func ExercisePlayer(b *chess.Board, p chess.Player) error {
move := p.Move()
if putsOwnKingIntoCheck(b, move) {
return &IllegalMoveError{Move: move, Reason: "puts own king in check"}
}
return nil
}
// 不好:调用 t.Fatal — 调用者失去控制
func ExercisePlayer(t *testing.T, b *chess.Board, p chess.Player) {
t.Helper()
move := p.Move()
if putsOwnKingIntoCheck(b, move) {
t.Fatalf("illegal move: %v puts own king in check", move)
}
}
```
**在需要丰富诊断信息时使用自定义错误类型**:
```go
type IllegalMoveError struct {
Move chess.Move
Reason string
}
func (e *IllegalMoveError) Error() string {
return fmt.Sprintf("illegal move %v: %s", e.Move, e.Reason)
}
```
---
## 何时使用验证 API vs 简单辅助函数
| 场景 | 使用方式 |
|------|----------|
| 你拥有的接口,由他人实现 | `*test` 包中的验证 API |
| 同一包中跨测试共享设置 | 使用 `t.Helper()` 的测试辅助函数 |
| 在 2-3 个测试中重用的复杂断言 | 返回 `error` 或 `bool` 的辅助函数 |
| 一次性的设置或比较 | 内联测试代码 |
**验证 API** 在以下场景值得额外的包:
- 多个外部包将实现你的接口
- 契约有容易被忽略的非显而易见的不变量
- 你想为"正确行为"提供单一事实来源
**简单辅助函数** 在以下场景更好:
- 辅助函数是直接的设置或比较函数
- 重用是偶然的,不是已发布契约的一部分
---
## 命名约定
使用表示范围的动词命名函数:`Verify`、`Exercise`、`RunConformance`。接受被测接口作为参数 — 绝不在验证包内部构造实现。
| 包 | 函数 | 用途 |
|----|------|------|
| `storagetest` | `Verify` | 验证 `storage.Backend` |
| `chesstest` | `ExercisePlayer` | 验证 `chess.Player` |
| `cachetest` | `RunConformance` | `cache.Cache` 的完整一致性套件 |
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.0.0"
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Generate a table-driven test scaffold for a Go function
USAGE
bash $SCRIPT_NAME [options] <FuncName> <package>
DESCRIPTION
Outputs a table-driven test file for the given function and package.
By default writes to stdout; use --output to write to a file.
Exits 0 on success, 2 on error.
OPTIONS
-h, --help Show this help message
-v, --version Show version
--output FILE Write to FILE instead of stdout
--force Allow --output to overwrite an existing file
--parallel Include t.Parallel() in generated test
--json Output structured JSON metadata to stdout
ARGUMENTS
FuncName Name of the function to test (must be exported/uppercase)
package Go package name for the test file
EXAMPLES
bash $SCRIPT_NAME ParseConfig config
bash $SCRIPT_NAME --parallel ParseConfig config
bash $SCRIPT_NAME --output config/parse_config_test.go ParseConfig config
bash $SCRIPT_NAME --force --output config/parse_config_test.go ParseConfig config
bash $SCRIPT_NAME --json --output config/parse_config_test.go ParseConfig config
bash $SCRIPT_NAME ParseConfig config > config/parse_config_test.go
EOF
}
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
s="${s//$'\n'/\\n}"
printf '%s' "$s"
}
OUTPUT=""
PARALLEL=false
JSON_OUTPUT=false
FORCE=false
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
--output) OUTPUT="${2:?error: --output requires a file path}"; shift 2 ;;
--force) FORCE=true; shift ;;
--parallel) PARALLEL=true; shift ;;
--json) JSON_OUTPUT=true; shift ;;
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
if [[ ${#POSITIONAL[@]} -lt 2 ]]; then
echo "error: FuncName and package are required" >&2
usage >&2
exit 2
fi
FUNC="${POSITIONAL[0]}"
PKG="${POSITIONAL[1]}"
if [[ ! "$FUNC" =~ ^[A-Z] ]]; then
echo "error: FuncName '$FUNC' must start with an uppercase letter" >&2
exit 2
fi
generate_test() {
local parallel_top="" parallel_sub=""
if $PARALLEL; then
parallel_top=$'\tt.Parallel()\n'
parallel_sub=$'\t\t\tt.Parallel()\n'
fi
cat <<EOF
package ${PKG}
import (
"testing"
)
func Test${FUNC}(t *testing.T) {
${parallel_top} tests := []struct {
name string
give string // TODO: replace with actual input type
want string // TODO: replace with actual output type
}{
{
name: "basic case",
give: "",
want: "",
},
// TODO: add more test cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
${parallel_sub} got := ${FUNC}(tt.give)
if got != tt.want {
t.Errorf("${FUNC}(%q) = %q, want %q", tt.give, got, tt.want)
}
// For richer diffs, consider:
// if diff := cmp.Diff(tt.want, got); diff != "" {
// t.Errorf("${FUNC}() mismatch (-want +got):\n%s", diff)
// }
})
}
}
EOF
}
if [[ -n "$OUTPUT" ]]; then
OUTPUT_DIR="$(dirname "$OUTPUT")"
if [[ ! -d "$OUTPUT_DIR" ]]; then
echo "error: directory '$OUTPUT_DIR' does not exist" >&2
exit 2
fi
if [[ -f "$OUTPUT" ]] && ! $FORCE; then
echo "error: '$OUTPUT' already exists (use --force to overwrite)" >&2
exit 2
fi
generate_test > "$OUTPUT"
if $JSON_OUTPUT; then
FUNC_ESC="$(json_escape "$FUNC")"
PKG_ESC="$(json_escape "$PKG")"
OUTPUT_ESC="$(json_escape "$OUTPUT")"
cat <<EOF
{"func":"$FUNC_ESC","package":"$PKG_ESC","output_file":"$OUTPUT_ESC","parallel":$PARALLEL,"written":true}
EOF
else
echo "Wrote test scaffold to $OUTPUT"
fi
else
if $JSON_OUTPUT; then
generate_test >&2
FUNC_ESC="$(json_escape "$FUNC")"
PKG_ESC="$(json_escape "$PKG")"
cat <<EOF
{"func":"$FUNC_ESC","package":"$PKG_ESC","output_file":"","parallel":$PARALLEL,"written":false}
EOF
else
generate_test
fi
fi
exit 0