Урок 22: Интеграционные тесты с testcontainers

Урок 22. Интеграционные тесты с testcontainers

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-integration-tests && cd go-integration-tests
go mod init go-integration-tests

# Устанавливаем зависимости
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
go get github.com/testcontainers/testcontainers-go/modules/mongodb
go get github.com/jackc/pgx/v5
go get github.com/jackc/pgx/v5/pgxpool
go get github.com/redis/go-redis/v9
go get github.com/stretchr/testify
go get github.com/google/uuid
go mod tidy

# Создаём структуру
mkdir -p internal/repository
mkdir -p migrations

🗄️ Файл: migrations/001_init.up.sql

CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);

💻 Файл: internal/repository/user_repo.go

package repository

import (
    "context"
    "fmt"
    "time"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/google/uuid"
)

type User struct {
    ID        uuid.UUID
    Email     string
    Name      string
    CreatedAt time.Time
    UpdatedAt time.Time
}

type UserRepo struct {
    pool *pgxpool.Pool
}

func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
    return &UserRepo{pool: pool}
}

func (r *UserRepo) Create(ctx context.Context, email, name string) (*User, error) {
    user := &User{}
    err := r.pool.QueryRow(ctx,
        `INSERT INTO users (email, name) VALUES ($1, $2)
         RETURNING id, email, name, created_at, updated_at`,
        email, name,
    ).Scan(&user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt)
    if err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }
    return user, nil
}

func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*User, error) {
    user := &User{}
    err := r.pool.QueryRow(ctx,
        `SELECT id, email, name, created_at, updated_at FROM users WHERE email = $1`,
        email,
    ).Scan(&user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt)
    if err != nil {
        if err == pgx.ErrNoRows {
            return nil, fmt.Errorf("user not found: %w", err)
        }
        return nil, fmt.Errorf("get user: %w", err)
    }
    return user, nil
}

func (r *UserRepo) Count(ctx context.Context) (int, error) {
    var count int
    err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
    return count, err
}

💻 Файл: internal/repository/user_repo_test.go

package repository_test

import (
    "context"
    "fmt"
    "log"
    "os"
    "testing"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/stretchr/testify/suite"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"

    "go-integration-tests/internal/repository"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  TEST SUITE (группа тестов с общим контейнером)        ║
// ╚══════════════════════════════════════════════════════════╝

// UserRepoTestSuite — testify suite для группировки тестов
type UserRepoTestSuite struct {
    suite.Suite
    container *postgres.PostgresContainer
    pool      *pgxpool.Pool
    repo      *repository.UserRepo
    ctx       context.Context
}

// SetupSuite — запускается ОДИН РАЗ перед всеми тестами
func (s *UserRepoTestSuite) SetupSuite() {
    s.ctx = context.Background()

    // 1. Запускаем PostgreSQL в Docker
    postgresContainer, err := postgres.Run(
        s.ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("testuser"),
        postgres.WithPassword("testpass"),
        // Ждём, пока БД будет готова принимать соединения
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2).
                WithStartupTimeout(30*time.Second),
        ),
    )
    require.NoError(s.T(), err, "Не удалось запустить PostgreSQL контейнер")
    s.container = postgresContainer

    // 2. Получаем строку подключения
    connStr, err := postgresContainer.ConnectionString(s.ctx, "sslmode=disable")
    require.NoError(s.T(), err)

    // 3. Создаём пул соединений
    pool, err := pgxpool.New(s.ctx, connStr)
    require.NoError(s.T(), err)
    s.pool = pool

    // 4. Применяем миграции
    s.runMigrations()

    // 5. Создаём репозиторий
    s.repo = repository.NewUserRepo(pool)

    log.Println("✅ Тестовое окружение готово")
}

// TearDownSuite — запускается ОДИН РАЗ после всех тестов
func (s *UserRepoTestSuite) TearDownSuite() {
    if s.pool != nil {
        s.pool.Close()
    }
    if s.container != nil {
        if err := s.container.Terminate(s.ctx); err != nil {
            log.Printf("Ошибка остановки контейнера: %v", err)
        }
    }
    log.Println("🧹 Тестовое окружение очищено")
}

// SetupTest — запускается ПЕРЕД каждым тестом
func (s *UserRepoTestSuite) SetupTest() {
    // Очищаем таблицу перед каждым тестом
    _, err := s.pool.Exec(s.ctx, "DELETE FROM users")
    require.NoError(s.T(), err, "Не удалось очистить таблицу")
}

// runMigrations — применяет SQL-миграции
func (s *UserRepoTestSuite) runMigrations() {
    migration, err := os.ReadFile("../../migrations/001_init.up.sql")
    require.NoError(s.T(), err, "Не удалось прочитать миграцию")

    _, err = s.pool.Exec(s.ctx, string(migration))
    require.NoError(s.T(), err, "Не удалось применить миграцию")
    log.Println("  ✓ Миграции применены")
}

// ╔══════════════════════════════════════════════════════════╗
// ║  ТЕСТЫ                                                  ║
// ╚══════════════════════════════════════════════════════════╝

func (s *UserRepoTestSuite) TestCreateUser() {
    // Act
    user, err := s.repo.Create(s.ctx, "alice@example.com", "Alice")

    // Assert
    require.NoError(s.T(), err)
    assert.NotEmpty(s.T(), user.ID)
    assert.Equal(s.T(), "alice@example.com", user.Email)
    assert.Equal(s.T(), "Alice", user.Name)
    assert.False(s.T(), user.CreatedAt.IsZero())
    assert.False(s.T(), user.UpdatedAt.IsZero())
}

func (s *UserRepoTestSuite) TestCreateUser_DuplicateEmail() {
    // Arrange
    _, err := s.repo.Create(s.ctx, "alice@example.com", "Alice")
    require.NoError(s.T(), err)

    // Act — пытаемся создать с тем же email
    _, err = s.repo.Create(s.ctx, "alice@example.com", "Another Alice")

    // Assert — должна быть ошибка уникальности
    assert.Error(s.T(), err)
    assert.Contains(s.T(), err.Error(), "duplicate key")
}

func (s *UserRepoTestSuite) TestGetByEmail() {
    // Arrange
    created, err := s.repo.Create(s.ctx, "bob@example.com", "Bob")
    require.NoError(s.T(), err)

    // Act
    found, err := s.repo.GetByEmail(s.ctx, "bob@example.com")

    // Assert
    require.NoError(s.T(), err)
    assert.Equal(s.T(), created.ID, found.ID)
    assert.Equal(s.T(), created.Email, found.Email)
    assert.Equal(s.T(), created.Name, found.Name)
}

func (s *UserRepoTestSuite) TestGetByEmail_NotFound() {
    // Act
    _, err := s.repo.GetByEmail(s.ctx, "nonexistent@example.com")

    // Assert
    assert.Error(s.T(), err)
    assert.Contains(s.T(), err.Error(), "not found")
}

func (s *UserRepoTestSuite) TestCount() {
    // Arrange — создаём 3 пользователей
    s.repo.Create(s.ctx, "user1@example.com", "User1")
    s.repo.Create(s.ctx, "user2@example.com", "User2")
    s.repo.Create(s.ctx, "user3@example.com", "User3")

    // Act
    count, err := s.repo.Count(s.ctx)

    // Assert
    require.NoError(s.T(), err)
    assert.Equal(s.T(), 3, count)
}

// TestUserRepoTestSuite — точка входа для testify suite
func TestUserRepoTestSuite(t *testing.T) {
    // Проверяем, что Docker доступен
    if testing.Short() {
        t.Skip("Пропускаем интеграционные тесты в коротком режиме")
    }

    suite.Run(t, new(UserRepoTestSuite))
}

💻 Файл: internal/repository/redis_cache_test.go

package repository_test

import (
    "context"
    "testing"
    "time"

    "github.com/redis/go-redis/v9"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/redis"
)

// TestRedisCache — интеграционный тест с Redis
func TestRedisCache(t *testing.T) {
    if testing.Short() {
        t.Skip("Пропускаем интеграционные тесты")
    }

    ctx := context.Background()

    // 1. Запускаем Redis в Docker
    redisContainer, err := redis.Run(
        ctx,
        "redis:7-alpine",
        testcontainers.WithWaitStrategy(
            // Ждём, пока Redis ответит на PING
            wait.ForLog("Ready to accept connections").
                WithStartupTimeout(15*time.Second),
        ),
    )
    require.NoError(t, err)
    defer func() {
        if err := redisContainer.Terminate(ctx); err != nil {
            t.Logf("Ошибка остановки Redis: %v", err)
        }
    }()

    // 2. Получаем адрес
    redisAddr, err := redisContainer.ConnectionString(ctx)
    require.NoError(t, err)

    // 3. Подключаемся
    client := redis.NewClient(&redis.Options{
        Addr: redisAddr,
    })
    defer client.Close()

    // Проверяем соединение
    require.NoError(t, client.Ping(ctx).Err())

    // 4. ТЕСТИРУЕМ КЭШИРОВАНИЕ
    t.Run("Set and Get", func(t *testing.T) {
        err := client.Set(ctx, "test-key", "test-value", 1*time.Minute).Err()
        require.NoError(t, err)

        val, err := client.Get(ctx, "test-key").Result()
        require.NoError(t, err)
        assert.Equal(t, "test-value", val)
    })

    t.Run("Get non-existent key", func(t *testing.T) {
        _, err := client.Get(ctx, "non-existent").Result()
        assert.ErrorIs(t, err, redis.Nil)
    })

    t.Run("Set with TTL", func(t *testing.T) {
        err := client.Set(ctx, "temp-key", "temp-value", 100*time.Millisecond).Err()
        require.NoError(t, err)

        // Сразу должен быть доступен
        val, err := client.Get(ctx, "temp-key").Result()
        require.NoError(t, err)
        assert.Equal(t, "temp-value", val)

        // Ждём истечения TTL
        time.Sleep(150 * time.Millisecond)

        _, err = client.Get(ctx, "temp-key").Result()
        assert.ErrorIs(t, err, redis.Nil)
    })
}

💻 Файл: internal/repository/testmain_test.go

package repository_test

import (
    "context"
    "fmt"
    "log"
    "os"
    "testing"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/testcontainers/testcontainers-go/modules/postgres"

    "go-integration-tests/internal/repository"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  TestMain — глобальный setup/teardown (альтернатива)   ║
// ╚══════════════════════════════════════════════════════════╝

var (
    testPool *pgxpool.Pool
    testRepo *repository.UserRepo
)

// TestMain — запускается ОДИН РАЗ для всего пакета
func TestMain(m *testing.M) {
    // Setup
    ctx := context.Background()

    postgresContainer, err := postgres.Run(
        ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    if err != nil {
        log.Fatalf("Не удалось запустить контейнер: %v", err)
    }

    connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        log.Fatalf("Connection string: %v", err)
    }

    pool, err := pgxpool.New(ctx, connStr)
    if err != nil {
        log.Fatalf("Pool: %v", err)
    }

    // Применяем миграции
    migration, _ := os.ReadFile("../../migrations/001_init.up.sql")
    pool.Exec(ctx, string(migration))

    testPool = pool
    testRepo = repository.NewUserRepo(pool)

    log.Println("✅ Глобальный setup завершён")

    // Запускаем ВСЕ тесты пакета
    exitCode := m.Run()

    // Teardown
    pool.Close()
    if err := postgresContainer.Terminate(ctx); err != nil {
        log.Printf("Ошибка остановки: %v", err)
    }

    log.Println("🧹 Глобальный teardown завершён")
    os.Exit(exitCode)
}

// TestWithGlobalSetup — использует глобальные testPool/testRepo
func TestWithGlobalSetup(t *testing.T) {
    t.Run("Create user via global repo", func(t *testing.T) {
        user, err := testRepo.Create(context.Background(),
            fmt.Sprintf("user-%d@test.com", time.Now().UnixNano()),
            "Test User",
        )
        require.NoError(t, err)
        assert.NotEmpty(t, user.ID)
    })
}

import (
    "time"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

💻 Файл: internal/repository/mongodb_test.go

package repository_test

import (
    "context"
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/mongodb"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

func TestMongoDB(t *testing.T) {
    if testing.Short() {
        t.Skip("Пропускаем интеграционные тесты")
    }

    ctx := context.Background()

    // 1. Запускаем MongoDB
    mongoContainer, err := mongodb.Run(ctx, "mongo:7")
    require.NoError(t, err)
    defer mongoContainer.Terminate(ctx)

    // 2. Получаем строку подключения
    connStr, err := mongoContainer.ConnectionString(ctx)
    require.NoError(t, err)

    // 3. Подключаемся
    client, err := mongo.Connect(ctx, options.Client().ApplyURI(connStr))
    require.NoError(t, err)
    defer client.Disconnect(ctx)

    require.NoError(t, client.Ping(ctx, nil))

    // 4. Тестируем CRUD
    col := client.Database("testdb").Collection("users")

    t.Run("Insert and Find", func(t *testing.T) {
        // Insert
        doc := bson.M{"name": "Alice", "email": "alice@example.com"}
        result, err := col.InsertOne(ctx, doc)
        require.NoError(t, err)
        require.NotNil(t, result.InsertedID)

        // Find
        var found bson.M
        err = col.FindOne(ctx, bson.M{"name": "Alice"}).Decode(&found)
        require.NoError(t, err)
        assert.Equal(t, "Alice", found["name"])
        assert.Equal(t, "alice@example.com", found["email"])
    })

    t.Run("Update", func(t *testing.T) {
        _, err := col.UpdateOne(
            ctx,
            bson.M{"name": "Alice"},
            bson.M{"$set": bson.M{"email": "alice.new@example.com"}},
        )
        require.NoError(t, err)

        var updated bson.M
        col.FindOne(ctx, bson.M{"name": "Alice"}).Decode(&updated)
        assert.Equal(t, "alice.new@example.com", updated["email"])
    })
}

🚀 Запуск тестов

# Все интеграционные тесты (нужен Docker!)
go test -v ./internal/repository/

# Только модульные тесты (без Docker)
go test -v -short ./...

# Конкретный integration тест
go test -v -run TestRedisCache ./internal/repository/

# Test suite
go test -v -run TestUserRepoTestSuite ./internal/repository/

# С флагом -race
go test -v -race ./internal/repository/

# С таймаутом (чтобы не висли)
go test -v -timeout 5m ./internal/repository/

📊 Модульные vs Интеграционные тесты

ХарактеристикаМодульные (урок 21)Интеграционные (этот урок)
ЗависимостиМоки (gomock)Реальные БД в Docker
СкоростьМиллисекундыСекунды (запуск контейнера)
ИзоляцияПолнаяТребует очистки данных
Уверенность”Логика работает""Работает с реальной БД”
CIВсегдаНужен Docker в CI
Запускgo test -shortgo test (полный)
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →