Backend Testing 10 min read

Go Integration Testing with Testcontainers: Real PostgreSQL and Redis

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Jan 25, 2026

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

  1. Reuse containers across tests when possible (shared setup function)
  2. Use t.Parallel() for independent test suites
  3. TimescaleDB image includes PostgreSQL—no need for separate images
  4. Alpine images for Redis (redis:7-alpine) are smaller and faster to pull

Best Practices Summary

  1. Use build tags - Separate unit and integration tests
  2. One container per test suite - Not per test (too slow)
  3. Cleanup with t.Cleanup() - Automatic teardown on test completion
  4. Test migrations inline - Each suite manages its own schema
  5. Insert required foreign key data - Tests should be self-contained
  6. Verify real behavior - Check database state, Redis keys, etc.
  7. Use database helpers - GetTableRowCount, TruncateTable for verification

Resources

go golang integration-testing testcontainers postgresql redis docker
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.