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

8.9 KiB
Raw Blame History

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.1assert 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:

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):

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):

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):

db, err := gorm.Open(drivermysql.Open(dsn), &gorm.Config{})
repo := mysql.NewTestRepo(db)  // internal/repository/mysql/test_helper.go

go-sqlmock (SQL-level mocking):

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):

mr, err := miniredis.Run()
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})

Manual interface mock (for core.Context):

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:

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):

// 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:

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():

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:

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:

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:

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:

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):

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