Integration tests verify your code works with real external systems. Using testcontainers-go, you can spin up actual PostgreSQL and Redis instances in Docker containers, run tests against them, and tear them down automatically. No mocks, no in-memory fakes—real behavior.
Why Integration Tests
Unit tests with mocks verify logic in isolation. Integration tests catch:
- SQL syntax errors and query bugs
- Transaction handling issues
- Connection pool behavior
- Redis key expiration
- Constraint violations
- Data type mismatches
Rule of thumb: Unit tests for business logic, integration tests for data access.
Project Setup
Dependencies
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/redis
Test Helpers Structure
pkg/testhelpers/
├── postgres.go # PostgreSQL container setup
├── redis.go # Redis container setup
├── database.go # Helper utilities
└── migrations.go # Migration runner
PostgreSQL Container Setup
Create a reusable PostgreSQL container helper:
// pkg/testhelpers/postgres.go
package testhelpers
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go"
pgcontainers "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
type PostgresContainer struct {
Container *pgcontainers.PostgresContainer
Pool *pgxpool.Pool
URI string
}
func SetupPostgres(ctx context.Context) (*PostgresContainer, error) {
container, err := pgcontainers.RunContainer(
ctx,
testcontainers.WithImage("timescale/timescaledb:latest-pg17"),
pgcontainers.WithDatabase("testdb"),
pgcontainers.WithUsername("postgres"),
pgcontainers.WithPassword("postgres"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(90*time.Second),
),
)
if err != nil {
return nil, fmt.Errorf("failed to start postgres container: %w", err)
}
uri, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get connection string: %w", err)
}
config, err := pgxpool.ParseConfig(uri)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to parse connection config: %w", err)
}
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to ping postgres: %w", err)
}
return &PostgresContainer{
Container: container,
Pool: pool,
URI: uri,
}, nil
}
func (pc *PostgresContainer) Cleanup(ctx context.Context) error {
pc.Pool.Close()
return pc.Container.Terminate(ctx)
}
Redis Container Setup
// pkg/testhelpers/redis.go
package testhelpers
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
rediscontainers "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/testcontainers/testcontainers-go/wait"
)
type RedisContainer struct {
Container *rediscontainers.RedisContainer
Client *redis.Client
URI string
}
func SetupRedis(ctx context.Context) (*RedisContainer, error) {
container, err := rediscontainers.RunContainer(
ctx,
testcontainers.WithImage("redis:7-alpine"),
testcontainers.WithWaitStrategy(
wait.ForLog("Ready to accept connections").
WithStartupTimeout(60*time.Second),
),
)
if err != nil {
return nil, fmt.Errorf("failed to start redis container: %w", err)
}
uri, err := container.ConnectionString(ctx)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get connection string: %w", err)
}
opts, err := redis.ParseURL(uri)
if err != nil {
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
}
client := redis.NewClient(opts)
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to ping redis: %w", err)
}
return &RedisContainer{
Container: container,
Client: client,
URI: uri,
}, nil
}
func (rc *RedisContainer) Cleanup(ctx context.Context) error {
_ = rc.Client.Close()
return rc.Container.Terminate(ctx)
}
Database Helper Utilities
// pkg/testhelpers/database.go
package testhelpers
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type DatabaseHelper struct {
pool *pgxpool.Pool
}
func NewDatabaseHelper(pool *pgxpool.Pool) *DatabaseHelper {
return &DatabaseHelper{pool: pool}
}
func (h *DatabaseHelper) GetTableRowCount(ctx context.Context, table string) (int, error) {
var count int
err := h.pool.QueryRow(ctx, "SELECT COUNT(*) FROM "+table).Scan(&count)
return count, err
}
func (h *DatabaseHelper) TruncateTable(ctx context.Context, table string) error {
_, err := h.pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
return err
}
func (h *DatabaseHelper) TableExists(ctx context.Context, table string) (bool, error) {
var exists bool
err := h.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = $1
)
`, table).Scan(&exists)
return exists, err
}
Writing Integration Tests
Build Tags
Use build tags to separate integration tests from unit tests:
//go:build integration
package repositories
import (
"context"
"testing"
// ...
)
Run only integration tests:
go test -tags=integration ./...
Run only unit tests (default):
go test ./...
Repository Integration Test
//go:build integration
package repositories
import (
"context"
"strings"
"testing"
"crypto-tracker/modules/alert/domain/entities"
"crypto-tracker/pkg/testhelpers"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupAlertRepositoryTest(t *testing.T, ctx context.Context) (
*alertRepository,
*pgxpool.Pool,
*testhelpers.PostgresContainer,
*testhelpers.DatabaseHelper,
) {
pgContainer, err := testhelpers.SetupPostgres(ctx)
require.NoError(t, err, "failed to setup postgres container")
// Run migrations
runAlertMigrations(t, ctx, pgContainer.Pool)
repo := NewAlertRepository(pgContainer.Pool)
dbHelper := testhelpers.NewDatabaseHelper(pgContainer.Pool)
// Insert required foreign key data
insertTestUser(t, ctx, pgContainer.Pool)
insertTestTokens(t, ctx, pgContainer.Pool)
t.Cleanup(func() {
_ = pgContainer.Cleanup(ctx)
})
return repo, pgContainer.Pool, pgContainer, dbHelper
}
func TestAlertRepository_Create_Success(t *testing.T) {
ctx := context.Background()
repo, _, _, _ := setupAlertRepositoryTest(t, ctx)
alert := &entities.Alert{
UserID: 1,
Symbol: "BTC",
Type: "above",
Threshold: 50000.50,
}
err := repo.Create(ctx, alert)
require.NoError(t, err)
assert.Greater(t, alert.ID, 0)
assert.Equal(t, "active", alert.Status)
assert.NotZero(t, alert.CreatedAt)
}
func TestAlertRepository_Create_Multiple(t *testing.T) {
ctx := context.Background()
repo, _, _, dbHelper := setupAlertRepositoryTest(t, ctx)
alerts := []struct {
symbol string
alertType string
threshold float64
}{
{"BTC", "above", 50000},
{"ETH", "below", 2000},
{"BNB", "above", 500},
}
for _, a := range alerts {
alert := &entities.Alert{
UserID: 1,
Symbol: a.symbol,
Type: a.alertType,
Threshold: a.threshold,
}
err := repo.Create(ctx, alert)
require.NoError(t, err)
}
count, err := dbHelper.GetTableRowCount(ctx, "alerts")
require.NoError(t, err)
assert.Equal(t, 3, count)
}
func TestAlertRepository_FindByID_NotFound(t *testing.T) {
ctx := context.Background()
repo, _, _, _ := setupAlertRepositoryTest(t, ctx)
found, err := repo.FindByID(ctx, 9999)
assert.Error(t, err)
assert.Nil(t, found)
}
func TestAlertRepository_Delete_WrongUser(t *testing.T) {
ctx := context.Background()
repo, _, _, dbHelper := setupAlertRepositoryTest(t, ctx)
alert := &entities.Alert{
UserID: 1,
Symbol: "BTC",
Type: "above",
Threshold: 50000,
}
err := repo.Create(ctx, alert)
require.NoError(t, err)
// Try to delete with wrong user ID
err = repo.Delete(ctx, alert.ID, 999)
require.NoError(t, err) // Delete succeeds but affects 0 rows
// Alert should still exist
count, err := dbHelper.GetTableRowCount(ctx, "alerts")
require.NoError(t, err)
assert.Equal(t, 1, count)
}
Service Integration Test (Full Stack)
Test services with real repositories:
//go:build integration
package services
import (
"context"
"fmt"
"strings"
"testing"
"crypto-tracker/modules/auth/domain"
"crypto-tracker/modules/auth/infrastructure/repositories"
"crypto-tracker/pkg/logger"
"crypto-tracker/pkg/testhelpers"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupAuthServiceTest(t *testing.T, ctx context.Context) (
*authService,
*pgxpool.Pool,
*testhelpers.RedisContainer,
) {
// Setup PostgreSQL
pgContainer, err := testhelpers.SetupPostgres(ctx)
require.NoError(t, err, "failed to setup postgres container")
runAuthMigrations(t, ctx, pgContainer.Pool)
// Setup Redis
redisContainer, err := testhelpers.SetupRedis(ctx)
require.NoError(t, err, "failed to setup redis container")
// Create real repositories (no mocks)
userRepo := repositories.NewUserRepository(pgContainer.Pool)
sessionRepo := repositories.NewSessionRepository(redisContainer.Client)
passwordSvc := NewPasswordService()
jwtSvc := NewJWTService("test-secret-key-that-is-long-enough-for-hs256")
log := logger.New("plain", "info")
authSvc := NewAuthService(userRepo, passwordSvc, jwtSvc, sessionRepo, log).(*authService)
t.Cleanup(func() {
_ = pgContainer.Cleanup(ctx)
_ = redisContainer.Cleanup(ctx)
})
return authSvc, pgContainer.Pool, redisContainer
}
func TestAuthService_Signup_Integration_Success(t *testing.T) {
ctx := context.Background()
authSvc, _, _ := setupAuthServiceTest(t, ctx)
user, token, err := authSvc.Signup(ctx, "test@example.com", "Test User", "password123")
require.NoError(t, err)
assert.NotNil(t, user)
assert.Equal(t, "test@example.com", user.Email)
assert.Equal(t, "Test User", user.Name)
assert.Greater(t, user.ID, 0)
assert.NotEmpty(t, token)
}
func TestAuthService_Signup_Integration_DuplicateEmail(t *testing.T) {
ctx := context.Background()
authSvc, _, _ := setupAuthServiceTest(t, ctx)
_, _, err := authSvc.Signup(ctx, "test@example.com", "User 1", "password123")
require.NoError(t, err)
_, _, err = authSvc.Signup(ctx, "test@example.com", "User 2", "password123")
assert.Error(t, err)
assert.Equal(t, domain.ErrEmailExists, err)
}
func TestAuthService_Login_Integration_Success(t *testing.T) {
ctx := context.Background()
authSvc, _, _ := setupAuthServiceTest(t, ctx)
// Signup first
signupUser, _, err := authSvc.Signup(ctx, "test@example.com", "Test User", "password123")
require.NoError(t, err)
// Login
loginUser, token, err := authSvc.Login(ctx, "test@example.com", "password123")
require.NoError(t, err)
assert.Equal(t, signupUser.ID, loginUser.ID)
assert.Equal(t, signupUser.Email, loginUser.Email)
assert.NotEmpty(t, token)
}
func TestAuthService_Login_Integration_WrongPassword(t *testing.T) {
ctx := context.Background()
authSvc, _, _ := setupAuthServiceTest(t, ctx)
_, _, err := authSvc.Signup(ctx, "test@example.com", "Test User", "password123")
require.NoError(t, err)
_, _, err = authSvc.Login(ctx, "test@example.com", "wrongpassword")
assert.Error(t, err)
assert.Equal(t, domain.ErrInvalidCredentials, err)
}
Testing Redis Session Storage
Verify tokens are actually stored in Redis:
func TestAuthService_TokenInRedis_Integration(t *testing.T) {
ctx := context.Background()
authSvc, _, redisContainer := setupAuthServiceTest(t, ctx)
user, token, err := authSvc.Signup(ctx, "test@example.com", "Test User", "password123")
require.NoError(t, err)
// Parse JWT to get sub_token claim
jwtSvc := NewJWTService("test-secret-key-that-is-long-enough-for-hs256")
claims, err := jwtSvc.ValidateToken(token)
require.NoError(t, err)
require.NotEmpty(t, claims.SubToken)
// Verify token exists in Redis
redisKey := fmt.Sprintf("/users/%d/session/%s", user.ID, claims.SubToken)
signature, err := redisContainer.Client.Get(ctx, redisKey).Result()
require.NoError(t, err, "token not found in Redis")
// Signature should match JWT signature (third part)
parts := strings.Split(token, ".")
assert.Equal(t, parts[2], signature)
}
Testing Password Hashing in Database
func TestAuthService_PasswordHashing_Integration(t *testing.T) {
ctx := context.Background()
authSvc, pool, _ := setupAuthServiceTest(t, ctx)
password := "securePassword123!@#"
_, _, err := authSvc.Signup(ctx, "test@example.com", "Test User", password)
require.NoError(t, err)
// Query database directly
conn, err := pool.Acquire(ctx)
require.NoError(t, err)
defer conn.Release()
var hash string
err = conn.QueryRow(ctx,
"SELECT password_hash FROM users WHERE email = $1",
"test@example.com",
).Scan(&hash)
require.NoError(t, err)
// Password should be hashed, not plaintext
assert.NotEqual(t, password, hash)
assert.NotEmpty(t, hash)
assert.True(t, strings.HasPrefix(hash, "$2a$")) // bcrypt prefix
}
Test Migrations
Each test suite manages its own schema:
func runAlertMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
migrations := []string{
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) DEFAULT '',
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);`,
`CREATE TABLE IF NOT EXISTS tokens (
id SERIAL PRIMARY KEY,
symbol VARCHAR(10) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tokens_symbol ON tokens(symbol);`,
`CREATE TABLE IF NOT EXISTS alerts (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
symbol VARCHAR(10) NOT NULL,
type VARCHAR(10) NOT NULL,
threshold DECIMAL(19, 8) NOT NULL,
status VARCHAR(20) DEFAULT 'active' NOT NULL,
triggered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_alerts_user_id ON alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_alerts_symbol ON alerts(symbol);`,
}
conn, err := pool.Acquire(ctx)
require.NoError(t, err)
defer conn.Release()
for i, migration := range migrations {
statements := strings.Split(migration, ";")
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := conn.Exec(ctx, stmt); err != nil {
t.Fatalf("migration %d failed: %v", i, err)
}
}
}
}
Test Data Helpers
Insert required foreign key data:
func insertTestUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
conn, err := pool.Acquire(ctx)
require.NoError(t, err)
defer conn.Release()
_, err = conn.Exec(ctx, `
INSERT INTO users (email, name, password_hash)
VALUES ('test@example.com', 'Test User', 'hash')
ON CONFLICT (email) DO NOTHING
`)
require.NoError(t, err)
}
func insertTestTokens(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
conn, err := pool.Acquire(ctx)
require.NoError(t, err)
defer conn.Release()
tokens := []struct {
symbol string
name string
}{
{"BTC", "Bitcoin"},
{"ETH", "Ethereum"},
{"BNB", "Binance Coin"},
}
for _, tk := range tokens {
_, err = conn.Exec(ctx, `
INSERT INTO tokens (symbol, name)
VALUES ($1, $2)
ON CONFLICT (symbol) DO NOTHING
`, tk.symbol, tk.name)
require.NoError(t, err)
}
}
Test Isolation
Each test gets a fresh container. Key patterns:
1. Use t.Cleanup() for automatic teardown:
t.Cleanup(func() {
_ = pgContainer.Cleanup(ctx)
_ = redisContainer.Cleanup(ctx)
})
2. Parallel tests with separate containers:
func TestParallel_AlertRepository(t *testing.T) {
t.Parallel() // Each parallel test gets its own container
ctx := context.Background()
repo, _, _, _ := setupAlertRepositoryTest(t, ctx)
// ...
}
3. Truncate between subtests (if reusing container):
func TestAlertRepository_Suite(t *testing.T) {
ctx := context.Background()
repo, _, _, dbHelper := setupAlertRepositoryTest(t, ctx)
t.Run("Create", func(t *testing.T) {
// test create
})
t.Run("FindByID", func(t *testing.T) {
dbHelper.TruncateTable(ctx, "alerts") // Clean slate
// test find
})
}
Running Tests
# Unit tests only (fast)
go test ./...
# Integration tests only
go test -tags=integration ./...
# All tests
go test -tags=integration ./...
# With coverage
go test -tags=integration -coverprofile=coverage.out ./...
# Specific package
go test -tags=integration -v ./modules/auth/...
# Single test
go test -tags=integration -run TestAuthService_Signup_Integration ./modules/auth/application/services/
CI/CD Configuration
GitHub Actions example:
name: Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- run: go test ./...
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- run: go test -tags=integration -v ./...
Performance Tips
- Reuse containers across tests when possible (shared setup function)
- Use
t.Parallel()for independent test suites - TimescaleDB image includes PostgreSQL—no need for separate images
- Alpine images for Redis (
redis:7-alpine) are smaller and faster to pull
Best Practices Summary
- Use build tags - Separate unit and integration tests
- One container per test suite - Not per test (too slow)
- Cleanup with
t.Cleanup()- Automatic teardown on test completion - Test migrations inline - Each suite manages its own schema
- Insert required foreign key data - Tests should be self-contained
- Verify real behavior - Check database state, Redis keys, etc.
- Use database helpers -
GetTableRowCount,TruncateTablefor verification