go-test-quality
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGo Test Quality
Go 测试质量
Tests are production code. They run in CI on every commit, they document behavior,
and they're the first thing you read when a function breaks at 3am.
Write them with the same care you'd give to code that handles money.
测试代码也是生产级代码。它们会在每次提交时在CI中运行,用于记录代码行为,而且当某个函数在凌晨3点出现故障时,测试代码是你首先要查看的内容。编写测试代码时,要像编写处理资金的代码一样谨慎。
1. Test Design Philosophy
1. 测试设计理念
Test behavior, not implementation
测试行为,而非实现细节
go
// ✅ Good — tests what the function DOES
func TestTransferFunds_InsufficientBalance(t *testing.T) {
from := NewAccount("alice", 100)
to := NewAccount("bob", 0)
err := TransferFunds(from, to, 150)
require.ErrorIs(t, err, ErrInsufficientFunds)
assert.Equal(t, 100, from.Balance(), "sender balance should be unchanged")
assert.Equal(t, 0, to.Balance(), "receiver balance should be unchanged")
}
// ❌ Bad — tests HOW the function does it
func TestTransferFunds_InsufficientBalance(t *testing.T) {
// asserts that debit() was called before credit()
// asserts that rollback() was called
// asserts internal mutex was locked
}go
// ✅ 良好示例 — 测试函数的实际行为
func TestTransferFunds_InsufficientBalance(t *testing.T) {
from := NewAccount("alice", 100)
to := NewAccount("bob", 0)
err := TransferFunds(from, to, 150)
require.ErrorIs(t, err, ErrInsufficientFunds)
assert.Equal(t, 100, from.Balance(), "转账发起方余额应保持不变")
assert.Equal(t, 0, to.Balance(), "转账接收方余额应保持不变")
}
// ❌ 不良示例 — 测试函数的实现细节
func TestTransferFunds_InsufficientBalance(t *testing.T) {
// 断言debit()在credit()之前被调用
// 断言rollback()被调用
// 断言内部互斥锁已锁定
}One assertion per logical concept
每个测试验证一个逻辑概念
A test should verify one behavior. Multiple calls are fine when they
verify different facets of the SAME behavior (e.g., both accounts after a transfer).
But a test that checks creation AND update AND deletion is three tests pretending
to be one.
assert一个测试应该只验证一种行为。当多个调用是为了验证同一行为的不同方面时(例如转账后两个账户的状态),是可以接受的。但如果一个测试同时检查创建、更新和删除操作,那其实是三个测试伪装成了一个。
assertName tests like bug reports
测试命名要像Bug报告一样清晰
The test name should describe the scenario so clearly that when it fails,
you already know what broke without reading the test body:
go
// ✅ Good — reads like a sentence
func TestOrderService_Cancel_RefundsPartiallyShippedItems(t *testing.T) { ... }
func TestParseConfig_ReturnsErrorOnMissingRequiredField(t *testing.T) { ... }
func TestRateLimiter_AllowsBurstAfterCooldown(t *testing.T) { ... }
// ❌ Bad — says nothing useful
func TestCancel(t *testing.T) { ... }
func TestParseConfig2(t *testing.T) { ... }
func TestRateLimiter_Success(t *testing.T) { ... }测试名称应清晰描述场景,这样当测试失败时,你无需查看测试代码就能知道哪里出了问题:
go
// ✅ 良好示例 — 名称像一句话一样易懂
func TestOrderService_Cancel_RefundsPartiallyShippedItems(t *testing.T) { ... }
func TestParseConfig_ReturnsErrorOnMissingRequiredField(t *testing.T) { ... }
func TestRateLimiter_AllowsBurstAfterCooldown(t *testing.T) { ... }
// ❌ 不良示例 — 没有任何有用信息
func TestCancel(t *testing.T) { ... }
func TestParseConfig2(t *testing.T) { ... }
func TestRateLimiter_Success(t *testing.T) { ... }2. Subtests for Organized Scenarios
2. 用子测试组织相关场景
Use to group related scenarios under a parent test. Each subtest
gets its own setup, its own failure, and its own name in the output:
t.Rungo
func TestUserService_Create(t *testing.T) {
svc := setupUserService(t)
t.Run("succeeds with valid input", func(t *testing.T) {
user, err := svc.Create(ctx, CreateUserInput{
Name: "Alice",
Email: "alice@example.com",
})
require.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Equal(t, "Alice", user.Name)
})
t.Run("rejects duplicate email", func(t *testing.T) {
_, _ = svc.Create(ctx, CreateUserInput{
Name: "Alice", Email: "taken@example.com",
})
_, err := svc.Create(ctx, CreateUserInput{
Name: "Bob", Email: "taken@example.com",
})
require.ErrorIs(t, err, ErrDuplicateEmail)
})
t.Run("rejects empty name", func(t *testing.T) {
_, err := svc.Create(ctx, CreateUserInput{
Name: "", Email: "valid@example.com",
})
var valErr *ValidationError
require.ErrorAs(t, err, &valErr)
assert.Equal(t, "name", valErr.Field)
})
}Each subtest is independent, readable, and debuggable. When
fails, you see exactly that name in CI output — not .
rejects duplicate emailTestUserService_Create/case_3使用将相关场景分组到父测试下。每个子测试都有自己的初始化逻辑、失败提示和输出名称:
t.Rungo
func TestUserService_Create(t *testing.T) {
svc := setupUserService(t)
t.Run("合法输入创建成功", func(t *testing.T) {
user, err := svc.Create(ctx, CreateUserInput{
Name: "Alice",
Email: "alice@example.com",
})
require.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Equal(t, "Alice", user.Name)
})
t.Run("拒绝重复邮箱", func(t *testing.T) {
_, _ = svc.Create(ctx, CreateUserInput{
Name: "Alice", Email: "taken@example.com",
})
_, err := svc.Create(ctx, CreateUserInput{
Name: "Bob", Email: "taken@example.com",
})
require.ErrorIs(t, err, ErrDuplicateEmail)
})
t.Run("拒绝空名称", func(t *testing.T) {
_, err := svc.Create(ctx, CreateUserInput{
Name: "", Email: "valid@example.com",
})
var valErr *ValidationError
require.ErrorAs(t, err, &valErr)
assert.Equal(t, "name", valErr.Field)
})
}每个子测试都是独立、易读且易于调试的。当"拒绝重复邮箱"测试失败时,你会在CI输出中看到确切的名称,而不是。
TestUserService_Create/case_33. Test Helpers Done Right
3. 正确编写测试辅助函数
Always call t.Helper()
t.Helper()务必调用t.Helper()
t.Helper()This makes failure messages point to the caller, not the helper:
go
func createTestUser(t *testing.T, svc *UserService, name string) *User {
t.Helper()
user, err := svc.Create(context.Background(), CreateUserInput{
Name: name,
Email: name + "@test.com",
})
require.NoError(t, err)
return user
}这会让错误信息指向调用辅助函数的位置,而非辅助函数内部:
go
func createTestUser(t *testing.T, svc *UserService, name string) *User {
t.Helper()
user, err := svc.Create(context.Background(), CreateUserInput{
Name: name,
Email: name + "@test.com",
})
require.NoError(t, err)
return user
}Factory functions with functional options
带函数式选项的工厂函数
For complex test objects, avoid a constructor with 15 parameters.
Use defaults with overrides:
go
func newTestOrder(t *testing.T, opts ...func(*Order)) *Order {
t.Helper()
o := &Order{
ID: uuid.New(),
UserID: uuid.New(),
Status: OrderStatusPending,
Total: 9999, // $99.99
CreatedAt: time.Now(),
}
for _, opt := range opts {
opt(o)
}
return o
}
// Usage — only override what matters for THIS test
func TestOrder_Cancel_RejectsShippedOrders(t *testing.T) {
order := newTestOrder(t, func(o *Order) {
o.Status = OrderStatusShipped
})
err := order.Cancel()
require.ErrorIs(t, err, ErrCannotCancelShipped)
}对于复杂的测试对象,避免使用有15个参数的构造函数。使用默认值加覆盖选项的方式:
go
func newTestOrder(t *testing.T, opts ...func(*Order)) *Order {
t.Helper()
o := &Order{
ID: uuid.New(),
UserID: uuid.New(),
Status: OrderStatusPending,
Total: 9999, // $99.99
CreatedAt: time.Now(),
}
for _, opt := range opts {
opt(o)
}
return o
}
// 使用方式 — 仅覆盖当前测试需要的参数
func TestOrder_Cancel_RejectsShippedOrders(t *testing.T) {
order := newTestOrder(t, func(o *Order) {
o.Status = OrderStatusShipped
})
err := order.Cancel()
require.ErrorIs(t, err, ErrCannotCancelShipped)
}Cleanup with t.Cleanup
t.Cleanup用t.Cleanup
做资源清理
t.CleanupPrefer over — it runs even if the test calls ,
and it's scoped to the test, not the function:
t.Cleanupdefert.FailNow()go
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("postgres", testDSN)
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return db
}优先使用而非——即使测试调用,它也会执行,而且它的作用域是测试而非函数:
t.Cleanupdefert.FailNow()go
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("postgres", testDSN)
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return db
}4. Golden File Testing
4. 黄金文件测试
For complex outputs (JSON responses, HTML, SQL queries, protobuf), comparing
against golden files is more maintainable than inline assertions:
go
var update = flag.Bool("update", false, "update golden files")
func TestRenderInvoice(t *testing.T) {
invoice := buildTestInvoice()
got, err := RenderInvoice(invoice)
require.NoError(t, err)
golden := filepath.Join("testdata", t.Name()+".golden")
if *update {
// Run: go test -update to regenerate golden files
require.NoError(t, os.WriteFile(golden, got, 0644))
}
want, err := os.ReadFile(golden)
require.NoError(t, err)
assert.Equal(t, string(want), string(got))
}Golden files live in directories (which ignores).
Commit them to git — they ARE the expected output. Review diffs in PRs.
testdata/go build对于复杂输出(JSON响应、HTML、SQL查询、protobuf),与黄金文件对比比内联断言更易于维护:
go
var update = flag.Bool("update", false, "更新黄金文件")
func TestRenderInvoice(t *testing.T) {
invoice := buildTestInvoice()
got, err := RenderInvoice(invoice)
require.NoError(t, err)
golden := filepath.Join("testdata", t.Name()+".golden")
if *update {
// 运行:go test -update 以重新生成黄金文件
require.NoError(t, os.WriteFile(golden, got, 0644))
}
want, err := os.ReadFile(golden)
require.NoError(t, err)
assert.Equal(t, string(want), string(got))
}黄金文件存放在目录中(会忽略该目录)。将它们提交到git——它们就是预期输出。在PR中要审查黄金文件的差异。
testdata/go build5. HTTP Handler Testing with httptest
httptest5. 用httptest
测试HTTP处理器
httptestUse for unit-style handler tests:
httptest.NewRecordergo
func TestUserHandler_GetByID(t *testing.T) {
store := &mockUserStore{
getByIDFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
handler := NewUserHandler(store, zap.NewNop())
t.Run("returns user as JSON", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
req.SetPathValue("id", "123")
rec := httptest.NewRecorder()
handler.HandleGet(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Header().Get("Content-Type"), "application/json")
var body map[string]string
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
assert.Equal(t, "Alice", body["name"])
})
t.Run("returns 404 for unknown user", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/unknown", nil)
req.SetPathValue("id", "unknown")
rec := httptest.NewRecorder()
handler.HandleGet(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}使用进行单元级的处理器测试:
httptest.NewRecordergo
func TestUserHandler_GetByID(t *testing.T) {
store := &mockUserStore{
getByIDFunc: func(ctx context.Context, id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
handler := NewUserHandler(store, zap.NewNop())
t.Run("返回用户JSON数据", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
req.SetPathValue("id", "123")
rec := httptest.NewRecorder()
handler.HandleGet(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Header().Get("Content-Type"), "application/json")
var body map[string]string
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
assert.Equal(t, "Alice", body["name"])
})
t.Run("未知用户返回404", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/unknown", nil)
req.SetPathValue("id", "unknown")
rec := httptest.NewRecorder()
handler.HandleGet(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}Full server test with httptest.NewServer
:
httptest.NewServer用httptest.NewServer
进行完整服务器测试:
httptest.NewServergo
func TestAPI_CreateUser_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
app := setupApp(t)
srv := httptest.NewServer(app.Router())
t.Cleanup(srv.Close)
resp, err := http.Post(srv.URL+"/api/v1/users",
"application/json",
strings.NewReader(`{"name":"Alice","email":"alice@test.com"}`))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}go
func TestAPI_CreateUser_Integration(t *testing.T) {
if testing.Short() {
t.Skip("跳过集成测试")
}
app := setupApp(t)
srv := httptest.NewServer(app.Router())
t.Cleanup(srv.Close)
resp, err := http.Post(srv.URL+"/api/v1/users",
"application/json",
strings.NewReader(`{"name":"Alice","email":"alice@test.com"}`))
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
}6. Mocking Strategies
6. 模拟策略
Interface-based mocks (preferred for ≤3 methods):
基于接口的模拟(方法数≤3时首选):
go
type mockNotifier struct {
sendFunc func(ctx context.Context, to, msg string) error
sent []string
}
func (m *mockNotifier) Send(ctx context.Context, to, msg string) error {
m.sent = append(m.sent, to)
if m.sendFunc != nil {
return m.sendFunc(ctx, to, msg)
}
return nil
}go
type mockNotifier struct {
sendFunc func(ctx context.Context, to, msg string) error
sent []string
}
func (m *mockNotifier) Send(ctx context.Context, to, msg string) error {
m.sent = append(m.sent, to)
if m.sendFunc != nil {
return m.sendFunc(ctx, to, msg)
}
return nil
}Function injection for simple seams:
针对简单接缝的函数注入:
go
type Service struct {
now func() time.Time
randID func() string
}
// Production: svc := &Service{now: time.Now, randID: uuid.NewString}
// Test: svc := &Service{now: fixedTime, randID: func() string { return "abc" }}go
type Service struct {
now func() time.Time
randID func() string
}
// 生产环境:svc := &Service{now: time.Now, randID: uuid.NewString}
// 测试环境:svc := &Service{now: fixedTime, randID: func() string { return "abc" }}What NOT to mock:
不要模拟这些内容:
- Value objects and pure functions — just call them
- The standard library — test the real , not a mock
json.Marshal - Your own code in the same package — test the real thing
- 值对象和纯函数——直接调用它们即可
- 标准库——测试真实的,而非模拟版本
json.Marshal - 同一包内的自有代码——测试真实实现
7. Integration Tests with Testcontainers
7. 用Testcontainers做集成测试
go
func TestPostgresUserStore(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
pg, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
require.NoError(t, err)
t.Cleanup(func() { pg.Terminate(ctx) })
connStr, err := pg.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
store, err := NewPostgresStore(connStr)
require.NoError(t, err)
t.Run("create and retrieve user", func(t *testing.T) {
created, err := store.Create(ctx, &User{Name: "Alice"})
require.NoError(t, err)
fetched, err := store.GetByID(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, "Alice", fetched.Name)
})
}Separate with build tags:
//go:build integrationRun with:
go test -tags=integration -count=1 ./...go
func TestPostgresUserStore(t *testing.T) {
if testing.Short() {
t.Skip("跳过集成测试")
}
ctx := context.Background()
pg, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
require.NoError(t, err)
t.Cleanup(func() { pg.Terminate(ctx) })
connStr, err := pg.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
store, err := NewPostgresStore(connStr)
require.NoError(t, err)
t.Run("创建并查询用户", func(t *testing.T) {
created, err := store.Create(ctx, &User{Name: "Alice"})
require.NoError(t, err)
fetched, err := store.GetByID(ctx, created.ID)
require.NoError(t, err)
assert.Equal(t, "Alice", fetched.Name)
})
}用构建标签区分:
//go:build integration运行命令:
go test -tags=integration -count=1 ./...8. Fuzz Testing (Go 1.18+)
8. 模糊测试(Go 1.18+)
Fuzz tests discover edge cases you'd never think of. Use for parsers,
validators, serializers — anything that takes arbitrary input:
go
func FuzzParseEmail(f *testing.F) {
f.Add("alice@example.com")
f.Add("")
f.Add("@")
f.Fuzz(func(t *testing.T, input string) {
result, err := ParseEmail(input)
if err != nil {
return // invalid input is fine, just don't panic
}
// Round-trip: parsing the output should give the same result
reparsed, err := ParseEmail(result.String())
require.NoError(t, err)
assert.Equal(t, result, reparsed)
})
}Run with:
go test -fuzz=FuzzParseEmail -fuzztime=30sIf your function can receive untrusted input, fuzz it.
模糊测试能发现你永远想不到的边缘情况。适用于解析器、验证器、序列化器——任何接收任意输入的组件:
go
func FuzzParseEmail(f *testing.F) {
f.Add("alice@example.com")
f.Add("")
f.Add("@")
f.Fuzz(func(t *testing.T, input string) {
result, err := ParseEmail(input)
if err != nil {
return // 无效输入是可接受的,只要不触发panic即可
}
// 往返测试:解析输出结果应得到相同的结果
reparsed, err := ParseEmail(result.String())
require.NoError(t, err)
assert.Equal(t, result, reparsed)
})
}运行命令:
go test -fuzz=FuzzParseEmail -fuzztime=30s如果你的函数会接收不可信输入,一定要进行模糊测试。
9. Parallel Tests
9. 并行测试
go
func TestSlugify(t *testing.T) {
t.Parallel()
t.Run("lowercases input", func(t *testing.T) {
t.Parallel()
assert.Equal(t, "hello-world", Slugify("Hello World"))
})
t.Run("strips special characters", func(t *testing.T) {
t.Parallel()
assert.Equal(t, "caf", Slugify("café!"))
})
}Do NOT use when tests share mutable state,
databases, files, or process-level state ().
t.Parallel()os.Setenvgo
func TestSlugify(t *testing.T) {
t.Parallel()
t.Run("将输入转为小写", func(t *testing.T) {
t.Parallel()
assert.Equal(t, "hello-world", Slugify("Hello World"))
})
t.Run("移除特殊字符", func(t *testing.T) {
t.Parallel()
assert.Equal(t, "caf", Slugify("café!"))
})
}当测试共享可变状态、数据库、文件或进程级状态(如)时,请勿使用。
os.Setenvt.Parallel()10. TestMain for Shared Setup
10. 用TestMain做共享初始化
Use when ALL tests in a package need expensive one-time setup:
go
var testDB *sql.DB
func TestMain(m *testing.M) {
var teardown func()
testDB, teardown = setupTestDatabase()
code := m.Run()
teardown()
os.Exit(code)
}Use sparingly — most tests don't need it.
当包内所有测试都需要昂贵的一次性初始化时使用:
go
var testDB *sql.DB
func TestMain(m *testing.M) {
var teardown func()
testDB, teardown = setupTestDatabase()
code := m.Run()
teardown()
os.Exit(code)
}谨慎使用——大多数测试不需要这个。
11. Coverage
11. 测试覆盖率
bash
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.outTargets: business logic 80%+, critical paths (auth, payments) 95%+,
handlers 70%+. Don't chase 100% on generated code and simple getters.
bash
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out目标: 业务逻辑覆盖率≥80%,关键路径(认证、支付)≥95%,处理器≥70%。不要为生成的代码和简单的getter追求100%覆盖率。
Anti-Patterns
反模式
- 🔴 Test with no assertions — always passes, proves nothing
- 🔴 for synchronization — use channels or polling
time.Sleep - 🔴 Test depends on execution order — each test must stand alone
- 🔴 Mocking everything — you end up testing your mocks, not your code
- 🟡 Test names like ,
Test1— name the scenarioTestSuccess - 🟡 Reaching into private fields — test through the public API
- 🟡 No edge cases: empty, nil, zero, max values, unicode
- 🟡 Giant shared setup — each test should set up only what it needs
- 🟢 Fuzz anything that takes untrusted input
- 🟢 Golden files for complex output comparisons
- 🔴 没有断言的测试——永远通过,毫无意义
- 🔴 用做同步——使用通道或轮询
time.Sleep - 🔴 测试依赖执行顺序——每个测试必须独立运行
- 🔴 模拟所有内容——最终你测试的是模拟对象,而非真实代码
- 🟡 像、
Test1这样的测试名称——要命名具体场景TestSuccess - 🟡 直接访问私有字段——通过公共API进行测试
- 🟡 未覆盖边缘情况:空值、nil、零值、最大值、Unicode字符
- 🟡 庞大的共享初始化——每个测试只应初始化自己需要的内容
- 🟢 对所有接收不可信输入的组件进行模糊测试
- 🟢 用黄金文件对比复杂输出
Verification Checklist
验证清单
- Every test has meaningful assertions (no empty test bodies)
- Test names describe the scenario, not the method
- called in every test utility function
t.Helper() - used for resource teardown
t.Cleanup() - used where safe, avoided where not
t.Parallel() - Integration tests guarded with or build tags
testing.Short() - Mocks are minimal — only mock external dependencies
- Edge cases covered: empty, nil, zero, boundary values
- passes
go test -race ./... - Coverage is meaningful, not just high numbers
- 每个测试都有有意义的断言(无空测试体)
- 测试名称描述场景,而非方法
- 每个测试工具函数都调用了
t.Helper() - 用做资源清理
t.Cleanup() - 在安全的场景使用,不安全的场景避免使用
t.Parallel() - 集成测试用或构建标签做防护
testing.Short() - 模拟是最小化的——只模拟外部依赖
- 覆盖边缘情况:空值、nil、零值、边界值
- 执行通过
go test -race ./... - 覆盖率有实际意义,不只是数字高