@testcontainers/postgres → testcontainers-go
beforeAll(async () => { container = await ... }) → TestMain или setup функция
await container.start() → testcontainers.GenericContainer(...)
container.getMappedPort(5432) → container.MappedPort(ctx, "5432")
await container.stop() → container.Terminate(ctx)
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.sqlCREATE 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.gopackage 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.gopackage 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.gopackage 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.gopackage 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.gopackage 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/
| Характеристика | Модульные (урок 21) | Интеграционные (этот урок) |
|---|---|---|
| Зависимости | Моки (gomock) | Реальные БД в Docker |
| Скорость | Миллисекунды | Секунды (запуск контейнера) |
| Изоляция | Полная | Требует очистки данных |
| Уверенность | ”Логика работает" | "Работает с реальной БД” |
| CI | Всегда | Нужен Docker в CI |
| Запуск | go test -short | go test (полный) |
docker ps.
💡 Best practices от сеньоров:
💡 Для Node.js разработчика: