Интеграционное тестирование Go с Docker: от теории к продвинутой практике
Интеграционное тестирование — критически важный этап разработки микросервисов на Go. Без проверки взаимодействия с базами данных, очередями сообщений и внешними API вы рискуете получить "работает на моей машине" в самом неприятном его проявлении. Docker позволяет поднимать чистые, изолированные окружения для каждого теста, а продвинутые техники — делать это быстро, параллельно и без боли. В этой статье мы разберём реальные подходы, которые используют senior-инженеры.
1. Почему Docker — идеальный партнёр для интеграционных тестов в Go
Go компилируется в статический бинарник, что делает его идеальным кандидатом для контейнеризации. При интеграционном тестировании Docker решает три ключевые задачи:
- Изоляция: каждый тест получает свежую базу данных или Redis без "мусора" от предыдущих запусков.
- Воспроизводимость: тесты работают одинаково на машине разработчика, в CI/CD и на production.
- Скорость: правильно настроенные контейнеры запускаются за секунды, а не минуты.
Основная ошибка новичков — запуск одного контейнера на все тесты. Это приводит к race conditions и "плавающим" багам. Продвинутый подход: один контейнер на тестовый пакет или даже на отдельный тест.
2. Testcontainers для Go: управляемые контейнеры из кода
Библиотека testcontainers-go позволяет управлять Docker-контейнерами прямо из Go-тестов. Это элегантнее, чем писать bash-скрипты или полагаться на предварительно запущенный Docker Compose.
Установка:
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
Пример: тестирование PostgreSQL с миграциями:
package integration_test
import (
"context"
"database/sql"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
func TestUserRepository(t *testing.T) {
ctx := context.Background()
pgContainer, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("user"),
postgres.WithPassword("password"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatal(err)
}
defer pgContainer.Terminate(ctx)
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Здесь выполняем миграции и тесты
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
)`)
if err != nil {
t.Fatal(err)
}
// Тестируем репозиторий
repo := NewUserRepository(db)
user, err := repo.Create(ctx, "Alice")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
Ключевые моменты:
WithWaitStrategy— ждём, пока PostgreSQL действительно будет готов, а не просто контейнер запущен.defer pgContainer.Terminate(ctx)— гарантирует очистку даже при панике.- Каждый тест получает свой экземпляр БД — полная изоляция.
3. Параллельные тесты и управление ресурсами
Go поддерживает параллельное выполнение тестов через t.Parallel(). Но с Docker-контейнерами это требует аккуратности: если вы запустите 10 тестов, каждый поднимет свой PostgreSQL, память закончится быстро.
Оптимальная стратегия — пул контейнеров. Используем sync.Pool для переиспользования:
package testutil
import (
"context"
"sync"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/redis"
)
type RedisPool struct {
pool sync.Pool
}
func NewRedisPool() *RedisPool {
return &RedisPool{
pool: sync.Pool{
New: func() interface{} {
ctx := context.Background()
container, err := redis.RunContainer(ctx,
testcontainers.WithImage("redis:7-alpine"),
)
if err != nil {
panic(err)
}
return container
},
},
}
}
func (p *RedisPool) Get(ctx context.Context) (*redis.RedisContainer, error) {
container := p.pool.Get().(*redis.RedisContainer)
// Проверяем, жив ли контейнер
if err := container.IsRunning(ctx); err != nil {
p.pool.Put(container)
return p.Get(ctx) // рекурсивно получаем новый
}
return container, nil
}
func (p *RedisPool) Put(container *redis.RedisContainer) {
p.pool.Put(container)
}
Использование в тестах:
var redisPool = testutil.NewRedisPool()
func TestRedisCache(t *testing.T) {
t.Parallel()
ctx := context.Background()
container, err := redisPool.Get(ctx)
if err != nil {
t.Fatal(err)
}
defer redisPool.Put(container)
// Тестируем кэш
addr, _ := container.Endpoint(ctx, "redis://")
cache := NewCache(addr)
err = cache.Set(ctx, "key", "value", time.Minute)
if err != nil {
t.Fatal(err)
}
}
Важно: синхронизируйте доступ к пулу через мьютексы, если используете его из разных тестовых пакетов. Для production-пайплайнов рассмотрите dockertest от Oracle — он умнее управляет ресурсами.
4. Docker Compose для сложных сценариев
Когда ваше приложение зависит от нескольких сервисов (БД + Redis + Kafka), удобнее использовать Docker Compose. Создайте docker-compose.test.yml:
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: user
POSTGRES_PASSWORD: password
healthcheck:
test: