---
title: "Тестируем pgx на живой базе"
description: "Интеграционные pgx тесты на живом Postgres: postgres.Run, ConnectionString, pgxpool.New, миграции, CRUD и ошибка 23505, четыре стратегии изоляции данных."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/05-pgx-i-postgres/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Тестируем pgx на живой базе

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

Модуль Postgres из четвёртого урока завёл живую базу в тесте — теперь подключим к ней настоящий клиент и получим pgx тесты в самом честном смысле. pgxpool.New вместо подделки интерфейса, реальные INSERT и SELECT, настоящая ошибка уникального индекса вместо выдуманной из мока — это и есть тесты базы данных go, которые ловят то, что юнит-прогон с заглушками пропускает. Соберём полный интеграционный тест репозитория: от postgres.Run и ConnectionString до Terminate, с миграциями, CRUD и проверкой 23505, а заодно сравним четыре стратегии изоляции данных. Если код ходит в базу, тесты обязаны ходить в базу — всё остальное компромисс.

У репозитория в Go обычно тонкий слой: методы принимают структуру, отдают структуру, а между ними — SQL. Пока этот слой прикрыт моком, тесты проверяют только вашу веру в базу: что INSERT вернёт nil, что SELECT отдаст те же поля, что ошибки не случится. Живой Postgres мыслит иначе — у него свои типы, ограничения, планировщик и каскады, и он ни за что не притворится моком. Тест репозитория на настоящей базе — это договор с движком: схема создаётся так же, как в проде, запросы проходят через настоящий парсер, ошибки возвращаются с настоящими кодами SQLSTATE. Платить придётся секундами: контейнер стартует дольше, чем создался мок, и прогон пакета требует Docker на машине. Но юнит-тест с заглушкой отвечает на вопрос «вызвал ли код метод», а интеграционный — «работает ли код вместе с Postgres». Для слоя хранилища второй вопрос и есть рабочий: именно он отделяет зелёный прогон от работающей функции. В этом уроке соберём такой тест целиком — от запуска контейнера до уборки после себя — и посмотрим, где он ловит то, что мок пропускает молча.

### pgx тесты: каркас из postgres.Run и pgxpool.New

Каркас любого pgx-теста собирается из четырёх шагов, и все они уже знакомы по уроку про [модуль Postgres](/courses/testcontainers/lessons/04-postgres-module/): **postgres.Run** поднимает контейнер и возвращает *postgres.PostgresContainer, **ConnectionString** собирает DSN с реальным хостом и портом — их знает только Docker, — **pgxpool.New** открывает пул по этому DSN, **pool.Ping** подтверждает, что база отвечает. Импорт выглядит как `github.com/testcontainers/testcontainers-go/modules/postgres`, а пул берётся из пакета `pgxpool` библиотеки pgx: одна строка DSN — и нативный клиент работает без database/sql. Уборка живёт в t.Cleanup: пул закрывается первым, Terminate останавливает контейнер вторым — если погасить базу раньше, закрытие пула встретит мёртвые соединения и добавит в лог шум. Каркас оформляется хелпером с t.Helper: setupTestDB принимает *testing.T и возвращает *pgxpool.Pool, и каждый тест пакета получает свежую базу одной строкой. Пул на тест — не расточительство: соединения живут в пределах Cleanup, и гонки за свободные коннекты между тестами не возникают; экономить пулы имеет смысл позже, когда появится reuse. Адрес контейнера меняется от запуска к запуску: порт выбирает Docker из свободных, поэтому DSN собирается вызовом ConnectionString, а не константой в конфиге.

### Хелпер: контейнер, DSN, пул и уборка

```go
func setupTestDB(t *testing.T) *pgxpool.Pool {
    t.Helper()
    ctx := context.Background()

    pgContainer, err := postgres.Run(ctx, "postgres:16-alpine")
    if err != nil {
        t.Fatalf("запуск postgres: %v", err)
    }

    dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatalf("строка подключения: %v", err)
    }

    pool, err := pgxpool.New(ctx, dsn)
    if err != nil {
        t.Fatalf("создание пула: %v", err)
    }
    if err := pool.Ping(ctx); err != nil {
        t.Fatalf("база не отвечает: %v", err)
    }

    t.Cleanup(func() {
        pool.Close()
        if err := pgContainer.Terminate(ctx); err != nil {
            t.Logf("останов postgres: %v", err)
        }
    })

    return pool
}
```

Хелпер объявляет t.Helper, чтобы трейсбеки падений указывали на строку теста, а не на setup. ConnectionString просит sslmode=disable: у тестового контейнера нет сертификатов, и параметр дописывается к DSN как аргумент. Ping — не украшение: он гарантирует, что о неживой базе мы узнаем до теста, а не внутри него. В Cleanup сначала закрывается пул, потом Terminate останавливает контейнер и убирает его вместе с томом.

### Миграции в тесте: Exec или WithInitScripts

Схему в тестовую базу можно доставить двумя способами. Первый — `pool.Exec` с простым CREATE TABLE прямо из хелпера: минимальная миграция на три строки, вся схема видна в самом тесте, ничего не прячется в файлах. Второй — **WithInitScripts** из модуля: он монтирует ваши SQL-файлы в `/docker-entrypoint-initdb.d/`, и официальный образ Postgres исполняет их при старте контейнера в порядке сортировки имён — если порядок критичен, у модуля есть WithOrderedInitScripts, который нумерует файлы сам. Практическое правило: если схему ведёт отдельный инструмент миграций и файлы уже лежат в репозитории — WithInitScripts прогонит именно их, и тест увидит ту же схему, что прод; файлы при этом исполняются образом до того, как пул подключится, — к первому Ping схема уже готова. Если тесту нужны две таблицы и индекс — Exec дешевле и прозрачнее: никакой синхронизации файлов, вся схема перед глазами, а вариация схемы под конкретный тест — правка одной строки, а не файла. Смешивать оба пути в одном пакете не нужно: потом никто не вспомнит, кто владеет какой таблицей.

### Минимальная миграция через Exec

```go
func migrateTestSchema(ctx context.Context, pool *pgxpool.Pool) error {
    _, err := pool.Exec(ctx, `
        CREATE TABLE users (
            id    BIGSERIAL PRIMARY KEY,
            email TEXT NOT NULL,
            name  TEXT NOT NULL
        )
    `)
    if err != nil {
        return err
    }

    _, err = pool.Exec(ctx,
        "CREATE UNIQUE INDEX users_email_key ON users (email)",
    )

    return err
}
```

Exec возвращает CommandTag и ошибку; для DDL интересна только вторая. Уникальный индекс создаётся отдельной командой — так его поведение проще показать в тесте на дубликат: без него INSERT с тем же email тихо запишется второй раз. BIGSERIAL даёт тесту настоящие сгенерированные ключи вместо выдуманных единиц из мока.

### CRUD на живой базе и ошибка 23505

Полный CRUD-тест делает три вещи, которые мок не проверит никогда. INSERT с RETURNING id возвращает сгенерированный ключ — заглушка подставила бы единицу, база честно считает BIGSERIAL и отдаёт следующее значение. SELECT со Scan раскладывает колонки по переменным: несовпадение типов вскроется здесь, потому что Scan конвертирует настоящий вывод движка, а не структуру, которую вы же и заготовили. UPDATE сверяется через RowsAffected: ноль затронутых строк значит, что промахнулись по id, и тест обязан это видеть. Отдельный козырь живой базы — ошибки ограничений: второй INSERT с тем же email падает, и pgx возвращает `*pgconn.PgError` с полем Code. Проверка через `errors.As` — каноничный способ из курса [pgx](/courses/pgx/): код `23505` означает unique_violation, 23503 — foreign_key_violation, 23502 — not_null_violation. Таблицы SQLSTATE живут в документации Postgres, и они стабильнее любых сообщений: текст ошибки меняется между версиями, код — нет. Тот же тест с моком вернул бы nil на обоих INSERT — потому что и индекс, и дубликат существовали только в вашей голове. NULL-колонки сюда же: в поле без указателя Scan вернёт ошибку конвертации, и увидите вы её только на настоящем выводе движка.

