Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 4 из 25 ~18 мин +50 XP

Модуль Postgres: настоящая база в тесте

postgres тесты go обычно спотыкаются об одно и то же: моки врут про SQL, а живая база в CI то есть, то нет. Модуль testcontainers postgres решает это в лоб — он поднимает в Docker-контейнере настоящий Postgres и отдаёт готовый DSN, как только база действительно готова принимать соединения. Вместо ручного GenericContainer с подбором образов, переменных окружения и wait-стратегий вы пишете один вызов postgres.Run с несколькими опциями — и дальше работаете с настоящим SQL: схемой, транзакциями, ограничениями и справочными данными. В уроке соберём такой тест с нуля: опции запуска и init-скрипты, подключение через pgx, снимки состояния Snapshot и Restore и типовые грабли первого дня — долгий старт, sslmode и порядок скриптов.

Содержание урока · 10 разделов

Модуль Postgres — это первый специализированный модуль курса и самый ходовой: почти каждый сервис что-то хранит, и интеграционный тест без хранилища проверяет меньше, чем написано в его имени. До сих пор контейнеры в курсе заводились голым GenericContainer, и для базы этого мало по трём причинам. Первая — конфигурация: у Postgres свой набор переменных окружения, свой каталог инициализации и свои особенности старта, и повторять их руками в каждом тестовом пакете утомительно — где-нибудь да забудете пароль. Вторая — готовность: база инициализируется в два прохода и перезапускает себя посреди старта, поэтому примитивное ожидание флейкает на медленных машинах и в CI под нагрузкой. Третья — удобство: после старта нужно собрать строку подключения с маппированным портом, логином и именем базы, а это рутинный код без отношения к делу. Модуль postgres закрывает все три пункта разом: он знает про переменные образа, про двойной старт и про формат DSN. В этом уроке разберём, что именно он делает за кулисами, как собрать на нём тест и какие грабли он убирает с дороги, а какие оставляет вам.

Почему testcontainers postgres, а не GenericContainer#

GenericContainer из второго урока умеет поднять что угодно, но про Postgres не знает ничего: образ, переменные POSTGRES_USER и POSTGRES_PASSWORD, проброс порта 5432, ожидание готовности — всё руками. Хуже того, готовность у этой базы хитрая: образ после первичной инициализации перезапускает сервер, и лог о готовности появляется дважды; наивное ожидание одного вхождения пропускает опасный момент, а ожидание только открытого порта ловит соединения с ещё не готовым сервером. Модуль закрывает это разом. Он выставляет переменные окружения по умолчанию, открывает порт 5432, стартует сервер с отключённым fsync ради скорости тестов и даёт готовую сборку ожиданий BasicWaitStrategies: двойной лог готовности плюс слушающий порт. Взамен вы получаете типизированный PostgresContainer — с методом DSN, снимками состояния и учтёнными именами базы и пользователя внутри, а не россыпью строк, которую вы собираете и синхронизируете сами в каждом пакете. Ничего магического здесь нет: внутри модуль пользуется теми же GenericContainer и кастомайзерами, но этот код написан и обкатан один раз в исходниках, а не в каждом вашем тесте.

Каркас: postgres.Run с опциями

GO
import (
    "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-скриптов или отдельным шагом теста — решать вам, но к первому соединению схема обязана уже существовать.

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суперпользователь; без опции — postgresWithUsername("app")
WithPasswordпароль суперпользователя, обязателен для образаWithPassword("secret")
WithInitScriptsскрипты инициализации, порядок — по именам файловWithInitScripts("testdata/schema.sql")
WithOrderedInitScriptsте же скрипты, но строго в заданном порядкеWithOrderedInitScripts("01-schema.sql", "02-seed.sql")
WithConfigFileсвой postgresql.conf: монтируется и уходит в config_fileWithConfigFile("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.

pg, err := postgres.(ctx, "postgres:16-alpine", postgres.("shop"), postgres.WithUsername("app"), postgres.("secret"), ) dsn, err := pg.(ctx, "sslmode=disable")

Расставьте минимальный тест по порядку

Соберите путь теста: контекст, старт базы, проверка ошибки, уборка, DSN, подключение.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: модуль Postgres

Термин

postgres.Run

нажмите, чтобы перевернуть
Определение

Вход в модуль: образ первым аргументом, дальше кастомайзеры. Возвращает типизированный PostgresContainer с ошибкой; дефолты окружения — postgres и postgres.

1 / 6

Что дальше: база — главный модуль 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 и прочитайте ошибку драйвера — теперь вы знаете её в лицо. Сравните время первого и второго запуска: разница и есть цена кеша образов.

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · Модуль Postgres: настоящая база в тесте)

Вопросы и ответы по уроку

Можно ли заменить модуль на GenericContainer с тем же образом?

Технически да: задайте переменные POSTGRES_*, пробросьте порт 5432 и соберите wait-стратегию сами. Но вы потеряете типизированный PostgresContainer с ConnectionString, готовую сборку ожиданий с учётом двойного перезапуска и методы Snapshot и Restore. Модуль — те же GenericContainer и кастомайзеры внутри, только уже собранные под Postgres: меньше кода в тесте и меньше флейков. Образ поднимется один и тот же — различие в объёме кода, который вы поддерживаете.

Почему ConnectionString отдаёт странный порт вместо 5432?

Docker маппирует порт контейнера на свободный порт хоста, и этот порт меняется от запуска к запуску. ConnectionString делает эту работу за вас через PortEndpoint: адрес всегда актуальный, а параллельные прогоны не дерутся за один порт. Жёстко прописанный 5432 в DSN — классическая ошибка переноса кода с локальной базы на тестовую.

Init-скрипты не сработали на переиспользуемом контейнере. Почему?

Скрипты из каталога /docker-entrypoint-initdb.d/ исполняются только при первой инициализации пустого каталога данных. Если контейнер уже инициализирован — например, переиспользуется между запусками, — entrypoint пропустит их молча. Тот же эффект даёт и чужой том от старого запуска. Накатывайте схему отдельным шагом теста или создавайте контейнер с чистым томом, тогда скрипты отработают штатно.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Go Base25 уроков~12 чновичок25 уроков уже доступны

Go с нуля: базовый синтаксис и конструкции

Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.

  • Писать, компилировать и запускать программы на Go
  • Свободно работать с переменными, типами, операторами
  • Управлять потоком выполнения: if, switch, for, range
  • Хранить данные в массивах, срезах и картах