Comprehensive guide for writing tests in Promenade Platform (DDD Architecture)
- Testing Philosophy
- Three-Tier Testing Strategy
- Unit Test Pattern
- Integration Test Pattern
- Benchmark Test Pattern
- Best Practices
- Common Pitfalls
- Test Coverage Requirements
Promenade follows strict DDD principles with a four-tier testing strategy:
- Unit Tests - Fast feedback, test business logic in isolation
- Smoke Tests - HTTP handler validation without DB
- Integration Tests - Repository validation with real database
- Benchmark Tests - Performance measurement and optimization validation
Key Principle: Each test type has ONE standardized pattern - maximum pattern consistency for easy understanding and maintenance.
Unit Tests (in-place) → Fast (5s) → Business logic
Smoke Tests (no DB) → Fast (2s) → HTTP handlers
Integration Tests (real DB) → Medium (14s) → Repository E2E + Full validation
Benchmark Tests (real DB) → Variable → Performance measurement
| Test Type | When to Use | What to Test | Dependencies |
|---|---|---|---|
| Unit | Business logic, entities, value objects | Domain rules, validation | None (pure Go) |
| Smoke | Handler regression checks | HTTP status + response shape | No DB |
| Integration | Repository, database queries | SQL operations, transactions | Real PostgreSQL |
| Benchmark | After optimizations, N+1 fixes | Query performance, memory | Real PostgreSQL |
Goal: reach 75–80% coverage with a predictable test footprint.
- Unit tests: 20–30 tests
- 1–2 happy paths per method
- 2–3 critical negative cases per method
- Focus on invariants, state transitions, and validation
- Smoke tests: 6–9 tests per handler
- CRUD + key error mappings
- No exhaustive error permutations
- Integration tests: 6–10 tests per repository
- Happy path + not found + constraint error
- 1–2 list/filter tests
- Low risk: follow baseline strictly
- Medium risk: add a few extra unit tests around state transitions
- High risk (payments, fiscal, authentication): allow +30–50% tests, but keep the same pattern
- Do not mirror use case error tests in handlers
- Do not mirror repository edge cases in use cases
- Use one representative error mapping per handler unless high risk
Mirror path structure - integration tests mirror production code:
internal/contexts/identity/user/
entity.go
usecase.go
adapter/repository/postgres/
user_repository.go
test/integration/contexts/identity/user/
repository_test.go ← mirrors adapter/repository/postgres/
package user_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/basilex/promenade/internal/contexts/identity/user"
"github.com/basilex/promenade/internal/contexts/identity/user/adapter/repository/postgres"
"github.com/basilex/promenade/pkg/uuidv7"
"github.com/basilex/promenade/test/integration"
)
// REFERENCE PATTERN for integration tests:
func TestUserRepository_Create(t *testing.T) {
// 1. SetupTestDBWithCleanTables - TRUNCATE all tables (clean DB for each test function)
db := integration.SetupTestDBWithCleanTables(t)
repo := postgres.NewUserRepository(db.DB)
ctx := context.Background()
// 2. Subtests - use UUID only when uniqueness is needed within a test function
t.Run("create user successfully", func(t *testing.T) {
// Static email OK - SetupTestDBWithCleanTables provides a clean DB
u, err := user.NewUser("test@example.com", "password123")
require.NoError(t, err)
err = repo.Create(ctx, u)
require.NoError(t, err)
assert.NotEqual(t, uuidv7.Nil, u.ID)
})
t.Run("duplicate email fails", func(t *testing.T) {
// Reuse same email - DB was truncated, no conflict
u1, _ := user.NewUser("test@example.com", "password123")
u2, _ := user.NewUser("test@example.com", "password456")
require.NoError(t, repo.Create(ctx, u1))
err := repo.Create(ctx, u2)
assert.Error(t, err) // Should fail on duplicate email
})
}
// If uniqueness is required between subtests:
func TestUserRepository_ListUsers(t *testing.T) {
db := integration.SetupTestDBWithCleanTables(t)
repo := postgres.NewUserRepository(db.DB)
ctx := context.Background()
// Create multiple users - include UUID in email for uniqueness
for i := 0; i < 5; i++ {
email := fmt.Sprintf("user_%d@example.com", i)
u, _ := user.NewUser(email, "password123")
require.NoError(t, repo.Create(ctx, u))
}
t.Run("list all users", func(t *testing.T) {
users, total, err := repo.ListUsers(ctx, 10, 0)
require.NoError(t, err)
assert.Equal(t, 5, total)
assert.Len(t, users, 5)
})
}- Use
SetupTestDBWithCleanTables(t)- TRUNCATE all tables at start of each test function - Static emails for single-entity tests - No UUID pollution
- UUID only for intra-function uniqueness - Multiple entities in ONE test function
- Context first parameter -
func Method(ctx context.Context, ...) - require.NoError for critical checks - Stop test on setup failure
- assert for expectations - Continue test to see all failures
- DON'T use
SetupTestDB(t)- It doesn't clean between test functions (causes duplicate key errors) - DON'T use WithTransaction - Requires repository signature changes (*sqlx.Tx vs *sqlx.DB)
- DON'T use CleanAllTables manually - SetupTestDBWithCleanTables already does it
- DON'T share state between test functions - Each function is independent
- DON'T use random data generators - Use predictable test data
// Helper creates test user with UUID in email (for uniqueness between test functions)
func createTestUser(t *testing.T, db *integration.TestDB, userID uuidv7.UUID) {
t.Helper()
uniqueEmail := fmt.Sprintf("testuser_%s@example.com", userID.String())
_, err := db.DB.Exec(`
INSERT INTO identity_users (id, email, password_hash, status)
VALUES ($1, $2, $3, $4)
`, userID, uniqueEmail, "password_hash", "active")
require.NoError(t, err)
}
func TestContactRepository_Create(t *testing.T) {
db := integration.SetupTestDBWithCleanTables(t)
repo := postgres.NewContactRepository(db.DB)
ctx := context.Background()
// Create test user first (for FK constraint)
userID := uuidv7.New()
createTestUser(t, db, userID)
t.Run("create email contact", func(t *testing.T) {
c, _ := contact.NewEmailContact(userID, "work@example.com", "Work")
err := repo.Create(ctx, c)
require.NoError(t, err)
})
}# All integration tests (auto-starts test DB)
make test-integration
# Specific context
go test ./test/integration/contexts/identity/... -v
# Specific aggregate
go test ./test/integration/contexts/identity/user -v
# With coverage
go test ./test/integration/... -coverTest business logic in isolation - entities, use cases, value objects.
In-place - same directory as production code:
internal/contexts/identity/user/
entity.go
entity_test.go ← unit test
usecase.go
usecase_test.go ← unit test
package user
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/basilex/promenade/pkg/uuidv7"
)
func TestUser_NewUser(t *testing.T) {
tests := []struct {
name string
email string
password string
wantErr bool
errContains string
}{
{
name: "valid user",
email: "test@example.com",
password: "ValidPass123!",
wantErr: false,
},
{
name: "invalid email",
email: "not-an-email",
password: "ValidPass123!",
wantErr: true,
errContains: "invalid email",
},
{
name: "weak password",
email: "test@example.com",
password: "123",
wantErr: true,
errContains: "password must be at least",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, err := NewUser(tt.email, tt.password)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
assert.Nil(t, user)
} else {
require.NoError(t, err)
assert.NotNil(t, user)
assert.NotEqual(t, uuidv7.Nil, user.ID)
assert.Equal(t, UserStatusActive, user.Status)
}
})
}
}
func TestUser_VerifyEmail(t *testing.T) {
u, _ := NewUser("test@example.com", "ValidPass123!")
assert.False(t, u.IsEmailVerified)
u.VerifyEmail()
assert.True(t, u.IsEmailVerified)
assert.NotNil(t, u.EmailVerifiedAt)
}
func TestUser_Suspend(t *testing.T) {
u, _ := NewUser("test@example.com", "ValidPass123!")
assert.Equal(t, UserStatusActive, u.Status)
err := u.Suspend()
require.NoError(t, err)
assert.Equal(t, UserStatusSuspended, u.Status)
}
func TestUser_Suspend_AlreadySuspended(t *testing.T) {
u, _ := NewUser("test@example.com", "ValidPass123!")
u.Suspend()
err := u.Suspend()
assert.Error(t, err)
assert.Contains(t, err.Error(), "already suspended")
}package user_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/basilex/promenade/internal/contexts/identity/user"
"github.com/basilex/promenade/pkg/uuidv7"
)
// MockRepository for testing
type MockRepository struct {
mock.Mock
}
func (m *MockRepository) Create(ctx context.Context, u *user.User) error {
args := m.Called(ctx, u)
return args.Error(0)
}
func (m *MockRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
args := m.Called(ctx, email)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*user.User), args.Error(1)
}
// ... implement other methods
func TestUseCase_Register(t *testing.T) {
mockRepo := new(MockRepository)
uc := user.NewUseCase(mockRepo)
ctx := context.Background()
t.Run("register new user successfully", func(t *testing.T) {
email := "newuser@example.com"
name := "New User"
password := "ValidPass123!"
// Mock: email doesn't exist
mockRepo.On("ExistsByEmail", ctx, email).Return(false, nil).Once()
mockRepo.On("Create", ctx, mock.AnythingOfType("*user.User")).Return(nil).Once()
u, err := uc.Register(ctx, email, name, password)
require.NoError(t, err)
assert.NotNil(t, u)
assert.Equal(t, email, u.Email.Value())
mockRepo.AssertExpectations(t)
})
t.Run("register with existing email fails", func(t *testing.T) {
email := "existing@example.com"
// Mock: email already exists
mockRepo.On("ExistsByEmail", ctx, email).Return(true, nil).Once()
u, err := uc.Register(ctx, email, "Name", "ValidPass123!")
assert.Error(t, err)
assert.ErrorIs(t, err, user.ErrEmailAlreadyExists)
assert.Nil(t, u)
mockRepo.AssertExpectations(t)
})
}
func TestUseCase_SuspendUser(t *testing.T) {
mockRepo := new(MockRepository)
uc := user.NewUseCase(mockRepo)
ctx := context.Background()
t.Run("suspend active user", func(t *testing.T) {
userID := uuidv7.New()
u, _ := user.NewUser("user@example.com", "ValidPass123!")
u.ID = userID
mockRepo.On("GetByID", ctx, userID).Return(u, nil).Once()
mockRepo.On("Update", ctx, u).Return(nil).Once()
err := uc.SuspendUser(ctx, userID)
require.NoError(t, err)
assert.Equal(t, user.UserStatusSuspended, u.Status)
mockRepo.AssertExpectations(t)
})
t.Run("suspend non-existent user fails", func(t *testing.T) {
userID := uuidv7.New()
mockRepo.On("GetByID", ctx, userID).Return(nil, user.ErrUserNotFound).Once()
err := uc.SuspendUser(ctx, userID)
assert.Error(t, err)
assert.ErrorIs(t, err, user.ErrUserNotFound)
mockRepo.AssertExpectations(t)
})
}# All unit tests (fast)
make test-unit
# Specific package
go test ./internal/contexts/identity/user -v
# With coverage
go test ./internal/contexts/identity/user -coverPerformance measurement and optimization validation with real database. Used to validate N+1 query fixes, measure query performance, and track performance regressions.
Mirror path structure in test/benchmark/contexts/:
test/benchmark/contexts/identity/user/
repository_bench_test.go ← Benchmark tests with real DB
internal/contexts/identity/user/
adapter/repository/postgres/
user_repository.go ← Production repository
package user_test
import (
"context"
"testing"
"github.com/basilex/promenade/internal/contexts/identity/user"
"github.com/basilex/promenade/internal/contexts/identity/user/adapter/repository/postgres"
"github.com/basilex/promenade/test/integration"
)
// BenchmarkUserRepository_ListUsers measures query performance
func BenchmarkUserRepository_ListUsers(b *testing.B) {
// Setup real database with test data
db := integration.SetupTestDBWithCleanTables(&testing.T{})
repo := postgres.NewUserRepository(db.DB)
ctx := context.Background()
// Create test data
for i := 0; i < 100; i++ {
u, _ := user.NewUser("user"+strconv.Itoa(i)+"@example.com", "password123")
_ = repo.Create(ctx, u)
}
// Reset timer before benchmark loop
b.ResetTimer()
// Benchmark loop
for i := 0; i < b.N; i++ {
_, err := repo.ListUsers(ctx, 1, 20)
if err != nil {
b.Fatal(err)
}
}
}
// BenchmarkUserRepository_ListUsers_WithRoles measures N+1 fix
func BenchmarkUserRepository_ListUsers_WithRoles(b *testing.B) {
db := integration.SetupTestDBWithCleanTables(&testing.T{})
repo := postgres.NewUserRepository(db.DB)
ctx := context.Background()
// Create test data with roles
for i := 0; i < 100; i++ {
u, _ := user.NewUser("user"+strconv.Itoa(i)+"@example.com", "password123")
_ = repo.Create(ctx, u)
// Assign roles
_ = repo.AssignRole(ctx, u.ID, "user")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := repo.ListUsers(ctx, 1, 20)
if err != nil {
b.Fatal(err)
}
}
}- Use real database - Benchmarks measure real query performance
- Create test data - Populate database with realistic dataset
- Reset timer - Call
b.ResetTimer()after setup - Measure specific operations - Benchmark individual methods
- Compare before/after - Run benchmarks before and after optimizations
- DON'T use mocks - Benchmarks need real database
- DON'T forget ResetTimer - Setup time skews results
- DON'T test trivial operations - Focus on expensive operations (queries, aggregations)
- DON'T ignore memory - Use
-benchmemflag to track allocations
# All benchmarks (5s per benchmark)
make test-benchmark
# Extended benchmarks (10s per benchmark)
make test-benchmark-all
# Specific benchmark
go test -bench=. ./test/benchmark/contexts/identity/user -v
# With memory stats
go test -bench=. -benchmem ./test/benchmark/contexts/identity/user
# Compare before/after optimization
go test -bench=ListUsers -benchmem ./test/benchmark/contexts/identity/user > before.txt
# Apply optimization
go test -bench=ListUsers -benchmem ./test/benchmark/contexts/identity/user > after.txt
benchcmp before.txt after.txt- Table-Driven Tests - Use for multiple scenarios
- Clear Test Names - Describe expected behavior
- AAA Pattern - Arrange, Act, Assert
- One Assertion Per Test - Or use subtests
- No Test Interdependence - Tests can run in any order
- Context Propagation - Always pass
ctxas first parameter
// Good
func TestUser_NewUser_InvalidEmail_ReturnsError(t *testing.T) {}
func TestUserRepository_Create_DuplicateEmail_ReturnsError(t *testing.T) {}
func TestUserHandler_Register_MissingPassword_Returns400(t *testing.T) {}
// Bad
func TestUser(t *testing.T) {}
func Test1(t *testing.T) {}
func TestCreate(t *testing.T) {}// Use require for critical setup
require.NoError(t, err, "failed to create test user")
// Use assert for expectations
assert.Equal(t, expected, actual)
assert.Contains(t, err.Error(), "expected substring")
// Don't swallow errors
if err != nil {
// Silent failure - BAD
}// Predictable test data
email := "test@example.com"
name := "Test User"
password := "TestPass123!"
// Random data (hard to debug failures)
email := fmt.Sprintf("user_%d@example.com", rand.Int())// Clear mock expectations
mockRepo.On("Create", ctx, mock.AnythingOfType("*user.User")).Return(nil).Once()
mockRepo.AssertExpectations(t) // Verify all mocks called
// Vague mocks
mockRepo.On("Create", mock.Anything, mock.Anything).Return(nil)// BAD - shared DB, duplicate key errors
func TestUserRepository_Create(t *testing.T) {
db := integration.SetupTestDB(t) // Doesn't clean between functions
// ...
}
func TestUserRepository_Update(t *testing.T) {
db := integration.SetupTestDB(t) // Same DB, leftover data
// ...
}// GOOD - clean DB for each function
func TestUserRepository_Create(t *testing.T) {
db := integration.SetupTestDBWithCleanTables(t) // TRUNCATE all tables
// ...
}
func TestUserRepository_Update(t *testing.T) {
db := integration.SetupTestDBWithCleanTables(t) // Fresh start
// ...
}// BAD - UUID pollution
func TestUserRepository_GetByID(t *testing.T) {
db := integration.SetupTestDBWithCleanTables(t)
email := fmt.Sprintf("test_%s@example.com", uuidv7.New().String()) // Unnecessary!
u, _ := user.NewUser(email, "password")
// ...
}// GOOD - static email (DB is clean)
func TestUserRepository_GetByID(t *testing.T) {
db := integration.SetupTestDBWithCleanTables(t)
u, _ := user.NewUser("test@example.com", "password") // Simple and clear
// ...
}// BAD - requires repository changes
func TestUserRepository_Create(t *testing.T) {
db := integration.SetupTestDB(t)
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
repo := postgres.NewUserRepository(tx) // Expects *sqlx.DB not *sqlx.Tx
// ...
}// GOOD - use SetupTestDBWithCleanTables
func TestUserRepository_Create(t *testing.T) {
db := integration.SetupTestDBWithCleanTables(t)
repo := postgres.NewUserRepository(db.DB) // Works with *sqlx.DB
// ...
}// BAD - testing internal methods
func TestUser_hashPassword(t *testing.T) {
hash, err := hashPassword("password")
// Testing private method
}// GOOD - testing public behavior
func TestUser_NewUser_PasswordIsHashed(t *testing.T) {
u, _ := NewUser("test@example.com", "password123")
assert.NotEqual(t, "password123", u.PasswordHash) // Verify password was hashed
}// BAD - mock setup but no verification
mockRepo.On("Create", ctx, mock.AnythingOfType("*user.User")).Return(nil)
uc.Register(ctx, "test@example.com", "Name", "password")
// Did Create get called? We don't know!// GOOD - verify all mocks called
mockRepo.On("Create", ctx, mock.AnythingOfType("*user.User")).Return(nil).Once()
uc.Register(ctx, "test@example.com", "Name", "password")
mockRepo.AssertExpectations(t) // Fails if Create not called exactly once| Component | Coverage | Why |
|---|---|---|
| Entities | 95%+ | Core business logic |
| Use Cases | 85%+ | Business operations |
| Repositories | 80%+ | Data access |
| Handlers | 70%+ | HTTP contracts (unit tests) |
| Value Objects | 95%+ | Domain primitives |
- Minimum: 80% total coverage
- Target: 90% total coverage
- Critical paths: 100% (authentication, authorization, payment)
# All tests with coverage
make test-coverage
# Specific package
go test ./internal/contexts/identity/user -cover -coverprofile=coverage.out
go tool cover -html=coverage.out
# Coverage report
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out | grep total- Main Testing Guide - Testing overview
- Testing Structure - Directory organization
- Integration Test Utilities - Helper functions
- DDD Architecture - Domain-Driven Design principles
- Event Bus Testing - Event-driven testing
Before writing tests, verify:
- Correct test type (unit/integration/benchmark)?
- Using standardized pattern?
- Mirror path structure for integration tests?
- SetupTestDBWithCleanTables for integration tests?
- Real database for integration tests?
- Table-driven tests for multiple scenarios?
- Clear, descriptive test names?
- AssertExpectations for all mocks?
- No UUID pollution in test data?
- Context propagation (
ctxfirst parameter)?
Last Updated: 2025-12-28
Status: Production-ready
Maintainer: Promenade Team