问题修复: - 修复 TimeoutMiddleware 死锁问题(嵌套锁调用) - 修复竞态条件(responseSent 标志确保只发送一次响应) - 基准测试超时从 5ms 改为 100ms 避免 race 检测不稳定 文档更新: - 添加中间件并发测试要点(testing_strategy_v1.md) - 添加 TimeoutMiddleware 并发安全经验(project_experience_summary.md) - 更新测试覆盖率报告 - 新建项目状态报告
703 lines
16 KiB
Markdown
703 lines
16 KiB
Markdown
# Supply API 测试方案 v1.2
|
||
|
||
## 1. 概述
|
||
|
||
本文档定义了 Supply API 项目的系统性测试策略,确保代码质量、可维护性和快速迭代能力。
|
||
遵循 Google Testing Blog 和 Atlassian Testing Guide 行业最佳实践。
|
||
|
||
---
|
||
|
||
## 2. 测试金字塔(标准三层)
|
||
|
||
### 2.1 金字塔结构
|
||
|
||
```
|
||
┌─────────────┐
|
||
│ E2E │ ← 5-10% (Playwright)
|
||
┌─────────────┐
|
||
│ Integration │ ← 15-20% (Store, DB, 真实依赖)
|
||
┌───────────────┐
|
||
│ Unit │ ← 70-80% (业务逻辑、领域模型)
|
||
└───────────────┘
|
||
```
|
||
|
||
### 2.2 各层定义
|
||
|
||
| 层级 | 目标占比 | 定义 | Build Tag | 速度目标 |
|
||
|------|----------|------|-----------|----------|
|
||
| E2E | 5-10% | 关键业务流程端到端验证 | `//go:build e2e` | < 1s |
|
||
| Integration | 15-20% | Store、Repository、DB 集成 | `//go:build integration` | < 100ms |
|
||
| Unit | 70-80% | 业务逻辑、领域模型、组件 | (默认) | < 10ms |
|
||
|
||
**注意**: 移除非标准的 "Component" 层,其包含在 Unit 层中。
|
||
|
||
---
|
||
|
||
## 3. 测试组织结构
|
||
|
||
### 3.1 文件命名规范
|
||
|
||
```
|
||
{package}_test.go # 单元测试(默认)
|
||
{package}_integration_test.go # 集成测试(需数据库)
|
||
{package}_e2e_test.go # E2E 测试(需完整环境)
|
||
{package}_slow_test.go # 慢速测试(默认跳过)
|
||
```
|
||
|
||
### 3.2 Build Tag 使用
|
||
|
||
```go
|
||
//go:build unit
|
||
// +build unit
|
||
|
||
package domain_test // 单元测试
|
||
|
||
//go:build integration
|
||
// +build integration
|
||
|
||
package repository_test // 集成测试
|
||
|
||
//go:build e2e
|
||
// +build e2e
|
||
|
||
package e2e_test // E2E 测试
|
||
|
||
//go:build slow
|
||
// +build slow
|
||
|
||
package slow_test // 慢速测试(CI中默认跳过)
|
||
```
|
||
|
||
### 3.3 测试包结构
|
||
|
||
```
|
||
internal/
|
||
├── domain/ # 领域模型
|
||
│ ├── account.go
|
||
│ ├── account_test.go # 账号单元测试
|
||
│ ├── package.go
|
||
│ ├── package_test.go # 套餐单元测试
|
||
│ └── invariants_test.go # 不变量测试
|
||
│
|
||
├── testutil/ # 测试工具包(新增)
|
||
│ ├── factory/ # 测试数据工厂
|
||
│ │ ├── account.go
|
||
│ │ ├── package.go
|
||
│ │ └── settlement.go
|
||
│ ├── mock/ # 统一Mock
|
||
│ │ └── mocks.go
|
||
│ └── assert/ # 自定义断言
|
||
│ └── assertions.go
|
||
│
|
||
├── middleware/ # HTTP中间件
|
||
│ ├── auth.go
|
||
│ └── auth_test.go # 认证测试
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 测试数据管理
|
||
|
||
### 4.1 测试数据工厂(新增)
|
||
|
||
```go
|
||
// internal/testutil/factory/account.go
|
||
|
||
type AccountFactory struct {
|
||
supplierID int64
|
||
provider Provider
|
||
accountType AccountType
|
||
credential string
|
||
riskAck bool
|
||
}
|
||
|
||
func NewAccountFactory() *AccountFactory {
|
||
return &AccountFactory{
|
||
supplierID: 1001,
|
||
provider: ProviderOpenAI,
|
||
accountType: AccountTypeAPIKey,
|
||
credential: "sk-test-key",
|
||
riskAck: true,
|
||
}
|
||
}
|
||
|
||
func (f *AccountFactory) WithSupplierID(id int64) *AccountFactory {
|
||
f.supplierID = id
|
||
return f
|
||
}
|
||
|
||
func (f *AccountFactory) WithProvider(p Provider) *AccountFactory {
|
||
f.provider = p
|
||
return f
|
||
}
|
||
|
||
func (f *AccountFactory) Build() *CreateAccountRequest {
|
||
return &CreateAccountRequest{
|
||
SupplierID: f.supplierID,
|
||
Provider: f.provider,
|
||
AccountType: f.accountType,
|
||
Credential: f.credential,
|
||
RiskAck: f.riskAck,
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
func TestAccountService_Create(t *testing.T) {
|
||
factory := NewAccountFactory()
|
||
|
||
// 正常场景
|
||
req := factory.Build()
|
||
|
||
// 边界场景
|
||
invalidReq := factory.
|
||
WithCredential("").
|
||
Build()
|
||
}
|
||
```
|
||
|
||
### 4.2 固定测试数据
|
||
|
||
```go
|
||
func TestAccountService_Create(t *testing.T) {
|
||
store := newMockAccountStore()
|
||
|
||
req := &CreateAccountRequest{
|
||
SupplierID: 1001,
|
||
Provider: ProviderOpenAI,
|
||
AccountType: AccountTypeAPIKey,
|
||
Credential: "sk-test-key",
|
||
RiskAck: true,
|
||
}
|
||
|
||
account, err := store.Create(context.Background(), req)
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 4.3 边界值测试
|
||
|
||
```go
|
||
tests := []struct {
|
||
name string
|
||
input float64
|
||
want bool
|
||
}{
|
||
{"zero", 0.0, true},
|
||
{"positive", 100.0, true},
|
||
{"negative", -1.0, false},
|
||
{"very large", 1e10, true},
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 单元测试规范
|
||
|
||
### 5.1 测试结构 (AAA模式)
|
||
|
||
```go
|
||
func TestXXX_Scenario(t *testing.T) {
|
||
// Arrange - 准备测试数据
|
||
store := newMockStore()
|
||
svc := NewService(store)
|
||
|
||
// Act - 执行被测操作
|
||
result, err := svc.DoSomething(ctx, req)
|
||
|
||
// Assert - 验证结果
|
||
assert.NoError(t, err)
|
||
assert.Equal(t, expected, result)
|
||
}
|
||
```
|
||
|
||
### 5.2 Mock 接口而非具体实现
|
||
|
||
```go
|
||
// ✅ 正确 - Mock 接口
|
||
type mockSettlementStore struct {
|
||
settlements map[int64]*Settlement
|
||
}
|
||
|
||
func (m *mockSettlementStore) GetByID(ctx context.Context, supplierID, id int64) (*Settlement, error) {
|
||
if s, ok := m.settlements[id]; ok && s.SupplierID == supplierID {
|
||
return s, nil
|
||
}
|
||
return nil, errors.New("not found")
|
||
}
|
||
|
||
// ❌ 错误 - Mock 具体类型
|
||
type mockRepo struct {
|
||
repo *repository.SettlementRepository
|
||
}
|
||
```
|
||
|
||
### 5.3 Mock 审计存储正确姿势
|
||
|
||
```go
|
||
type AuditStore interface {
|
||
Emit(ctx context.Context, event audit.Event) error
|
||
Query(ctx context.Context, filter audit.EventFilter) ([]audit.Event, error)
|
||
QueryWithTotal(ctx context.Context, filter audit.EventFilter) ([]audit.Event, int64, error)
|
||
GetByID(ctx context.Context, eventID string) (audit.Event, error)
|
||
}
|
||
|
||
// ✅ 正确 - 使用具体类型
|
||
func (m *mockAuditStore) Emit(ctx context.Context, event audit.Event) error {
|
||
return nil
|
||
}
|
||
|
||
// ✅ 错误模拟 - 返回错误
|
||
func (m *mockFailingAuditStore) Emit(ctx context.Context, event audit.Event) error {
|
||
return errors.New("audit emit failed")
|
||
}
|
||
```
|
||
|
||
### 5.4 表驱动测试
|
||
|
||
```go
|
||
func TestSettlementStatus_Transitions(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
from SettlementStatus
|
||
to SettlementStatus
|
||
expected bool
|
||
}{
|
||
{"pending to processing", SettlementStatusPending, SettlementStatusProcessing, true},
|
||
{"pending to completed", SettlementStatusPending, SettlementStatusCompleted, false},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result := ValidateStateTransition(tt.from, tt.to)
|
||
assert.Equal(t, tt.expected, result)
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 集成测试规范
|
||
|
||
### 6.1 Build Tag 隔离
|
||
|
||
```go
|
||
//go:build integration
|
||
// +build integration
|
||
|
||
package repository_test
|
||
|
||
import (
|
||
"testing"
|
||
"github.com/stretchr/testify/assert"
|
||
)
|
||
|
||
func TestIntegrationSettlementRepository(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping integration test in short mode")
|
||
}
|
||
// 需要真实的 PostgreSQL 或使用 sqlmock
|
||
}
|
||
```
|
||
|
||
### 6.2 使用 Test Database
|
||
|
||
```go
|
||
//go:build integration
|
||
// +build integration
|
||
|
||
func TestIntegrationSettlementRepository(t *testing.T) {
|
||
// 选项1: 使用 sqlmock
|
||
db, mock, _ := sqlmock.New()
|
||
defer db.Close()
|
||
|
||
// 选项2: 使用轻量级测试数据库
|
||
// 推荐: github.com/testcontainers/testcontainers-go
|
||
}
|
||
```
|
||
|
||
### 6.3 运行命令
|
||
|
||
```bash
|
||
# 只运行单元测试(默认)
|
||
go test ./...
|
||
|
||
# 包含集成测试
|
||
go test -tags=integration ./...
|
||
|
||
# 排除集成测试(快速模式)
|
||
go test -short ./...
|
||
|
||
# 运行慢速测试
|
||
go test -tags=slow ./...
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 覆盖率要求
|
||
|
||
### 7.1 模块覆盖率目标
|
||
|
||
| 模块 | 最低覆盖率 | 当前 | 状态 |
|
||
|------|-----------|------|------|
|
||
| domain | 70% | 71.2% | ✅ |
|
||
| middleware | 80% | 80.4% | ✅ |
|
||
| audit/handler | 75% | 79.6% | ✅ |
|
||
| audit/service | 80% | 83.0% | ✅ |
|
||
| audit/model | 80% | 93.8% | ✅ |
|
||
| audit/sanitizer | 80% | 84.3% | ✅ |
|
||
| security | 80% | 88.8% | ✅ |
|
||
| iam | 70% | 93.2% | ✅ |
|
||
|
||
### 7.2 覆盖率检查命令
|
||
|
||
```bash
|
||
# ✅ 推荐:单独验证关键模块(显示真实覆盖率)
|
||
go test -cover ./internal/domain/... # → 71.2%
|
||
go test -cover ./internal/middleware/... # → 80.4%
|
||
|
||
# ⚠️ 联合运行(覆盖率数值会被稀释)
|
||
go test -coverprofile=coverage.out ./...
|
||
go tool cover -html=coverage.out -o coverage.html
|
||
```
|
||
|
||
### 7.3 覆盖率未达标处理
|
||
|
||
1. 分析未覆盖代码路径
|
||
2. 添加针对性测试用例
|
||
3. 确认覆盖率达到目标
|
||
4. **禁止强行凑覆盖率而编写无意义测试**
|
||
|
||
---
|
||
|
||
## 8. 测试命名规范
|
||
|
||
### 8.1 函数命名
|
||
|
||
```
|
||
Test{Service}_{Method}_{Scenario}
|
||
|
||
示例:
|
||
- TestAccountService_Create_Success
|
||
- TestAccountService_Create_InvalidInput
|
||
- TestPackageService_Publish_ExpiredPackage
|
||
- TestSettlementService_Withdraw_ExceedsBalance
|
||
```
|
||
|
||
### 8.2 子测试命名
|
||
|
||
```go
|
||
func TestAccountService_Activate(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
setup func() *Account
|
||
supplierID int64
|
||
wantErr bool
|
||
}{
|
||
{
|
||
name: "activate pending account success",
|
||
setup: func() *Account { /* ... */ },
|
||
supplierID: 1001,
|
||
wantErr: false,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
// ...
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 并发与竞态测试
|
||
|
||
### 9.1 启用 Race 检测
|
||
|
||
```bash
|
||
# 运行所有测试并检测竞态条件
|
||
go test -race ./...
|
||
|
||
# 详细输出
|
||
go test -race -v ./internal/domain/...
|
||
```
|
||
|
||
### 9.2 并发安全测试示例
|
||
|
||
```go
|
||
func TestConcurrentAccountAccess(t *testing.T) {
|
||
store := newMockAccountStore()
|
||
var wg sync.WaitGroup
|
||
|
||
for i := 0; i < 10; i++ {
|
||
wg.Add(1)
|
||
go func(id int) {
|
||
defer wg.Done()
|
||
_, err := store.GetByID(context.Background(), 1001, 1)
|
||
assert.NoError(t, err)
|
||
}(i)
|
||
}
|
||
|
||
wg.Wait()
|
||
}
|
||
```
|
||
|
||
### 9.2 中间件并发测试要点
|
||
|
||
**中间件的并发安全问题通常体现在**:
|
||
- ResponseWriter 的并发写入
|
||
- 共享状态的竞争访问
|
||
- 超时与正常响应的冲突
|
||
|
||
**TimeoutMiddleware 正确测试模式**:
|
||
|
||
```go
|
||
// ✅ 正确:超时设置足够长(>100ms),确保正常完成
|
||
func TestWithTimeoutMiddleware_NormalCompletion(t *testing.T) {
|
||
handler := WithTimeoutMiddleware(nextHandler, 100*time.Millisecond)
|
||
|
||
req := httptest.NewRequest("GET", "/", nil)
|
||
w := httptest.NewRecorder()
|
||
|
||
handler.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Errorf("expected status 200, got %d", w.Code)
|
||
}
|
||
}
|
||
|
||
// ✅ 正确:使用 WaitGroup 确保 handler 完成后再检查
|
||
func TestWithTimeoutMiddleware_Concurrent(t *testing.T) {
|
||
var wg sync.WaitGroup
|
||
wg.Add(1)
|
||
|
||
go func() {
|
||
defer wg.Done()
|
||
handler.ServeHTTP(w, req)
|
||
}()
|
||
|
||
wg.Wait()
|
||
// 现在可以安全检查结果
|
||
}
|
||
```
|
||
|
||
**⚠️ 常见错误**:
|
||
|
||
```go
|
||
// ❌ 错误:超时设置过短(<10ms)导致 race 检测下不稳定
|
||
timeoutHandler := WithTimeoutMiddleware(handler, 1*time.Millisecond)
|
||
|
||
// ❌ 错误:测试并发写入 ResponseRecorder 但不等待完成
|
||
go func() {
|
||
handler.ServeHTTP(w, req) // 可能还在执行
|
||
}()
|
||
time.Sleep(10 * time.Millisecond)
|
||
assert.Equal(t, 200, w.Code) // w 可能尚未写入
|
||
|
||
// ❌ 错误:假设 select 会优先选择已关闭的 channel
|
||
// 当 handlerDone 和 timeout 同时就绪时,行为是未定义的
|
||
```
|
||
|
||
### 9.3 Race 检测必须通过
|
||
|
||
所有并发测试必须在 race 模式下通过:
|
||
|
||
```bash
|
||
# 必须验证
|
||
go test -race ./internal/middleware/...
|
||
|
||
# 基准测试也需要 race 检测
|
||
go test -race -bench=. ./internal/benchmark/...
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 性能回归测试
|
||
|
||
### 10.1 执行时间监控
|
||
|
||
```go
|
||
//go:build slow
|
||
// +build slow
|
||
|
||
func TestPerformance_SettlementQuery(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping performance test")
|
||
}
|
||
|
||
start := time.Now()
|
||
|
||
// 执行查询
|
||
result, err := svc.Query(ctx, req)
|
||
|
||
elapsed := time.Since(start)
|
||
|
||
// 断言在可接受范围内
|
||
assert.NoError(t, err)
|
||
assert.True(t, elapsed < 100*time.Millisecond,
|
||
"Query took %v, expected < 100ms", elapsed)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 测试运行策略
|
||
|
||
### 11.1 本地开发
|
||
|
||
```bash
|
||
# 快速测试(跳过慢速和集成测试)
|
||
go test -short ./...
|
||
|
||
# 完整测试(含集成测试)
|
||
go test -tags=integration, slow ./...
|
||
|
||
# 竞态检测
|
||
go test -race ./...
|
||
|
||
# 只测试修改的包
|
||
go test ./internal/domain/...
|
||
|
||
# 详细输出
|
||
go test -v -cover ./internal/domain/...
|
||
```
|
||
|
||
### 11.2 CI/CD
|
||
|
||
```yaml
|
||
# .github/workflows/test.yml
|
||
name: Test
|
||
on: [push, pull_request]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Set up Go
|
||
uses: actions/setup-go@v5
|
||
with:
|
||
go-version: '1.21'
|
||
|
||
- name: Run unit tests
|
||
run: go test -short -race -coverprofile=coverage.out ./...
|
||
|
||
- name: Run integration tests
|
||
run: go test -tags=integration -race -coverprofile=coverage.out ./...
|
||
|
||
- name: Run slow tests
|
||
run: go test -tags=slow ./...
|
||
|
||
- name: Upload coverage
|
||
uses: codecov/codecov-action@v4
|
||
with:
|
||
files: ./coverage.out
|
||
```
|
||
|
||
---
|
||
|
||
## 12. 常见问题处理
|
||
|
||
### 12.1 测试依赖外部服务
|
||
|
||
```go
|
||
// ✅ 使用 Mock
|
||
func TestSettlementService(t *testing.T) {
|
||
store := newMockSettlementStore()
|
||
svc := NewSettlementService(store, nil, nil)
|
||
}
|
||
```
|
||
|
||
### 12.2 时间相关测试
|
||
|
||
```go
|
||
// 使用依赖注入
|
||
type SettlementService struct {
|
||
store SettlementStore
|
||
clock Clock // 注入时间依赖
|
||
}
|
||
```
|
||
|
||
### 12.3 Flaky 测试处理
|
||
|
||
```go
|
||
// ❌ 错误 - 在测试中重试
|
||
func TestNetworkCall(t *testing.T) {
|
||
for i := 0; i < 3; i++ {
|
||
if err := attempt(); err == nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// ✅ 正确 - 标记为已知问题并使用超时
|
||
func TestNetworkCall(t *testing.T) {
|
||
if os.Getenv("CI") == "" {
|
||
t.Skip("Skipping flaky test outside CI")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
err := callWithRetry(ctx, endpoint)
|
||
assert.NoError(t, err)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 13. 测试检查清单
|
||
|
||
新代码合并前:
|
||
|
||
- [ ] 所有单元测试通过 (`go test ./...`)
|
||
- [ ] 覆盖率达标(无下降)
|
||
- [ ] Race 检测通过 (`go test -race ./...`)
|
||
- [ ] 无 `TODO` 或 `FIXME` 遗留测试
|
||
- [ ] Mock 使用正确接口签名
|
||
- [ ] 测试名称符合规范
|
||
- [ ] 表驱动测试覆盖边界情况
|
||
- [ ] 集成测试在 CI 中正常运行
|
||
- [ ] 性能测试在慢速测试套件中
|
||
|
||
---
|
||
|
||
## 14. 下一步行动计划
|
||
|
||
### ✅ 已完成
|
||
|
||
1. Domain 模块覆盖率提升 (40.7% → 71.2%)
|
||
2. Middleware 模块覆盖率提升 (52.7% → 80.4%)
|
||
3. Audit handler 模块覆盖率提升 (75% → 79.6%)
|
||
|
||
### P1 - 创建测试工具包
|
||
|
||
1. **testutil/factory** - 测试数据工厂
|
||
2. **testutil/mock** - 统一Mock库
|
||
3. **testutil/assert** - 自定义断言
|
||
|
||
### P2 - 完善集成测试
|
||
|
||
1. Repository 模块集成测试骨架
|
||
2. Settlement Store 集成测试
|
||
|
||
### P3 - 补充测试类型
|
||
|
||
1. E2E 测试骨架
|
||
2. 性能回归测试
|
||
|
||
---
|
||
|
||
## 15. 参考资料
|
||
|
||
- [Google Testing Blog](https://testing.googleblog.com/)
|
||
- [Atlassian Testing Guide](https://www.atlassian.com/continuous-delivery/software-testing)
|
||
- [Go Testing](https://pkg.go.dev/testing)
|
||
- [testify](https://github.com/stretchr/testify)
|
||
- [testcontainers-go](https://github.com/testcontainers/testcontainers-go)
|
||
- [Go Race Detector](https://go.dev/blog/race-detector)
|
||
- [Advanced Testing in Go](https://google.github.io/aip/214)
|