---
title: "Модуль Postgres: настоящая база в тесте"
description: "Модуль testcontainers postgres: настоящий Postgres в тесте — postgres.Run с опциями, init-скрипты, готовый DSN со sslmode, Snapshot и Restore между тестами."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/04-postgres-module/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Testcontainers в Go: интеграционные тесты с Docker», урок 4 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testcontainers/lessons/04-postgres-module/*

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

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

> **Живое место: лог готовности приходит дважды.** Первый запуск 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](/courses/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?

- shop — имя приложения по умолчанию
- ✅ postgres — дефолт, который модуль ставит в переменную POSTGRES_DB
- Имя совпадёт с пользователем из WithUsername, если он задан
- Пустая строка: ConnectionString вернёт ошибку

Модуль ещё до применения ваших опций выставляет POSTGRES_USER, POSTGRES_PASSWORD и POSTGRES_DB в postgres, и WithDatabase переопределяет имя базы. Затем Run перечитывает переменные уже на запущенном контейнере и собирает PostgresContainer, а ConnectionString подставляет именно это имя в DSN.

### 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). Что произойдёт?

- Снимок уйдёт в базу-шаблон migrated_template — всё штатно
- ✅ Snapshot вернёт ошибку: системную базу postgres нельзя дропнуть для восстановления
- Snapshot запаникует: версии с MustSnapshot у модуля нет
- Снимок сохранится в файл внутри контейнера

Restore работает через удаление рабочей базы и пересоздание её из шаблона, поэтому база с именем postgres запрещена: модуль проверяет это заранее и возвращает ошибку, не доходя до SQL. Рабочее имя базы задаётся WithDatabase — механика снимков требует отдельной рабочей базы, а не системной.

### Типовые ошибки: долгий старт, 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

```go
pg, err := postgres.Run(ctx, "postgres:16-alpine",
    postgres.WithDatabase("shop"),
    postgres.BasicWaitStrategies(),
)
// err == nil

dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
```

**Что выведет программа?**

- ✅ postgres://postgres:postgres@localhost:<маппированный порт>/shop?sslmode=disable
- postgres://postgres:postgres@localhost:5432/shop?sslmode=disable
- postgres://postgres@localhost:<маппированный порт>/shop — без пароля: он не задан
- Ошибка: без WithUsername и WithPassword модуль не соберёт DSN

Пользователь и пароль выставляются дефолтами модуля — postgres и postgres, — поэтому DSN собирается полностью. Порт берётся из PortEndpoint: Docker маппирует 5432 контейнера на свободный порт хоста, и он меняется от запуска к запуску. sslmode=disable дописывается в query-строку как есть.

### Соберите запуск базы

Восстановите вызовы: старт контейнера, имя базы, пароль и получение DSN.

```go
pg, err := postgres.___(ctx, "postgres:16-alpine",
    postgres.___("shop"),
    postgres.WithUsername("app"),
    postgres.___("secret"),
)

dsn, err := pg.___(ctx, "sslmode=disable")
```

Подсказка: Контейнер запускает postgres.Run; имя базы задаёт WithDatabase, пароль — WithPassword, а готовый DSN отдаёт ConnectionString.

Правильные ответы в пропусках: `Run`, `WithDatabase`, `WithPassword`, `ConnectionString`

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

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

Правильный порядок строк:

1. `ctx := context.Background()`
2. `pg, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("shop"))`
3. `if err != nil {`
4. `  t.Fatal(err)`
5. `}`
6. `defer testcontainers.TerminateContainer(pg)`
7. `dsn, err := pg.ConnectionString(ctx, "sslmode=disable")`
8. `conn, err := pgx.Connect(ctx, dsn)`

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

- **postgres.Run** — Вход в модуль: образ первым аргументом, дальше кастомайзеры. Возвращает типизированный PostgresContainer с ошибкой; дефолты окружения — postgres и postgres.
- **ConnectionString** — DSN вида postgres://пользователь:пароль@хост:порт/база?аргументы; хост и порт — из PortEndpoint по 5432/tcp, аргументы дописываются в query-строку. Есть MustConnectionString с паникой.
- **WithInitScripts** — Монтирует скрипты в /docker-entrypoint-initdb.d/ с режимом 0755; исполнение при первой инициализации в порядке сортировки имён. WithOrderedInitScripts фиксирует порядок числовыми префиксами.
- **BasicWaitStrategies** — Двухступенчатая готовность: лог database system is ready to accept connections дважды — сервер перезапускается после initdb, — затем слушающий порт 5432/tcp.
- **Snapshot и Restore** — Снимок базы в шаблон (по умолчанию migrated_template) и восстановление из него несколькими SQL-командами. Для базы postgres запрещено; драйвер снимков — WithSQLDriver.
- **fsync=off** — Модуль стартует сервер с отключённой синхронизацией на диск: для тестов надёжность диска не нужна, а записи ускоряются заметно. В продакшене так делать нельзя.

> **Про песочницу: нужен локальный Docker.** Честно: в браузерной песочнице этот урок не запустить — Docker там нет, а модулю нужен живой демон. Рабочая схема — локально: установленный Go, запущенный Docker и обычный go test в терминале. Код разобран построчно, так что напечатайте тест руками и прогоните на своей машине: первая сборка скачает образ за пару минут, последующие пойдут быстро — образ кешируется. И держите Docker запущенным: ошибка про подключение к демону — это не про тест, а про окружение.

Что дальше: база — главный модуль testcontainers, дальше курс пройдёт по остальным хранилищам и брокерам, а пока закрепите связку базы с драйвером. Пятый урок — [тестируем pgx на живой базе](/courses/testcontainers/lessons/05-pgx-i-postgres/) — заберёт этот каркас и доведёт до полноценных тестов репозитория: пулы, транзакции, изоляция данных между тестами. Драйвер с наскоку не освоить — [курс pgx](/courses/pgx/) разбирает его по косточкам, от простых запросов до батчей и пула. Обзор всего пути — [курс testcontainers](/courses/testcontainers/): от жизненного цикла контейнера до CI и параллельности. Каркас TestMain из этого урока скопируйте в проект один раз — он переживёт десятки тестов без единой правки: контекст, Run, уборка в defer. И проверка на честность перед следующей темой: уберите BasicWaitStrategies из своего теста и погоните его на медленной машине — частота падений скажет о цене готовой стратегии ожидания лучше любых слов.

## Коротко: что запомнить

- 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 пропустит их молча. Тот же эффект даёт и чужой том от старого запуска. Накатывайте схему отдельным шагом теста или создавайте контейнер с чистым томом, тогда скрипты отработают штатно.
