🧪 Testing✍️ Khoa📅 19/04/2026☕ 7 phút đọc

Testing: Integration và Contract Tests

1. Vấn đề với "fake" infrastructure

Test với mock DB:              Test với real DB:
  Code ──► MockDB               Code ──► Real PostgreSQL
             │                             │
             │ Trả về hardcoded data        │ Thực thi SQL thật
             │                             │ Check constraints thật
             │                             │ Transaction isolation thật
             ▼                             ▼
  Test pass ≠ Production works   Fail sớm = Tốt hơn

Integration test với real infrastructure có chi phí (speed) nhưng confidence bù đắp hoàn toàn. Testcontainers-go giải quyết bài toán "chạy DB thật trong test mà không cần setup thủ công".


2. testcontainers-go — Real DB trong test

Setup cơ bản

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
// testutil/db.go
package testutil

import (
    "context"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
)

// SetupPostgres spin up a real PostgreSQL container for testing.
// Tự động cleanup khi test kết thúc.
func SetupPostgres(t *testing.T) *sqlx.DB {
    t.Helper()

    ctx := context.Background()

    container, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    if err != nil {
        t.Fatalf("failed to start postgres container: %v", err)
    }

    // Cleanup khi test kết thúc
    t.Cleanup(func() {
        if err := container.Terminate(ctx); err != nil {
            t.Logf("failed to terminate container: %v", err)
        }
    })

    connStr, err := container.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatalf("failed to get connection string: %v", err)
    }

    db, err := sqlx.Connect("postgres", connStr)
    if err != nil {
        t.Fatalf("failed to connect to postgres: %v", err)
    }

    // Chạy migrations
    if err := runMigrations(db); err != nil {
        t.Fatalf("failed to run migrations: %v", err)
    }

    return db
}

Dùng trong test

// internal/user/repository_test.go
package user_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "myapp/internal/user"
    "myapp/testutil"
)

func TestUserRepository_Create(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    db := testutil.SetupPostgres(t)
    repo := user.NewRepository(db)
    ctx := context.Background()

    t.Run("creates user successfully", func(t *testing.T) {
        u := &user.User{
            Name:  "Alice",
            Email: "alice@example.com",
        }
        err := repo.Create(ctx, u)
        require.NoError(t, err)
        assert.NotZero(t, u.ID)
        assert.NotZero(t, u.CreatedAt)
    })

    t.Run("returns error on duplicate email", func(t *testing.T) {
        u1 := &user.User{Name: "Bob", Email: "bob@example.com"}
        require.NoError(t, repo.Create(ctx, u1))

        u2 := &user.User{Name: "Bob Clone", Email: "bob@example.com"} // duplicate
        err := repo.Create(ctx, u2)
        require.Error(t, err)
        assert.ErrorIs(t, err, user.ErrDuplicateEmail)
    })
}

func TestUserRepository_Transaction(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    db := testutil.SetupPostgres(t)
    repo := user.NewRepository(db)
    ctx := context.Background()

    t.Run("rollback on failure", func(t *testing.T) {
        // Count trước
        count, _ := repo.Count(ctx)

        err := repo.CreateWithProfile(ctx, &user.User{Name: "Charlie"}, nil) // nil profile → error
        require.Error(t, err)

        // Count phải không thay đổi — transaction đã rollback
        newCount, _ := repo.Count(ctx)
        assert.Equal(t, count, newCount)
    })
}

Reuse container giữa nhiều tests (TestMain)

// internal/user/main_test.go
package user_test

import (
    "os"
    "testing"
    "myapp/testutil"
    "github.com/jmoiron/sqlx"
)

var testDB *sqlx.DB

func TestMain(m *testing.M) {
    // Shared setup — container khởi động 1 lần cho cả package
    // Không dùng t.Helper() ở đây vì không có *testing.T
    // Cần cleanup manually

    ctx := context.Background()
    container, db, cleanup := testutil.SetupPostgresForMain(ctx)
    testDB = db

    code := m.Run() // chạy tất cả tests trong package

    cleanup()
    os.Exit(code)
}

// Tests dùng testDB thay vì SetupPostgres(t) mỗi lần
func TestUserRepository_FindByEmail(t *testing.T) {
    repo := user.NewRepository(testDB)
    // ... test code
}

Redis với miniredis

import "github.com/alicebob/miniredis/v2"

