---
title: "Тесты: testcontainers и pgxmock"
description: "Тесты БД-кода на Go: testcontainers поднимает реальный Postgres в Docker, pgxmock гоняет юнит-тесты без базы. Динамический порт, TestMain, ExpectQuery и грабли."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/23-testy-s-testcontainers/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Тесты: testcontainers и pgxmock

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 23 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/23-testy-s-testcontainers/*

Код с базой тестируют на двух уровнях, и путать их дорого. Юнит-тесты с pgxmock проверяют логику за миллисекунды: какой SQL ушёл, с какими аргументами, что вернулось. Интеграционные тесты с testcontainers для Go поднимают настоящий Postgres в Docker и ловят всё, что мок физически не видит: опечатки в SQL, типы колонок, конфликты уникальности, поведение транзакций. Разберём, какой уровень когда выбирать, как держать один контейнер на пакет через TestMain, откуда брать строку подключения с динамическим портом и какие ожидания строятся в pgxmock.

У репозитория из седьмого урока появился баг: метод ByID делает два запроса вместо одного, а фейк, который стоял вместо базы, этого не заметил — он просто возвращает заготовленную структуру на любой вызов. Фейки и стабы на картах хороши для бизнес-логики, но код, который сам составляет SQL, нуждается в проверке того, что именно ушло в базу. Тест без базы — это тест, который никогда не запускал настоящий запрос, и рано или поздно такой код добирается до продакшена с первой же опечаткой. Отсюда два уровня тестов. Нижний — юнит-тесты на моках: библиотека pgxmock имитирует pgx, записывает каждый вызов и сверяет его с ожиданиями. Верхний — интеграционные тесты: testcontainers поднимает в Docker настоящий Postgres, применяет миграции, и репозиторий работает с живым сервером. Мок быстр и детерминирован, но слеп к реальному SQL; живая база видит всё, но стоит секунд на старт контейнера. Сильные команды держат обе прослойки и знают, какая ошибка на каком уровне ловится.

### Два уровня: что ловит каждый

pgxmock жёстко проверяет оркестрацию. ExpectQuery фиксирует ожидаемый SQL, WithArgs — аргументы, WillReturnRows — результат; в конце ExpectationsWereMet докладывает, не остался ли невыполненный запрос. Ловятся мгновенно: вызов с не теми аргументами, лишний запрос, пропущенный запрос, неправильный порядок, ошибка, которую код не обработал. Чего мок не ловит: синтаксис SQL — парсера в нём нет, строка просто сравнивается со строкой; типы Postgres и их сканирование; ограничения NOT NULL, UNIQUE, внешние ключи; настоящие pgconn.PgError из шестнадцатого урока; миграции и поведение блокировок. testcontainers переворачивает список: живой сервер ловит опечатку в имени колонки, невалидный тип, конфликт 23505 из семнадцатого урока, забытый COMMIT. Плата — секунды на подъём контейнера и зависимость от Docker. Есть и оборотная сторона строгости: ожидание фиксирует контракт, поэтому любое изменение SQL ломает тест — сначала это раздражает, а через месяц оказывается главным барьером против незаметных правок запросов. Пирамида простая: на моках — каждый метод репозитория, на живой базе — сценарии с транзакциями, миграциями и всеми местами, где SQL генерируется динамически.

Механика testcontainers-go для Postgres умещается в один вызов: postgres.Run(ctx, "postgres:16-alpine", opts...) из модуля github.com/testcontainers/testcontainers-go/modules/postgres возвращает контейнер-обёртку. Функция Run появилась в v0.32 и вытеснила устаревший RunContainer; образ передаётся строкой прямо сюда, отдельный тег не нужен. Опции собирают конфигурацию: WithDatabase и WithUsername задают базу и пользователя, WithInitScripts подкладывает SQL-скрипты инициализации — удобное место для минимальной схемы, хотя честные миграции из двадцать первого урока лучше применять кодом. Из подготовленных стратегий ожидания чаще всего берут BasicWaitStrategies: модуль ждёт дважды встреченную в логах строку database system is ready to accept connections — Postgres при первом старте перезапускается, и одно вхождение обманывает нетерпеливый тест, — а затем дожидается, когда Docker начнёт обслуживать порт. Образ тянется из реестра один раз: первый прогон съедает время на загрузку, дальше Docker кэширует слои и контейнер стартует за секунды, поэтому в CI кэш образов — самая дешёвая инвестиция в скорость пайплайна. Результат Run — контейнер, из которого вытаскивается строка подключения, и это отдельный урок о портах.

### Один контейнер на пакет: TestMain

```go
var testPool *pgxpool.Pool

func TestMain(m *testing.M) {
        ctx := context.Background()

        ctr, err := postgres.Run(ctx,
                "postgres:16-alpine",
                postgres.WithDatabase("app_test"),
                postgres.BasicWaitStrategies(),
        )
        if err != nil {
                log.Fatalf("postgres контейнер: %v", err)
        }

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

        testPool, err = pgxpool.New(ctx, dsn)
        if err != nil {
                log.Fatalf("пул: %v", err)
        }

        code := m.Run()

        testPool.Close()
        if err := ctr.Terminate(ctx); err != nil {
                log.Printf("terminate: %v", err)
        }
        os.Exit(code)
}

func TestCreateUser(t *testing.T) {
        ctx := t.Context() // отмена при завершении теста
        _, err := testPool.Exec(ctx,
                "INSERT INTO users (email) VALUES ($1)", "anna@example.com")
        if err != nil {
                t.Fatal(err)
        }
}
```

TestMain выполняется один раз на пакет: контейнер и пул создаются до всех тестов и закрываются после m.Run(). Деферы здесь не спасают — os.Exit пропускает их, поэтому закрытие пула и Terminate прописаны явно до выхода. Каждый тест получает общий testPool и свой контекст через t.Context(), который отменяется автоматически по завершении теста.

### Строка подключения: динамический порт

Главная грабля новичка: тест ломится на localhost:5432 и падает с connection refused, потому что testcontainers публикует порт контейнера на случайном свободном порту хоста — два пакета тестов могут работать параллельно и не драться за 5432. Единственный корректный источник адреса — метод ConnectionString(ctx, "sslmode=disable"): он возвращает строку с фактическим хостом и портом, а переменные аргументы дописывает в конец как параметры DSN. Есть и MustConnectionString, который паникует вместо возврата ошибки, — в тестах это даже честнее. Строку подключения получает TestMain и раздаёт пулом; сами тесты не знают ни хоста, ни порта. Для изоляции данных между тестами модуль предлагает Snapshot и Restore: снимок состояния базы делается после миграций, восстановление откатывает данные к нему перед каждым тестом — дешевле, чем пересоздавать схему. Уровень параллельности тоже выбирается осознанно: t.Parallel() внутри одного пакета безопасен с общим пулом, но два разных пакета с TestMain-контейнерами поедут одновременно — именно поэтому порт и обязан быть случайным. Если хелперы с контейнером вызываются не только из TestMain, подъём прячут за sync.Once, чтобы второй вызов переиспользовал первый контейнер.

> **Живое место: контейнер на каждый тест.** Тест-хелпер поднимал postgres.Run на каждый сабтест — тридцать тестов, тридцать контейнеров, и CI сначала растянулся до получаса, а потом докер упал по памяти. Если контейнер нужен один на пакет — TestMain или sync.Once обязательны. Если нужна изоляция данных, контейнер не пересоздают: Snapshot и Restore из модуля postgres или TRUNCATE между тестами решают задачу за миллисекунды вместо десятков секунд на новый подъём.

### Проверка: connection refused

**Вопрос:** Интеграционный тест падает с connection refused на localhost:5432, хотя контейнер в логах поднялся. Что произошло?

- Postgres в контейнере ещё не готов принимать подключения — нужен более длинный таймаут
- ✅ Тест использует фиксированный порт вместо строки из ConnectionString контейнера
- Пул создан до контейнера — нужно поменять порядок
- sslmode=disable запрещён современными версиями Postgres

Testcontainers публикует порт контейнера на случайном порту хоста: 5432 там не занят, и соединение с фиксированным портом отбрасывается. Адрес берут из ConnectionString — он возвращает фактический хост и порт, а готовность уже гарантирует стратегия ожидания.

### Юнит-тесты на pgxmock

pgxmock — мок библиотеки pgx: github.com/pashagolub/pgxmock/v4. Конструкторы pgxmock.NewPool и pgxmock.NewConn возвращают значения интерфейсов PgxPoolIface и PgxConnIface с той же поверхностью, что *pgxpool.Pool и *pgx.Conn. Чтобы репозиторий принял мок, его поле типизируют интерфейсом — либо pgxmock-овским целиком, либо собственным узким из двух-трёх методов. Сценарий читается как сценарий: mock.ExpectQuery("SELECT id, email FROM users WHERE id = $1") объявляет ожидаемый запрос, WithArgs(1) фиксирует аргументы, WillReturnRows(pgxmock.NewRows([]string{"id", "email"}).AddRow(1, "anna@example.com")) задаёт табличный ответ, который сканирование из шестого урока разберёт как настоящий. В конце теста ExpectationsWereMet() вернёт ошибку, если хоть одно ожидание не сработало или сработало лишнее. По умолчанию ожидания сопоставляются строго по порядку; MatchExpectationsInOrder(false) это ослабляет, а Times(n) объявляет, сколько раз один и тот же запрос должен сработать.

### Юнит-тест репозитория с pgxmock

```go
func TestUserRepo_ByID(t *testing.T) {
        mock, err := pgxmock.NewPool(
                pgxmock.QueryMatcherOption(pgxmock.QueryMatcherEqual),
        )
        if err != nil {
                t.Fatal(err)
        }

        rows := pgxmock.NewRows([]string{"id", "email"}).
                AddRow(1, "anna@example.com")

        mock.ExpectQuery(
                "SELECT id, email FROM users WHERE id = $1",
        ).WithArgs(1).WillReturnRows(rows)

        repo := NewUserRepo(mock)
        u, err := repo.ByID(context.Background(), 1)
        if err != nil {
                t.Fatal(err)
        }
        if u.Email != "anna@example.com" {
                t.Fatalf("email = %q", u.Email)
        }

        if err := mock.ExpectationsWereMet(); err != nil {
                t.Fatal(err)
        }
}
```

QueryMatcherOption с QueryMatcherEqual включает точное сравнение SQL: строка ожидания должна совпасть с реальной посимвольно. Без него действует регулярное выражение, и спецсимволы в запросе живут своей жизнью. Мок подставлен в репозиторий через интерфейс, сценарий объявлен до вызова, а ExpectationsWereMet в конце гарантирует, что запрос был выполнен ровно один раз.

### ExpectExec против ExpectQuery

Второй по частоте источник ошибок — перепутанный тип ожидания. Правило жёсткое: Exec требует ExpectExec и WillReturnResult(pgxmock.NewResult("INSERT", 1)) — тег команды и число затронутых строк; Query и QueryRow требуют ExpectQuery и WillReturnRows. QueryRow под капотом выполняет обычный Query, поэтому мок не различает их: ожидание ставится одно. Если код открыл транзакцию, добавляются ExpectBegin и ExpectCommit (или ExpectRollback) — без них ExpectationsWereMet закапризничает. Аргументы сравниваются по значениям: передали int вместо int64 — тест покажет расхождение с ожиданием, и это фича, а не придирка. Важно понимать, что мок не запускает SQL: опечатка в имени таблицы, лишняя запятая, неверный тип колонки пройдут тихо, потому что строка запроса лишь сопоставляется со строкой ожидания. Всё, что связано с реальным исполнением, уезжает на уровень выше.

> **Живое место: SQL, который оказался регуляркой.** Тест падает с call to QueryContext was not expected, хотя запрос выглядит идентичным ожиданию. Причина: по умолчанию pgxmock сопоставляет SQL как регулярное выражение, и доллар в $1 — это якорь конца строки. Если пишете живой SQL с параметрами, задайте pgxmock.QueryMatcherOption(pgxmock.QueryMatcherEqual) при создании мока — либо экранируйте спецсимволы. Второй вариант работает, но читать тесты после него тяжело.

### Проверка: слепые зоны мока

**Вопрос:** Какой дефект pgxmock не обнаружит в принципе?

- Репозиторий передал в запрос аргумент, которого нет в ожидании
- ✅ Репозиторий выполнил SQL с опечаткой в имени колонки
- Репозиторий сделал запрос, которого не ждали
- Ожиданный запрос так и не был выполнен

pgxmock не исполняет SQL и не знает его синтаксиса: строка запроса только сопоставляется со строкой ожидания. Опечатку в имени колонки поймает лишь живой Postgres из testcontainers — на интеграционном уровне.

### Что вернёт репозиторий?

```go
mock.ExpectQuery("SELECT id FROM users WHERE id = $1").
    WithArgs(1).
    WillReturnRows(pgxmock.NewRows([]string{"id"}).AddRow(1))

// тестируемый код:
row := pool.QueryRow(ctx,
    "SELECT id FROM users WHERE id = $1", 2)
var id int
err := row.Scan(&id)
```

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

- err == nil, id == 1: мок вернёт заготовленную строку
- err == nil, id == 0: строк нет, Scan вернул ноль
- ✅ err != nil: вызов с аргументом 2 не совпал с WithArgs(1)
- panic: мок настроен строго и паникует на лишних вызовах

Аргумент 2 не совпал с ожидаемым WithArgs(1), поэтому мок вернул ошибку вместо строк — она и приедет из Scan. Никакого panic нет: строгость мока проявляется в ошибках, а ExpectationsWereMet позже доложит о несыгравшем ожидании.

### Какой уровень когда выбирать

Разводка по уровням опирается на цену и риск. Юнит-тест на моке выполняется за миллисекунды — его гоняют при каждом сохранении файлов, поэтому на моках живут все методы репозитория: контракты запросов, обработка ошибок, сканирование. Интеграционный тест стоит секунды и требует Docker — его место на pre-merge и в CI, и там он проверяет то, что мок не видит: миграции из двадцать первого урока применяются и не конфликтуют, SQL синтаксически валиден, транзакции из тринадцатого урока коммитятся и откатываются, уникальные индексы дают честную 23505. Практичный гибрид: один пакет интеграционных тестов с общим контейнером из TestMain гоняет критический путь — создать, прочитать, обновить, удалить, поймать конфликт; остальное — моки. Полезно и обратное направление: мок-тесты пишутся первыми в TDD-цикле и задают контракт, который интеграционный тест потом подтверждает на настоящем сервере. Первый подъём образа подтягивает его из реестра, дальше Docker кэширует и старт занимает секунды; забытый при падении CI контейнер подчищает встроенный reaper, так что авария не копит мусор на машине.

### Три способа подменить базу в тесте

| Подмена | Скорость | Что ловит | Чего не видит | Типичное место |
| --- | --- | --- | --- | --- |
| Фейк на карте | Миллисекунды | Логику сервиса поверх репозитория | Весь SQL и драйвер | Юнит-тесты бизнес-правил |
| pgxmock | Миллисекунды | SQL-строку, аргументы, порядок вызовов, ответы | Синтаксис, типы, ограничения, реальные ошибки | Юнит-тесты репозитория |
| testcontainers + Postgres | Секунды на контейнер | Реальное исполнение: SQL, типы, транзакции, миграции | Ничего — это настоящая база | Интеграционные тесты в CI |

### Правила тестов БД-кода одним списком

- Юнит-тесты на pgxmock — для каждого метода репозитория: они дешёвые и гоняются постоянно
- Интеграционные тесты с testcontainers — для SQL, транзакций, миграций и конфликтов уникальности
- Один контейнер на пакет: TestMain или sync.Once, а не postgres.Run в каждом тесте
- Строку подключения брать только из ConnectionString: порт хоста назначается динамически
- Контексты тестов — t.Context(); в TestMain — context.Background()
- Изоляция данных — Snapshot/Restore или TRUNCATE, не пересоздание контейнера
- Exec — ExpectExec, Query и QueryRow — ExpectQuery; Begin требует ExpectCommit или ExpectRollback
- Для живого SQL включать QueryMatcherEqual; ExpectationsWereMet — в конце каждого мок-теста

### Соберите ожидание pgxmock

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

```go
mock.___("SELECT email FROM users WHERE id = $1").
    ___(1).
    ___(pgxmock.NewRows([]string{"email"}).AddRow("anna@example.com"))

// ... вызов тестируемого кода ...

if err := mock.___(); err != nil {
    t.Fatal(err)
}
```

Подсказка: Ожидание начинается с ExpectQuery, уточняется аргументами и ответом, а сверяется ExpectationsWereMet.

Правильные ответы в пропусках: `ExpectQuery`, `WithArgs`, `WillReturnRows`, `ExpectationsWereMet`

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

Расставьте шаги интеграционного теста: контейнер, строка, пул, прогоны, уборка.

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

1. `ctr, err := postgres.Run(ctx, "postgres:16-alpine",`
2. `    postgres.BasicWaitStrategies())`
3. `if err != nil { log.Fatal(err) }`
4. `dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")`
5. `testPool, err = pgxpool.New(ctx, dsn)`
6. `code := m.Run()`
7. `testPool.Close()`
8. `ctr.Terminate(ctx)`
9. `os.Exit(code)`

### Карточки: тесты БД-кода

- **postgres.Run** — Точка входа модуля postgres testcontainers-go: поднимает контейнер образа вроде postgres:16-alpine и возвращает обёртку с ConnectionString и Terminate.
- **ConnectionString** — Метод контейнера: возвращает DSN с фактическим хостом и случайным портом хоста; аргументы дописываются как параметры, например sslmode=disable.
- **ExpectationsWereMet** — Финальная сверка pgxmock: ошибка, если какое-то ожидание не сыграло или произошёл незапланированный вызов.
- **QueryMatcherEqual** — Точное посимвольное сравнение SQL вместо регулярного выражения по умолчанию — спасает от спецсимволов вроде $1 в живых запросах.
- **ExpectExec против ExpectQuery** — Exec требует ExpectExec и NewResult; Query и QueryRow — ExpectQuery и WillReturnRows. QueryRow мок не отличает от Query.
- **Один контейнер на пакет** — TestMain поднимает Postgres до всех тестов и убирает после m.Run(); для хелперов — sync.Once. Изоляция данных — Snapshot/Restore.

Песочница урока не исполняет Go-тесты и pgx: этот материал гоняется локально — для мок-тестов хватит одного go test, для интеграционных нужен Docker с работающим демоном. Дальше по курсу: [двадцать первый урок](/courses/pgx/lessons/21-migracii/) — миграции, которые в интеграционных тестах применяются к контейнерной базе и через WithInitScripts, и кодом; [шестнадцатый](/courses/pgx/lessons/16-oshibki-pgx/) — настоящие pgconn.PgError, которые мок способен имитировать, но не породить; [двадцать второй](/courses/pgx/lessons/22-indexy-plany/) — планы запросов, проверить которые можно только на живом сервере, то есть ровно в тестах из этого урока. Домашнее задание сводит три уровня в один рабочий пакет. Отдельно стоит прогнать пакет дважды: go test без флагов и с -count=1 после очистки образов — если тесты завалились на чистой машине, они зависят от состояния, оставленного прошлым прогоном. И последняя мысль: testcontainers и pgxmock не конкурируют — мок описывает намерение кода, контейнер проверяет реальность, и наличие обоих уровней отличает проверенный SQL-слой от надеющегося.

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

- Два уровня тестов БД-кода: pgxmock для юнит-тестов оркестрации, testcontainers для интеграционных с реальным Postgres.
- postgres.Run(ctx, "postgres:16-alpine", ...) поднимает контейнер; BasicWaitStrategies ждёт готовность по логам и порту.
- Порт хоста назначается динамически: DSN берут из ConnectionString(ctx, "sslmode=disable"), а не хардкодят.
- Один контейнер на пакет через TestMain или sync.Once; изоляция данных — Snapshot/Restore или TRUNCATE.
- pgxmock: ExpectQuery/ExpectExec, WithArgs, WillReturnRows, Times и финальный ExpectationsWereMet.
- Дефолтный матчер SQL — регулярное выражение: для живых запросов включают QueryMatcherEqual.
- Мок не исполняет SQL: синтаксис, типы, ограничения и настоящие PgError ловит только живая база.
- Пирамида: моки на каждый метод репозитория, интеграционные — на критический путь и миграции в CI.

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

Соберите пакет тестов из двух файлов. В первом — юнит-тесты репозитория на pgxmock с QueryMatcherEqual: два метода, по два-три ожидания с WithArgs и WillReturnRows, ExpectationsWereMet в конце каждого; затем специально перепутайте ExpectExec и ExpectQuery и посмотрите на сообщение об ошибке. Во втором — TestMain с postgres.Run образа postgres:16-alpine, пулом на пакет и одним интеграционным тестом: примените схему, вставьте строку, прочитайте её и поймайте конфликт уникальности на повторной вставке. Убедитесь через docker ps, что контейнер завершился после прогона, а в выводе go test -v нет connection refused: если есть, значит вы забыли ConnectionString и лезете на фиксированный порт.

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

### Нужен ли Docker для pgxmock-тестов?

Нет: pgxmock работает целиком в памяти процесса, Docker не нужен — поэтому мок-тесты гоняются везде, включая машины без демона, и выполняются за миллисекунды. Docker требуется только интеграционной части с testcontainers: без него контейнер Postgres подняться не сможет, и такие тесты честно пропускают через t.Skip при недоступном Docker.

### Можно ли pgxmock использовать без изменения кода репозитория?

Да, если репозиторий уже зависит от интерфейса, а не от конкретного типа *pgxpool.Pool — достаточно, чтобы интерфейс совпадал с PgxPoolIface или его подмножеством. Если же поле типизировано конкретным пулом, придётся сначала ввести интерфейс: это час работы, но зато открывает дорогу всем мок-тестам сразу.

### Как тестировать миграции и схему?

Интеграционным тестом на контейнере: postgres.Run с WithInitScripts для простой инициализации либо явное применение инструментов миграции из двадцать первого урока к контейнерной базе, после чего Snapshot фиксирует состояние, и каждый тест восстанавливается из него. Так схема проверяется на настоящем Postgres каждой версии, которую вы поддерживаете.
