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
@@ -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` 的完整一致性套件 |