func TestCacheService(t *testing.T) {
    mr := miniredis.RunT(t) // tự cleanup khi t kết thúc

    client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
    svc := NewCacheService(client)

    err := svc.Set(ctx, "key", "value", time.Minute)
    require.NoError(t, err)

    // Simulate TTL expiry
    mr.FastForward(2 * time.Minute)

    _, err = svc.Get(ctx, "key")
    assert.ErrorIs(t, err, ErrCacheMiss)
}

3. Contract Testing với Pact

Consumer-driven contract testing: service A (consumer) định nghĩa contract nó expect từ service B (provider). Provider phải verify contract đó.

Consumer (Frontend / Service A)          Provider (Service B)
         │                                        │
         │ 1. Define contract                      │
         │    "Khi tôi GET /users/1               │
         │     tôi expect response này"           │
         │                                        │
         │ 2. Run consumer test                   │
         │    → Generate pact file                │
         │                                        │
         │ 3. Publish pact file ─────────────────►│
         │    (Pact Broker)                       │
         │                                        │ 4. Provider verifies
         │                                        │    pact file against
         │                                        │    real implementation
         │                                        │
         │◄────────────── 5. Pass/Fail ───────────│

Consumer side (Go)

go get github.com/pact-foundation/pact-go/v2
// api/client/user_client_test.go
package client_test

