# 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:** - `_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*