### Тест репозитория: вставить, прочитать, обновить

```go
func TestUserRepository_CRUD(t *testing.T) {
    ctx := context.Background()
    pool := setupTestDB(t)
    if err := migrateTestSchema(ctx, pool); err != nil {
        t.Fatalf("миграции: %v", err)
    }

    // INSERT: RETURNING отдаёт сгенерированный базой ключ
    var id int64
    err := pool.QueryRow(ctx,
        "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id",
        "anna@example.com", "Анна",
    ).Scan(&id)
    if err != nil {
        t.Fatalf("insert: %v", err)
    }

    // SELECT: Scan раскладывает колонки по переменным
    var name string
    if err := pool.QueryRow(ctx,
        "SELECT name FROM users WHERE id = $1", id,
    ).Scan(&name); err != nil || name != "Анна" {
        t.Fatalf("select: name=%q err=%v", name, err)
    }

    // UPDATE: RowsAffected подтверждает, что строка нашлась
    tag, err := pool.Exec(ctx,
        "UPDATE users SET name = $1 WHERE id = $2", "Анна П.", id,
    )
    if err != nil || tag.RowsAffected() != 1 {
        t.Fatalf("update: affected=%d err=%v", tag.RowsAffected(), err)
    }
}
```

QueryRow с RETURNING — идиома pgx для вставки: одна поездка в базу и ключ на руках. Scan у Row возвращает ошибку запроса или сканирования в одном err — разделять их не нужно. RowsAffected у CommandTag после Exec — единственная честная проверка UPDATE: запрос «прошёл» и без совпадений, и без этой проверки тест молчит о промахе.

> **Живое место: мок вернул nil, база вернула 23505.** Реальная авария: метод Create возвращал ошибку, мок в тесте возвращал nil — прогон зелёный, релиз ушёл. В проде повторная попытка после таймаута вставила письмо второй раз: уникальный индекс вернул 23505, ретрай-обёртка посчитала ошибку фатальной и уронила весь батч, и письма уехали только после ручного перезапуска. Юнит-тест с моком не мог этого поймать — заглушка не знала про индекс, потому что индекс жил в схеме, а не в интерфейсе. Мораль для слоя хранилища: мок фиксирует ваши предположения о базе, живой Postgres возвращает поведение базы. Такие аварии стоят дороже секунд на старте контейнера — и измеряются не в миллисекундах, а в разбуженных среди ночи людях.

### Изоляция данных: четыре стратегии

Как только pgx-тестов становится больше одного, вопрос изоляции данных решает, останутся ли тесты базы данных go воспроизводимыми: каждый прогон должен видеть базу в одном и том же состоянии, иначе тесты начнут падать по очереди — в зависимости от того, кто шёл первым. Это главный компромисс направления: скорость против независимости. Свежая база на тест — новый контейнер из postgres.Run: полная изоляция, но секунды на каждый тест. Отдельная схема на тест — `CREATE SCHEMA test_xxx` и `SET search_path` в строке подключения: один контейнер, у каждого теста своё пространство имён, дёшево и почти так же чисто. Транзакция с откатом — тест работает внутри Begin, а Cleanup вызывает Rollback: мгновенно, но только пока тест не делает DDL или собственный COMMIT. TRUNCATE между тестами — чистые таблицы одним вызовом, но легко забыть таблицу и получить данные соседа. У модуля есть и пятый путь: Snapshot после миграций и Restore перед тестом — база копируется из шаблона, механика спрятана в модуле. Стратегия выбирается на весь пакет, а не на отдельный тест: половина тестов на транзакциях, половина на TRUNCATE — гарантий нет ни у кого. Таблица ниже сводит варианты по скорости и надёжности.

### Стратегии изоляции: цена и гарантии

| Стратегия | Как работает | Плюс / минус |
| --- | --- | --- |
| Свежая база на тест | новый postgres.Run на каждый тест, уборка в Terminate | максимальная изоляция; дороже всех по времени |
| Схема на тест | CREATE SCHEMA плюс SET search_path в DSN | один контейнер, быстро; объекты уровня базы общие |
| Транзакция с откатом | Begin до теста, Rollback в Cleanup | самая быстрая; ломается на DDL и внутреннем COMMIT |
| TRUNCATE | очистка таблиц между тестами | наглядно; хрупко — забытая таблица протянет мусор |
| Snapshot и Restore | снапшот после миграций, Restore перед тестом | схема копируется мгновенно; живёт на механике модуля |

### Проверка: COMMIT внутри тестовой транзакции

**Вопрос:** Тест внутри стратегии «транзакция с откатом» выполняет собственный COMMIT, чтобы проверить каскады. Что произойдёт?

- Rollback в Cleanup вернёт всё, включая уже закоммиченное
- ✅ COMMIT зафиксирует изменения в общей базе: Rollback уже бессилен
- Postgres запретит COMMIT внутри тестовой транзакции
- Тест зависнет, пока Cleanup не отменит соединение

COMMIT завершает транзакцию, и Rollback после него откатывать нечего: данные останутся в общей базе и протекут в соседние тесты. Транзакционная стратегия подходит только тестам, которые не управляют фиксацией сами; если нужны COMMIT и каскады — изолируйте тест отдельной схемой или свежей базой.

### Проверка: распознать дубликат

**Вопрос:** Как надёжно отличить нарушение уникального индекса от любой другой ошибки pgx?

- Сравнить текст ошибки со строкой duplicate key value
- Проверить err != nil и считать каждую ошибку дубликатом
- ✅ errors.As с *pgconn.PgError и сверить поле Code с 23505
- Вызвать pool.Ping: если он вернул nil, это был дубликат

pgx упаковывает серверные ошибки в *pgconn.PgError, где Code — пятизначный SQLSTATE из каталога Postgres. errors.As достаёт структуру сквозь обёртки fmt.Errorf с %w. Сравнение по тексту хрупко: формулировки меняются между версиями, а коды стабильны.

### Почему моки ловят не всё: типы, планировщик, каскады

Мок — это зеркало ваших ожиданий, и четыре класса багов он не отражает в принципе. **Типы**: pgx сканирует timestamptz, NUMERIC, UUID, массивы — несовпадение обнаружится только при живом Scan, потому что конвертацию выполняет драйвер против настоящего вывода движка. **Планировщик**: запрос, который на заглушке «возвращает список», на живой таблице может читать её целиком — seq scan увидит только Postgres, а тест с EXPLAIN увидит его вместе с базой. **Ограничения**: NOT NULL, CHECK, внешние ключи и уникальные индексы существуют только в схеме — мок о них не знает, а база расскажет кодом ошибки. **Каскады**: ON DELETE CASCADE и триггеры сработают лишь в реальном движке, и тест, удаляющий родителя, проверит, что дети уехали сами. Добавим пятый класс — парсинг SQL: мок принимает любую строку, а база разбирает её синтаксисом; опечатка в имени колонки на заглушке зелёная, на живой базе — честная ошибка undefined column. Юнит-слой с заглушками оставьте для бизнес-правил: там он быстр и уместен. Но граница — ровно интерфейс хранилища: за ним начинается база, и тестировать её нужно настоящей.

### Чек-лист живого теста репозитория

- postgres.Run и пул — в хелпере с t.Helper: каркас не дублируется по тестам
- ConnectionString собирает DSN: хост и порт знает только контейнер, руками их не собирать
- pool.Ping сразу после pgxpool.New: неживая база должна упасть до теста, а не в нём
- t.Cleanup закрывает пул первым, Terminate останавливает контейнер вторым
- 23505 ловится через errors.As и pgconn.PgError — не сравнением текстов
- RowsAffected после UPDATE обязателен: без него тест молчит о промахе
- одна стратегия изоляции на весь пакет: смешение схем, TRUNCATE и транзакций хуже неидеального выбора

### Что напечатает обработчик ошибки

```go
_, err := pool.Exec(ctx,
    "INSERT INTO users (email, name) VALUES ($1, $2)",
    "anna@example.com", "Пётр",
)
// anna@example.com уже в таблице, уникальный индекс на месте
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
    log.Print(pgErr.Code)
}
```

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

- ✅ 23505 — unique_violation
- 23503 — foreign_key_violation
- ничего: Exec вернёт nil
- 0 — ошибки имеют нулевой код

Второй INSERT с тем же email нарушает уникальный индекс: Postgres возвращает SQLSTATE 23505, pgx оборачивает его в pgconn.PgError — Code печатает 23505. 23503 был бы нарушением внешнего ключа, а без errors.As лог остался бы пустым: err не nil, но тип не совпадает.

### Соберите каркас теста

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

```go
dsn, err := pgContainer.___(ctx, "sslmode=disable")
pool, err := pgxpool.___(ctx, dsn)
if err := pool.___(ctx); err != nil {
        t.Fatal(err)
}
var pgErr *pgconn.PgError
if errors.___(err, &pgErr) && pgErr.Code == "23505" {
        t.Log("дубликат email")
}
```

Подсказка: Строку подключения отдаёт ConnectionString, пул строит New, живость проверяет Ping, а PgError достаёт errors.As.

Правильные ответы в пропусках: `ConnectionString`, `New`, `Ping`, `As`

### Расставьте шаги хелпера setupTestDB

Соберите каркас по порядку: контейнер, DSN, пул, проверка связи, уборка и возврат.

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

1. `func setupTestDB(t *testing.T) *pgxpool.Pool {`
2. `    pgContainer, err := postgres.Run(ctx, "postgres:16-alpine")`
3. `    dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable")`
4. `    pool, err := pgxpool.New(ctx, dsn)`
5. `    if err := pool.Ping(ctx); err != nil {`
6. `        t.Fatalf("база не отвечает: %v", err)`
7. `    }`
8. `    t.Cleanup(func() { pool.Close(); _ = pgContainer.Terminate(ctx) })`
9. `    return pool`
10. `}`

### Карточки: pgx на живой базе

- **postgres.Run** — Запускает контейнер Postgres и возвращает PostgresContainer: по умолчанию пользователь и база postgres, команда с fsync=off ради скорости тестов.
- **ConnectionString** — Метод контейнера: собирает DSN postgres://… с реальным хостом и портом от Docker; допускает допареметры вроде sslmode=disable. MustConnectionString паникует вместо ошибки.
- **WithInitScripts** — Монтирует SQL-файлы в /docker-entrypoint-initdb.d/: образ Postgres исполняет их при старте в порядке сортировки имён; WithOrderedInitScripts задаёт порядок явно.
- **23505** — SQLSTATE unique_violation: нарушение уникального индекса. Приходит как pgconn.PgError с полем Code; достаётся errors.As. Соседи: 23503 — внешний ключ, 23502 — NOT NULL.
- **t.Cleanup** — Уборка теста в порядке LIFO: зарегистрированная в хелпере функция закроет пул и погасит контейнер даже после падения теста — Terminate не потеряется.
- **SET search_path** — Задаёт схему по умолчанию для соединения: вместе с CREATE SCHEMA на тест даёт каждому тесту своё пространство имён в одном контейнере.

> **Про песочницу.** Честная оговорка: в браузерной песочнице этот урок не запустится — тестам нужен локальный Docker, установленный Go и go test в терминале вашего проекта. Песочница сайта исполняет только тематическую программу на чистом Go, без контейнеров и сети. Поэтому читайте код построчно: каркас setupTestDB, миграции, CRUD и 23505 — это сценарий, который вы воспроизводите у себя; всё необходимое — образ postgres, пул pgx и один файл с тестом. Первый прогон скачает образ, второй и дальше пойдут из кэша — минуты превратятся в секунды.

Что дальше: база-контейнер на каждый пакет — цена входа, и её снижают reuse и параллельность: переиспользование контейнера между тестами разберёт шестнадцатый урок, а управление конкурентным прогоном — семнадцатый, там же — изоляция под t.Parallel. Фикстуры уровня пакета и TestMain красиво складываются в [Testify-наборы](/courses/testify/) из девятнадцатого урока. Если связка код — база — тесты переросла этот уровень, системная перспектива есть в продвинутом курсе [go-advanced](/courses/go-advanced/). Домашняя проверка на честность: уберите из миграции уникальный индекс и посмотрите, как поведёт себя тест на дубликат — второй INSERT запишется молча, и это лучший аргумент за живую базу, который вы увидите собственными глазами. А когда тестов станет много, вернитесь к таблице изоляции и выберите одну стратегию для пакета — она окупится уже на следующем прогоне. Порядок один и тот же: живая база, честные ошибки, уборка после себя — остальное детали.

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

- Каркас pgx-теста: postgres.Run, ConnectionString, pgxpool.New, pool.Ping; уборка в t.Cleanup — пул закрывается первым, Terminate вторым.
- Миграции: pool.Exec с CREATE TABLE для минимальной схемы или WithInitScripts — SQL-файлы уезжают в /docker-entrypoint-initdb.d/ и исполняются при старте.
- Реальный CRUD проверяет RETURNING, Scan и RowsAffected; дубликат ловится как pgconn.PgError с кодом 23505 через errors.As.
- Изоляция: свежая база, схема на тест, транзакция с откатом, TRUNCATE — скорость против надёжности; reuse и параллельность — уроки 16-17.

## Домашнее задание

Соберите setupTestDB с миграцией из двух таблиц — users и orders с внешним ключом на users — и напишите CRUD-тест: INSERT с RETURNING, SELECT со Scan, UPDATE с проверкой RowsAffected. Добавьте тест на дубликат email с errors.As и pgconn.PgError, затем прогоните второй тест под транзакцией с откатом и под TRUNCATE — сравните время прогона и зафиксируйте выбор стратегии в комментарии.

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

### Мой проект работает через database/sql, а не через нативный pgx. Подойдёт ли этот каркас?

Да: DSN от ConnectionString одинаков для обоих режимов, и sql.Open принимает тот же postgres-DSN. Отличие в слое запросов: database/sql сканирует типы строже, а pgxpool даёт нативные типы pgx. У модуля есть и WithSQLDriver — он пригодится, если механике снапшотов нужно ходить именно вашим драйвером. Каркас Run → ConnectionString → пул → Cleanup при этом не меняется.

### Почему контейнер стартует быстро, а первый прогон занимает минуты?

Первая загрузка образа — это docker pull, дальше образ живёт в локальном кэше. Сам Postgres стартует за секунды, и модуль ускоряет его честным приёмом: контейнер запускается с fsync=off, потому что терять данные тестовой базе нечего. Ускорение повторных прогонов — reuse из шестнадцатого урока и параллельность из семнадцатого.

### Где посмотреть полный список кодов вроде 23505?

Это SQLSTATE — каталог кодов ошибок в документации Postgres. Для тестов хранилища хватает горячей пятёрки: 23505 — unique_violation, 23503 — foreign_key_violation, 23502 — not_null_violation. Работать с ними в pgx удобнее всего через pgconn.PgError и errors.As — как в курсе pgx, где связке посвящён отдельный разбор ошибок.
