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."