testcontainers-go: Docker в тестах на Go
В современной разработке программного обеспечения тестирование интеграций с внешними сервисами (базами данных, брокерами сообщений, кэшами) является одной из самых сложных задач. Традиционные подходы, такие как использование общих тестовых баз данных или моков, часто приводят к проблемам: тесты становятся хрупкими, зависят от окружения или не отражают реального поведения системы. Библиотека testcontainers-go предлагает элегантное решение: она позволяет запускать изолированные Docker-контейнеры прямо из тестового кода на Go, обеспечивая чистую, воспроизводимую и близкую к production среду для каждого тестового запуска.
Основная идея testcontainers-go заключается в том, чтобы управлять жизненным циклом Docker-контейнеров программно. Вы описываете необходимый сервис (например, PostgreSQL, Redis, Kafka) в коде теста, библиотека автоматически скачивает образ (если его нет), запускает контейнер, дожидается его готовности и предоставляет вам параметры подключения (хост, порт, учетные данные). После завершения теста контейнер автоматически останавливается и удаляется, не оставляя мусора в системе. Это делает тесты независимыми, быстрыми и безопасными.
testcontainers-go — это Go-реализация популярного проекта Testcontainers, который изначально появился в Java, а затем был портирован на другие языки. Библиотека активно поддерживается сообществом и предоставляет готовые модули для большинства популярных сервисов: PostgreSQL, MySQL, Redis, MongoDB, Kafka, RabbitMQ, Elasticsearch и многие другие. Она идеально подходит для написания интеграционных тестов, end-to-end тестов и даже для локальной разработки, когда нужно быстро поднять окружение.
Установка
Для установки testcontainers-go используйте стандартный менеджер пакетов Go. Убедитесь, что у вас установлен Docker (или Podman) и Go версии 1.19 или выше.
go get github.com/testcontainers/testcontainers-go
Для работы с конкретными сервисами (например, PostgreSQL) потребуется установить соответствующий модуль:
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/testcontainers/testcontainers-go/modules/kafka
# и т.д.
После установки импортируйте пакеты в ваш тестовый файл:
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
Быстрый старт: минимальный рабочий пример
Давайте напишем простой тест, который запускает контейнер PostgreSQL, создает таблицу, вставляет данные и проверяет их. Это покажет вам основные принципы работы с библиотекой.
package main
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" // драйвер PostgreSQL
)
func TestPostgresIntegration(t *testing.T) {
ctx := context.Background()
// 1. Запускаем контейнер PostgreSQL
postgresContainer, err := postgres.RunContainer(ctx,
testcontainers.WithImage("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(60*time.Second)),
)
if err != nil {
t.Fatal(err)
}
defer postgresContainer.Terminate(ctx) // обязательно очищаем контейнер
// 2. Получаем строку подключения
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
// 3. Подключаемся к базе данных
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// 4. Выполняем тестовые операции
_, err = db.Exec("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)")
if err != nil {
t.Fatal(err)
}
_, err = db.Exec("INSERT INTO users (name) VALUES ($1)", "Alice")
if err != nil {
t.Fatal(err)
}
var name string
err = db.QueryRow("SELECT name FROM users WHERE id = $1", 1).Scan(&name)
if err != nil {
t.Fatal(err)
}
if name != "Alice" {
t.Errorf("expected 'Alice', got '%s'", name)
}
t.Log("Integration test passed!")
}
Запустите тест командой go test -v. Вы увидите, как Docker скачивает образ PostgreSQL (если его нет), запускает контейнер, и тест выполняется успешно. После завершения контейнер будет автоматически удален.
Основные методы и функции
testcontainers-go предоставляет богатый API для управления контейнерами. Ниже приведены 10 ключевых методов и функций с примерами.
1. testcontainers.GenericContainer(ctx, req)
Сигнатура: func GenericContainer(ctx context.Context, req GenericContainerRequest) (Container, error)
Что делает: Создает и запускает контейнер на основе общих параметров. Это самый гибкий метод, позволяющий настроить образ, порты, переменные окружения и стратегии ожидания.
req := testcontainers.ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
WaitingFor: wait.ForHTTP("/").WithPort("80/tcp"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
log.Fatal(err)
}
defer container.Terminate(ctx)
2. container.Endpoint(ctx, port)
Сигнатура: func (c *DockerContainer) Endpoint(ctx context.Context, port string) (string, error)
Что делает: Возвращает внешний адрес (хост и порт) для доступа к контейнеру. Полезно, когда нужно получить динамически назначенный порт.
endpoint, err := container.Endpoint(ctx, "5432")
if err != nil {
log.Fatal(err)
}
// endpoint будет выглядеть как "localhost:54321"
3. container.PortEndpoint(ctx, port, scheme)
Сигнатура: func (c *DockerContainer) PortEndpoint(ctx context.Context, port string, scheme string) (string, error)
Что делает: Аналогичен Endpoint, но добавляет схему (например, "http://" или "tcp://") к возвращаемому адресу.
httpEndpoint, err := container.PortEndpoint(ctx, "8080", "http")
// httpEndpoint будет "http://localhost:32768"
4. container.Exec(ctx, cmd)
Сигнатура: func (c *DockerContainer) Exec(ctx context.Context, cmd []string) (int, io.Reader, error)
Что делает: Выполняет команду внутри запущенного контейнера. Возвращает код возврата, stdout и stderr. Полезно для выполнения миграций или проверки состояния.
exitCode, reader, err := container.Exec(ctx, []string{"ls", "-la", "/app"})
if err != nil {
log.Fatal(err)
}
output, _ := io.ReadAll(reader)
log.Printf("Exit code: %d, Output: %s", exitCode, output)
5. container.CopyFileToContainer(ctx, hostFilePath, containerFilePath, fileMode)
Сигнатура: func (c *DockerContainer) CopyFileToContainer(ctx context.Context, hostFilePath string, containerFilePath string, fileMode int64) error
Что делает: Копирует файл с хост-машины в контейнер. Полезно для загрузки конфигурационных файлов или тестовых данных.
err := container.CopyFileToContainer(ctx, "./testdata/config.yaml", "/app/config.yaml", 0644)
if err != nil {
log.Fatal(err)
}
6. container.CopyFileFromContainer(ctx, containerFilePath)
Сигнатура: func (c *DockerContainer) CopyFileFromContainer(ctx context.Context, containerFilePath string) (io.ReadCloser, error)
Что делает: Копирует файл из контейнера на хост-машину (возвращает reader). Полезно для извлечения логов или результатов работы.
reader, err := container.CopyFileFromContainer(ctx, "/var/log/app.log")
if err != nil {
log.Fatal(err)
}
defer reader.Close()
logContent, _ := io.ReadAll(reader)
log.Printf("Log: %s", logContent)
7. wait.ForLog(logString)
Сигнатура: func ForLog(logString string) *LogStrategy
Что делает: Создает стратегию ожидания, которая ждет появления указанной строки в логах контейнера. Это один из самых надежных способов дождаться готовности сервиса.
req := testcontainers.ContainerRequest{
Image: "redis:7-alpine",
WaitingFor: wait.ForLog("Ready to accept connections"),
}
8. wait.ForHTTP(path)
Сигнатура: func ForHTTP(path string) *HTTPStrategy
Что делает: Создает стратегию ожидания, которая выполняет HTTP GET запрос к указанному пути и ждет ответа 200 OK. Идеально для веб-сервисов.
req := testcontainers.ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
WaitingFor: wait.ForHTTP("/health").WithPort("80/tcp"),
}
9. wait.ForSQL(driver, url, query)
Сигнатура: func ForSQL(driver string, url string, query string) *SQLStrategy
Что делает: Создает стратегию ожидания, которая подключается к базе данных и выполняет проверочный SQL-запрос. Это самый надежный способ дождаться готовности СУБД.
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
Env: map[string]string{
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
ExposedPorts: []string{"5432/tcp"},
WaitingFor: wait.ForSQL("postgres", "host=localhost port=5432 user=test password=test dbname=testdb sslmode=disable", "SELECT 1"),
}
10. testcontainers.WithWaitStrategy(strategy)
Сигнатура: func WithWaitStrategy(strategy wait.Strategy) ContainerOption
Что делает: Функция-опция, которая добавляет стратегию ожидания к запросу контейнера. Может комбинироваться с другими опциями.
container, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16-alpine"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithStartupTimeout(120*time.Second),
),
)
Реальный практический пример: Тестирование REST API с PostgreSQL и Redis
Давайте создадим полноценный пример, который тестирует REST API, использующее PostgreSQL для хранения данных и Redis для кэширования. Мы напишем тест, который запускает оба контейнера, выполняет HTTP-запросы к нашему API и проверяет корректность ответов.
package integration
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/go-redis/redis/v8"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/testcontainers/testcontainers-go/wait"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// User - модель данных
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name"`
Age int `json:"age"`
}
// App - структура нашего приложения
type App struct {
DB *gorm.DB
Redis *redis.Client
}
func (a *App) GetUser(id uint) (*User, error) {
// Пытаемся получить из кэша
val, err := a.Redis.Get(context.Background(), fmt.Sprintf("user:%d", id)).Result()
if err == nil {
var user User
json.Unmarshal([]byte(val), &user)
return &user, nil
}
// Если нет в кэше, идем в БД
var user User
if err := a.DB.First(&user, id).Error; err != nil {
return nil, err
}
// Сохраняем в кэш
data, _ := json.Marshal(user)
a.Redis.Set(context.Background(), fmt.Sprintf("user:%d", id), data, 10*time.Minute)
return &user, nil
}
func (a *App) CreateUser(name string, age int) (*User, error) {
user := User{Name: name, Age: age}
if err := a.DB.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func TestFullIntegration(t *testing.T) {
ctx := context.Background()
// 1. Запускаем PostgreSQL
postgresContainer, err := postgres.RunContainer(ctx,