2026-03-21 16:01:32 +08:00

284 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Testing Patterns
**Analysis Date:** 2026-03-21
## Test Framework
**Runner:**
- Go standard `testing` package
- Config: `Makefile` target `test`
**Assertion Libraries:**
- `github.com/stretchr/testify v1.11.1``assert` package (preferred in newer tests)
- Standard `t.Fatal`, `t.Fatalf`, `t.Errorf` (used in most tests, older style)
**Mocking Libraries:**
- `github.com/DATA-DOG/go-sqlmock v1.5.2` — SQL-level DB mocking
- `github.com/alicebob/miniredis/v2 v2.36.1` — in-process Redis mock server
- Manual mock structs implementing interfaces (for `core.Context`, logger)
**Run Commands:**
```bash
make test # Run all tests: go test -v --cover ./internal/...
go test -v ./internal/service/... # Test specific package
go test -v -run TestFunctionName ./... # Run single test
```
**Coverage output:** `coverage.out` (present in repo root)
## Test File Organization
**Location:**
- Co-located with source files — `foo_test.go` sits in the same directory as `foo.go`
- No separate `tests/` directory for Go unit/integration tests
**Naming:**
- `<subject>_test.go` matching the function/feature under test
- Package declaration: same package as source (`package activity`) for white-box tests, or `package game_test` for black-box tests (rare)
**Structure:**
```
internal/service/activity/
├── activity_order_service.go
├── concurrency_test.go # integration (real DB)
├── reward_snapshot_test.go # integration (SQLite in-memory)
├── sanitize_test.go # unit (no DB)
internal/service/game/
├── token.go
├── token_test.go # uses miniredis + SQLite
internal/service/user/
├── error_test.go # uses go-sqlmock
├── request_shipping_batch_test.go
internal/api/app/
├── store_test.go # HTTP handler integration test
```
## Test Structure
**Suite Organization (table-driven tests):**
```go
func TestShouldTriggerInstantDraw(t *testing.T) {
testCases := []struct {
name string
orderStatus int32
drawMode string
shouldTrigger bool
}{
{"已支付+即时开奖", 2, "instant", true},
{"已支付+定时开奖", 2, "scheduled", false},
{"未支付+即时开奖", 1, "instant", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := shouldTriggerInstantDraw(tc.orderStatus, tc.drawMode)
if result != tc.shouldTrigger {
t.Errorf("期望触发=%v实际触发=%v", tc.shouldTrigger, result)
}
})
}
}
```
**Patterns:**
- Setup: create repo/DB then initialize service via constructor
- Teardown: SQLite in-memory DBs are ephemeral (no cleanup needed); miniredis uses `defer mr.Close()`
- Assertion: `t.Fatalf` for unrecoverable setup failures; `t.Errorf` for assertion failures
- Skipping: `t.Skipf` when preconditions absent (e.g., real DB unavailable in concurrency tests)
## Mocking
**In-memory SQLite (primary DB mock):**
```go
repo, err := mysql.NewSQLiteRepoForTest() // internal/repository/mysql/testrepo_sqlite.go
if err != nil {
t.Fatal(err)
}
db := repo.GetDbW()
// Manually create tables with SQLite-compatible DDL
db.Exec(`CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, ...)`)
```
`NewSQLiteRepoForTest()``gorm.Open(sqlite.Open(":memory:"), ...)` → returns `Repo` interface.
**TestRepo wrapping real DB (for integration tests with live MySQL):**
```go
db, err := gorm.Open(drivermysql.Open(dsn), &gorm.Config{})
repo := mysql.NewTestRepo(db) // internal/repository/mysql/test_helper.go
```
**go-sqlmock (SQL-level mocking):**
```go
db, mock, err := sqlmock.New()
gormDB, _ := gorm.Open(gormmysql.New(gormmysql.Config{
Conn: db,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
mock.ExpectQuery("SELECT .* FROM `system_item_cards`").
WithArgs(100, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id", "status"}).AddRow(100, 0))
```
**miniredis (Redis mock):**
```go
mr, err := miniredis.Run()
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
```
**Manual interface mock (for `core.Context`):**
```go
type mockContext struct {
core.Context // embed to satisfy interface
ctx context.Context
}
func (m *mockContext) RequestContext() core.StdContext { return core.StdContext{Context: m.ctx} }
func (m *mockContext) ShouldBindJSON(obj interface{}) error { return nil }
func (m *mockContext) AbortWithError(err core.BusinessError) {}
// ... implement all interface methods with no-op stubs
```
**MockLogger pattern:**
```go
type MockLogger struct {
logger.CustomLogger
}
func (l *MockLogger) Info(msg string, fields ...zap.Field) {}
func (l *MockLogger) Error(msg string, fields ...zap.Field) {}
func (l *MockLogger) Warn(msg string, fields ...zap.Field) {}
func (l *MockLogger) Debug(msg string, fields ...zap.Field) {}
```
**What to Mock:**
- DB connection (use SQLite in-memory or go-sqlmock)
- Redis (use miniredis)
- External API clients (use interface injection)
- Logger (use MockLogger embedding `logger.CustomLogger`)
- `core.Context` (use manual mock struct)
**What NOT to Mock:**
- Business logic within services under test
- DAO query building when testing DB query behavior
## Fixtures and Factories
**Test Data (DDL + seed SQL directly in test):**
```go
// Create table
repo.GetDbW().Exec(`CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
...
)`)
// Seed data
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (101, ?, 2, 0, 100, ?)", userID, o1Time)
```
**Shared table initialization helpers:**
```go
func initTestTables(t *testing.T, db *gorm.DB) { ... } // in task_center package tests
func ensureExtraTablesForServiceTest(t *testing.T, db *gorm.DB) { ... }
```
**Test helper functions marked with `t.Helper()`:**
```go
func assertAttribution(t *testing.T, got map[int64]activityAttribution, activityID, wantChannelID int64, wantChannelCode string) {
t.Helper()
...
}
```
**Location:**
- No centralized fixtures directory. Each test file creates its own data inline.
- Shared helpers defined within the same package test files.
## Coverage
**Requirements:** `make test` runs with `--cover` flag but no enforced minimum threshold.
**View Coverage:**
```bash
go test -v --cover ./internal/... # prints coverage % per package
```
Coverage output file: `/Users/win/2025/AICoding/bindbox/bindbox_game/coverage.out`
## Test Types
**Unit Tests (pure logic, no DB):**
- Scope: individual pure functions — JSON utilities, string parsing, coupon discount math
- Pattern: call function, assert return value
- Examples: `internal/service/product/product_test.go` (TestNormalizeJSON, TestSplitImages), `internal/service/order/discount_test.go` (TestApplyCouponDiscount), `internal/service/activity/sanitize_test.go`
**Integration Tests (SQLite in-memory DB):**
- Scope: service methods requiring DB reads/writes, HTTP handler end-to-end via `net/http/httptest`
- Pattern: `NewSQLiteRepoForTest()` → create DDL → seed data → call service/handler → assert DB state or response
- Examples: `internal/service/task_center/service_test.go`, `internal/api/app/store_test.go`, `internal/service/activity/reward_snapshot_test.go`
**Integration Tests (Real MySQL — skipped when unavailable):**
- Scope: concurrency/race condition testing requiring real DB transactions
- Pattern: hardcoded DSN, `t.Skipf` on connection failure
- Examples: `internal/service/activity/concurrency_test.go`
**E2E Tests:** Not present in the current codebase.
## Common Patterns
**HTTP Handler Testing:**
```go
mux, _ := core.New(lg)
mux.Group("/api/app").GET("/store/items", NewStore(lg, repo).ListStoreItemsForApp())
rr := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/app/store/items?kind=product&page=1&page_size=10", bytes.NewBufferString(""))
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String())
}
var rsp map[string]interface{}
json.Unmarshal([]byte(rr.Body.String()), &rsp)
```
**Async/Concurrency Testing:**
```go
var wg sync.WaitGroup
var mu sync.Mutex
successCount := 0
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
// ... concurrent operation
mu.Lock()
defer mu.Unlock()
successCount++
}(i)
}
wg.Wait()
// assert final DB state
```
**Error Path Testing:**
```go
err := svc.AddItemCard(context.Background(), 1, 100, 1)
assert.Error(t, err)
assert.Equal(t, "record not found", err.Error())
```
**Testify assertion style (preferred in newer tests):**
```go
assert.NoError(t, err)
assert.NotEmpty(t, token)
assert.Equal(t, userID, claims.UserID)
assert.Contains(t, err.Error(), "invalid ticket format")
```
---
*Testing analysis: 2026-03-21*