import (
    "fmt"
    "testing"

    "github.com/pact-foundation/pact-go/v2/consumer"
    "github.com/pact-foundation/pact-go/v2/matchers"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestUserClient_GetUser_Pact(t *testing.T) {
    // Setup Pact consumer
    mockProvider, err := consumer.NewV2Pact(consumer.MockHTTPProviderConfig{
        Consumer: "frontend-service",
        Provider: "user-service",
    })
    require.NoError(t, err)

    // Define interaction (contract)
    err = mockProvider.
        AddInteraction().
        Given("user 1 exists").
        UponReceiving("a request to get user 1").
        WithRequest("GET", "/api/v1/users/1").
        WillRespondWith(200, func(b *consumer.V2ResponseBuilder) {
            b.JSONBody(matchers.MapMatcher{
                "id":    matchers.Integer(1),
                "name":  matchers.Like("Alice"),
                "email": matchers.Regex("alice@example.com", `^[^@]+@[^@]+\.[^@]+


  
  
  
  Testing: Integration và Contract Tests — Khoa's Blog
  
  
  
  
  


  
  

  
), }) }). ExecuteTest(t, func(config consumer.MockServerConfig) error { // Client code chạy với mock provider client := NewUserClient(fmt.Sprintf("http://%s:%d", config.Host, config.Port)) user, err := client.GetUser(context.Background(), 1) if err != nil { return err } assert.Equal(t, int64(1), user.ID) assert.NotEmpty(t, user.Name) return nil }) require.NoError(t, err) }

Provider verification

// api/server/user_provider_test.go
package server_test

import (
    "testing"

    "github.com/pact-foundation/pact-go/v2/provider"
)

func TestUserProvider_PactVerification(t *testing.T) {
    // Spin up real server
    srv := httptest.NewServer(setupRouter(testDB))
    defer srv.Close()

    verifier := provider.NewVerifier()
    err := verifier.VerifyProvider(t, provider.VerifyRequest{
        ProviderBaseURL:            srv.URL,
        BrokerURL:                  "https://pact-broker.example.com",
        Provider:                   "user-service",
        ConsumerVersionSelectors:   []provider.ConsumerVersionSelector{{Latest: true}},
        StateHandlers: provider.StateHandlers{
            "user 1 exists": func(setup bool, s provider.ProviderStateV3) (provider.ProviderStateV3Response, error) {
                if setup {
                    // seed test data
                    testDB.Exec(`INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com')
                                 ON CONFLICT (id) DO NOTHING`)
                }
                return nil, nil
            },
        },
    })
    require.NoError(t, err)
}

4. API Contract Testing với golden files

Golden file testing: lần đầu chạy, lưu output vào file. Lần sau, so sánh output với file đã lưu. Detect regression vô tình.

// testutil/golden.go
package testutil

import (
    "os"
    "path/filepath"
    "testing"

    "github.com/stretchr/testify/assert"
)

// UpdateGolden: go test -update-golden để update golden files
var updateGolden = flag.Bool("update-golden", false, "update golden files")

func AssertGolden(t *testing.T, name string, got []byte) {
    t.Helper()
    path := filepath.Join("testdata", name+".golden")

    if *updateGolden {
        os.MkdirAll("testdata", 0755)
        os.WriteFile(path, got, 0644)
        t.Logf("updated golden file: %s", path)
        return
    }

    want, err := os.ReadFile(path)
    if err != nil {
        t.Fatalf("golden file not found: %s (run with -update-golden to create)", path)
    }

    assert.Equal(t, string(want), string(got),
        "output doesn't match golden file %s (run with -update-golden to update)", path)
}
// api/handler/user_handler_test.go
func TestUserHandler_GetUser_Golden(t *testing.T) {
    db := testutil.SetupPostgres(t)
    testutil.SeedFixtures(t, db, "testdata/users.sql")

    router := setupRouter(db)
    srv := httptest.NewServer(router)
    defer srv.Close()

    resp, err := http.Get(srv.URL + "/api/v1/users/1")
    require.NoError(t, err)
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)

    // Normalize để golden file stable (remove timestamps, etc.)
    normalized := normalizeJSON(t, body)

    testutil.AssertGolden(t, "get_user_response", normalized)
}
# Tạo / update golden files
go test -run TestUserHandler_GetUser_Golden -update-golden ./api/handler/

# Verify không thay đổi
go test -run TestUserHandler_GetUser_Golden ./api/handler/

Golden files nên được commit vào git. Khi PR có golden file changes, reviewer thấy ngay API response thay đổi gì.


5. HTTP Integration Test với httptest.Server

func TestOrderAPI_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    db := testutil.SetupPostgres(t)
    redis := testutil.SetupRedis(t)

    // Inject real dependencies
    deps := Dependencies{
        DB:    db,
        Cache: redis,
        Clock: testutil.FixedClock(t, "2024-01-15T10:00:00Z"),
    }
    router := NewRouter(deps)
    srv := httptest.NewServer(router)
    defer srv.Close()

    client := &http.Client{Timeout: 5 * time.Second}

    t.Run("full order flow", func(t *testing.T) {
        // 1. Create order
        createBody := `{"user_id": 1, "items": [{"sku": "PROD-1", "qty": 2}]}`
        resp, err := client.Post(srv.URL+"/api/v1/orders", "application/json", strings.NewReader(createBody))
        require.NoError(t, err)
        assert.Equal(t, http.StatusCreated, resp.StatusCode)

        var order Order
        json.NewDecoder(resp.Body).Decode(&order)
        resp.Body.Close()

        // 2. Confirm payment
        req, _ := http.NewRequest("POST",
            fmt.Sprintf("%s/api/v1/orders/%d/pay", srv.URL, order.ID),
            strings.NewReader(`{"method": "credit_card"}`))
        req.Header.Set("Content-Type", "application/json")
        resp, err = client.Do(req)
        require.NoError(t, err)
        assert.Equal(t, http.StatusOK, resp.StatusCode)
        resp.Body.Close()

        // 3. Verify order status
        resp, err = client.Get(fmt.Sprintf("%s/api/v1/orders/%d", srv.URL, order.ID))
        require.NoError(t, err)
        var updated Order
        json.NewDecoder(resp.Body).Decode(&updated)
        resp.Body.Close()

        assert.Equal(t, "paid", updated.Status)
    })
}

6. So sánh chiến lược

                    testcontainers    miniredis/fakeredis    mock
Realistic           ████████████      ████████░░            ████░░░░░░
Speed               ████░░░░░░        ████████░░            ██████████
Setup complexity    ████████░░        ████░░░░░░            ████░░░░░░
CI friendliness     ████████░░        ██████████            ██████████
Catch real bugs     ██████████        ████████░░            ████░░░░░░

Khuyến nghị:
  PostgreSQL/MySQL  → testcontainers (không thể fake SQL behavior)
  Redis             → miniredis (behavior gần giống, setup đơn giản)
  External HTTP API → mock/stub (không control, có thể flaky)
  Message queue     → testcontainers hoặc in-memory fake

💡 Interview: "Contract testing giải quyết vấn đề gì unit test không giải quyết được? — Nó verify rằng service A và service B đồng thuận về API contract, không cần deploy cả hai cùng lúc. Rất quan trọng trong microservices khi các teams deploy độc lập."