284 lines
8.9 KiB
Markdown
284 lines
8.9 KiB
Markdown
# 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*
|