Содержание урока · 10 разделов
- Почему testcontainers postgres, а не GenericContainer
- postgres.Run: опции и init-скрипты
- Подключение из теста: DSN и sslmode
- Опции postgres.Run: кто за что отвечает
- Snapshot и Restore: чистое состояние на каждый тест
- Типовые ошибки: долгий старт, sslmode, права скриптов
- Чек-лист запуска базы в тесте
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Модуль Postgres — это первый специализированный модуль курса и самый ходовой: почти каждый сервис что-то хранит, и интеграционный тест без хранилища проверяет меньше, чем написано в его имени. До сих пор контейнеры в курсе заводились голым GenericContainer, и для базы этого мало по трём причинам. Первая — конфигурация: у Postgres свой набор переменных окружения, свой каталог инициализации и свои особенности старта, и повторять их руками в каждом тестовом пакете утомительно — где-нибудь да забудете пароль. Вторая — готовность: база инициализируется в два прохода и перезапускает себя посреди старта, поэтому примитивное ожидание флейкает на медленных машинах и в CI под нагрузкой. Третья — удобство: после старта нужно собрать строку подключения с маппированным портом, логином и именем базы, а это рутинный код без отношения к делу. Модуль postgres закрывает все три пункта разом: он знает про переменные образа, про двойной старт и про формат DSN. В этом уроке разберём, что именно он делает за кулисами, как собрать на нём тест и какие грабли он убирает с дороги, а какие оставляет вам.
Почему testcontainers postgres, а не GenericContainer#
GenericContainer из второго урока умеет поднять что угодно, но про Postgres не знает ничего: образ, переменные POSTGRES_USER и POSTGRES_PASSWORD, проброс порта 5432, ожидание готовности — всё руками. Хуже того, готовность у этой базы хитрая: образ после первичной инициализации перезапускает сервер, и лог о готовности появляется дважды; наивное ожидание одного вхождения пропускает опасный момент, а ожидание только открытого порта ловит соединения с ещё не готовым сервером. Модуль закрывает это разом. Он выставляет переменные окружения по умолчанию, открывает порт 5432, стартует сервер с отключённым fsync ради скорости тестов и даёт готовую сборку ожиданий BasicWaitStrategies: двойной лог готовности плюс слушающий порт. Взамен вы получаете типизированный PostgresContainer — с методом DSN, снимками состояния и учтёнными именами базы и пользователя внутри, а не россыпью строк, которую вы собираете и синхронизируете сами в каждом пакете. Ничего магического здесь нет: внутри модуль пользуется теми же GenericContainer и кастомайзерами, но этот код написан и обкатан один раз в исходниках, а не в каждом вашем тесте.
Каркас: postgres.Run с опциями
GOimport (
"context"
"log"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("shop"),
postgres.WithUsername("app"),
postgres.WithPassword("secret"),
postgres.WithInitScripts("testdata/schema.sql", "testdata/seed.sql"),
postgres.BasicWaitStrategies(),
)
if err != nil {
log.Fatalf("postgres не поднялся: %v", err)
}
defer testcontainers.TerminateContainer(pg)
m.Run()
}postgres.Run принимает образ первым аргументом и возвращает контейнер с ошибкой. Каждая опция With превращается в переменную окружения образа: база получит имя shop, пользователь app — пароль secret. Скрипты schema.sql и seed.sql монтируются в каталог инициализации: при старте создадутся схема и справочные данные. BasicWaitStrategies не даст соединиться с неготовой базой, а TerminateContainer в defer уберёт контейнер, даже если тесты упадут. Имя образа — единственное, что вы обязаны выбрать сами.
postgres.Run: опции и init-скрипты#
Вход в модуль — функция postgres.Run(ctx, образ, opts ...testcontainers.ContainerCustomizer): тот же принцип кастомайзеров, что у ядра testcontainers, плюс собственные опции модуля. WithDatabase задаёт имя начальной базы, WithUsername и WithPassword — суперпользователя и его пароль; ничего не передали — получите postgres и postgres на всё. Схему и справочные данные кладут init-скриптами: WithInitScripts монтирует файлы в каталог /docker-entrypoint-initdb.d/, и entrypoint образа исполняет их при первой инициализации пустого каталога данных — в порядке сортировки имён. Когда порядок критичен — сначала схема, потом справочники, — берите WithOrderedInitScripts: она переименовывает файлы числовыми префиксами и исполняет строго в заданном вами порядке. Классическая пара — schema.sql со схемой и seed.sql со справочниками: страны, роли, тарифы — всё то, что тесты читают, но не меняют. Для нестандартной конфигурации сервера есть WithConfigFile: файл монтируется в /etc/postgresql.conf и передаётся серверу ключом config_file — лимиты соединений, локаль, всё, что обычно живёт в конфиге. Миграции внутрь init-скриптов или отдельным шагом теста — решать вам, но к первому соединению схема обязана уже существовать.
Первый запуск Postgres в контейнере — это initdb, затем перезапуск сервера, и только после него рабочая готовность. Строка database system is ready to accept connections появляется в логах дважды, и тот, кто ждёт одного вхождения, подчас продолжает тест в момент, когда сервер ушёл в перезапуск, — и ловит отказ соединения. Отсюда правило: два вхождения плюс слушающий порт — ровно это делает BasicWaitStrategies. Проверить себя легко: поднимите контейнер руками, выведите логи в терминал и посчитайте вхождения строки сами. Спорное мнение с условием: можно обойтись одним ожиданием порта — но на Mac и Windows Docker поднимает отдельный прокси, порт открывается раньше готовности базы, и тесты начинают флейкать; на Linux прокси не нужен, и там такое упрощение чаще прощает себя.
ConnectionString и подключение pgx
GO// pg — *postgres.PostgresContainer из TestMain
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer conn.Close(ctx)
var got int
if err := conn.QueryRow(ctx, "select 1").Scan(&got); err != nil || got != 1 {
t.Fatalf("select 1: %v", err)
}ConnectionString собирает DSN с маппированным портом и дописывает sslmode=disable: контейнер без TLS, и явно отключённый режим снимает вопрос. pgx.Connect открывает одно соединение — для теста этого достаточно, в продакшене берут пул. QueryRow со select 1 — минимальная проверка, что база жива: тест падает с ясной ошибкой, а не висит на первом деле. Close в defer обязателен: соединение должно освободиться до конца прогона.
Подключение из теста: DSN и sslmode#
PostgresContainer.ConnectionString(ctx, args ...string) возвращает DSN вида postgres://пользователь:пароль@хост:порт/база?аргументы — хост и порт берутся из PortEndpoint по порту 5432/tcp, то есть из маппированного порта Docker. Выбирать порт руками не нужно и вредно: на занятой машине тест упадёт, а так Docker отдаёт свободный порт каждому контейнеру, и параллельные прогоны не дерутся. Дополнительные аргументы дописываются в query-строку: sslmode=disable, application_name=myapp. Контейнер по умолчанию не настроен на TLS, поэтому sslmode=disable задают явно: дефолты у драйверов разные — lib/pq требует шифрования, pgx предпочитает и откатывается, — и сюрпризы тут никому не нравятся. Аргументы склеиваются амперсандами, поэтому передать можно сколько нужно: connect_timeout=10 или application_name=myapp — формат тот же, что в query-строке на проде. Для фикстур есть MustConnectionString: паника вместо ошибки — удобно, когда старт уже проверен и ручная проверка ошибки только шумит. Подключайтесь чем угодно: pgx.Connect, database/sql с драйвером pgx — postgres тесты go проходят на честном SQL, а тонкости самого драйвера — тема отдельного курса pgx.
Опции postgres.Run: кто за что отвечает#
| Опция | За что отвечает | Пример значения |
|---|---|---|
| WithDatabase | имя начальной базы, создаваемой при старте | WithDatabase("shop") |
| WithUsername | суперпользователь; без опции — postgres | WithUsername("app") |
| WithPassword | пароль суперпользователя, обязателен для образа | WithPassword("secret") |
| WithInitScripts | скрипты инициализации, порядок — по именам файлов | WithInitScripts("testdata/schema.sql") |
| WithOrderedInitScripts | те же скрипты, но строго в заданном порядке | WithOrderedInitScripts("01-schema.sql", "02-seed.sql") |
| WithConfigFile | свой postgresql.conf: монтируется и уходит в config_file | WithConfigFile("my-postgres.conf") |
Проверка: имя базы по умолчанию
postgres.Run вызвали без WithDatabase. Какое имя базы окажется в DSN, который вернёт ConnectionString?
Snapshot и Restore: чистое состояние на каждый тест#
После миграций и справочных данных состояние базы хочется зафиксировать и откатывать. Для этого у контейнера есть методы Snapshot и Restore: Snapshot копирует текущую базу в базу-шаблон — по умолчанию она называется migrated_template, имя меняется опцией WithSnapshotName, — а Restore удаляет рабочую базу и пересоздаёт её из шаблона. Это дешевле, чем новый контейнер на каждый тест: восстановление — несколько SQL-команд, а не запуск ещё одного Postgres. На больших схемах снимок берут один раз на пакет и откатывают в каждом тесте: секунды на восстановление против минут на пересоздание с миграциями. Механика честная: под капотом sql.Open с драйвером, зарегистрированным в программе; по умолчанию это database/sql-драйвер postgres, но если приложение импортирует pgx, скажите об этом модулю через WithSQLDriver — лишний драйвер ради снимков тянуть не придётся. Одна ловушка из исходников: базу с именем postgres восстановить нельзя — Restore дропает рабочую базу, и системную трогать запрещено, модуль вернёт ошибку. К переиспользованию контейнера между запусками вернёмся в уроке 16 — там эта механика выстрелит в полную силу.
Snapshot и Restore между тестами
GO// один раз после миграций: снимок уйдёт в базу-шаблон
if err := pg.Snapshot(ctx); err != nil {
t.Fatalf("snapshot: %v", err)
}
// в тесте, который портит данные, — откат в cleanup:
t.Cleanup(func() {
if err := pg.Restore(ctx); err != nil {
t.Logf("restore: %v", err)
}
})
// имя шаблона: pg.Snapshot(ctx, postgres.WithSnapshotName("per_suite"))
// драйвер для снимков: postgres.WithSQLDriver("pgx")Snapshot делают один раз после миграций: база копируется в шаблон migrated_template. Restore в t.Cleanup возвращает базу к снимку после каждого теста, который портит данные. Восстановление выполняется SQL-командами через драйвер, уже зарегистрированный программой, — с WithSQLDriver pgx не нужно тянуть лишний драйвер только ради снимков. Тест, который портит справочники, без Restore оставит следующему тесту мусор в данных.
Проверка: снимок системной базы
Контейнер создан без WithDatabase, вы вызвали pg.Snapshot(ctx). Что произойдёт?
Типовые ошибки: долгий старт, sslmode, права скриптов#
Первая грабля — долгий старт. Первый запуск тянет образ, делает initdb и перезапускает сервер; на холодной CI-машине это минуты. Короткий context.WithTimeout вокруг Run убьёт контейнер посреди инициализации — потолок ставят на весь прогон или не ставят вовсе, а образ кешируют. Без wait-стратегии Run вообще вернётся мгновенно: контейнер стартовал, но первые запросы упадут в отказ. Вторая грабля — sslmode. Без явного sslmode=disable драйвер пытается договориться о TLS, которого в контейнере нет, и тест падает загадочной ошибкой про отказ в соединении — ошибка всплывает не в Run, а на первом запросе, и выглядит как проблема базы, хотя дело в настройке соединения. Третья — init-скрипты. Права модуль берёт на себя: монтирует файлы с режимом 0755, и chmod на хосте не нужен. Ловятся на другом: скрипты исполняются только при первой инициализации пустого каталога данных — на переиспользуемом контейнере они молча пропустятся; сортировка по именам запустит seed.sql раньше schema.sql, если имена не продуманы; а сломанный скрипт валит entrypoint, и wait-стратегия честно истекает по дедлайну.
Чек-лист запуска базы в тесте#
- postgres.Run вместо GenericContainer: окружение, порт и fsync уже настроены
- образ — первый аргумент Run; лёгкий alpine-вариант стартует быстрее
- WithDatabase, WithUsername, WithPassword — имя базы и суперпользователь
- схема и справочные данные — WithInitScripts; порядок важен — WithOrderedInitScripts
- готовность — BasicWaitStrategies: двойной лог готовности плюс слушающий порт
- DSN — ConnectionString со sslmode=disable; порт выбирает Docker, не вы
- Snapshot после миграций, Restore в cleanup — чистое состояние без пересоздания
Что вернёт ConnectionString
Прочитайте код и выберите, что он выведет:
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("shop"),
postgres.BasicWaitStrategies(),
)
// err == nil
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")Соберите запуск базы
Восстановите вызовы: старт контейнера, имя базы, пароль и получение DSN.
Расставьте минимальный тест по порядку
Соберите путь теста: контекст, старт базы, проверка ошибки, уборка, DSN, подключение.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: модуль Postgres
Честно: в браузерной песочнице этот урок не запустить — Docker там нет, а модулю нужен живой демон. Рабочая схема — локально: установленный Go, запущенный Docker и обычный go test в терминале. Код разобран построчно, так что напечатайте тест руками и прогоните на своей машине: первая сборка скачает образ за пару минут, последующие пойдут быстро — образ кешируется. И держите Docker запущенным: ошибка про подключение к демону — это не про тест, а про окружение.
Что дальше: база — главный модуль testcontainers, дальше курс пройдёт по остальным хранилищам и брокерам, а пока закрепите связку базы с драйвером. Пятый урок — тестируем pgx на живой базе — заберёт этот каркас и доведёт до полноценных тестов репозитория: пулы, транзакции, изоляция данных между тестами. Драйвер с наскоку не освоить — курс pgx разбирает его по косточкам, от простых запросов до батчей и пула. Обзор всего пути — курс testcontainers: от жизненного цикла контейнера до CI и параллельности. Каркас TestMain из этого урока скопируйте в проект один раз — он переживёт десятки тестов без единой правки: контекст, Run, уборка в defer. И проверка на честность перед следующей темой: уберите BasicWaitStrategies из своего теста и погоните его на медленной машине — частота падений скажет о цене готовой стратегии ожидания лучше любых слов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- postgres.Run поднимает настоящий Postgres: WithDatabase, WithUsername, WithPassword задают базу и суперпользователя, дефолт — postgres.
- Схему и справочники кладут init-скриптами: WithInitScripts — по именам, WithOrderedInitScripts — в заданном порядке, права 0755 модуль ставит сам.
- ConnectionString отдаёт готовый DSN с маппированным портом; sslmode=disable задают явно — TLS в контейнере нет.
- BasicWaitStrategies ждёт двойной лог готовности и слушающий порт; Snapshot и Restore откатывают базу к снимку между тестами.
Домашнее задание
Соберите тестовый пакет с TestMain: postgres.Run с WithDatabase и двумя init-скриптами — схемой и справочными данными, подключение через pgx и ConnectionString со sslmode=disable. Затем добавьте Snapshot после старта и Restore в t.Cleanup в тесте, который меняет данные, и убедитесь, что состояние откатывается. В конце уберите sslmode=disable из DSN и прочитайте ошибку драйвера — теперь вы знаете её в лицо. Сравните время первого и второго запуска: разница и есть цена кеша образов.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли заменить модуль на GenericContainer с тем же образом?
Технически да: задайте переменные POSTGRES_*, пробросьте порт 5432 и соберите wait-стратегию сами. Но вы потеряете типизированный PostgresContainer с ConnectionString, готовую сборку ожиданий с учётом двойного перезапуска и методы Snapshot и Restore. Модуль — те же GenericContainer и кастомайзеры внутри, только уже собранные под Postgres: меньше кода в тесте и меньше флейков. Образ поднимется один и тот же — различие в объёме кода, который вы поддерживаете.
Почему ConnectionString отдаёт странный порт вместо 5432?
Docker маппирует порт контейнера на свободный порт хоста, и этот порт меняется от запуска к запуску. ConnectionString делает эту работу за вас через PortEndpoint: адрес всегда актуальный, а параллельные прогоны не дерутся за один порт. Жёстко прописанный 5432 в DSN — классическая ошибка переноса кода с локальной базы на тестовую.
Init-скрипты не сработали на переиспользуемом контейнере. Почему?
Скрипты из каталога /docker-entrypoint-initdb.d/ исполняются только при первой инициализации пустого каталога данных. Если контейнер уже инициализирован — например, переиспользуется между запусками, — entrypoint пропустит их молча. Тот же эффект даёт и чужой том от старого запуска. Накатывайте схему отдельным шагом теста или создавайте контейнер с чистым томом, тогда скрипты отработают штатно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах