У репозитория из седьмого урока появился баг: метод 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
GOvar 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, хотя контейнер в логах поднялся. Что произошло?
Юнит-тесты на 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
GOfunc 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: опечатка в имени таблицы, лишняя запятая, неверный тип колонки пройдут тихо, потому что строка запроса лишь сопоставляется со строкой ожидания. Всё, что связано с реальным исполнением, уезжает на уровень выше.
Тест падает с call to QueryContext was not expected, хотя запрос выглядит идентичным ожиданию. Причина: по умолчанию pgxmock сопоставляет SQL как регулярное выражение, и доллар в $1 — это якорь конца строки. Если пишете живой SQL с параметрами, задайте pgxmock.QueryMatcherOption(pgxmock.QueryMatcherEqual) при создании мока — либо экранируйте спецсимволы. Второй вариант работает, но читать тесты после него тяжело.
Проверка: слепые зоны мока
Какой дефект pgxmock не обнаружит в принципе?
Что вернёт репозиторий?
Прочитайте код и выберите, что он выведет:
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)Какой уровень когда выбирать#
Разводка по уровням опирается на цену и риск. Юнит-тест на моке выполняется за миллисекунды — его гоняют при каждом сохранении файлов, поэтому на моках живут все методы репозитория: контракты запросов, обработка ошибок, сканирование. Интеграционный тест стоит секунды и требует 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
Восстановите четыре пропуска: объявление запроса, аргументы, ответ и финальная сверка.
Соберите каркас TestMain
Расставьте шаги интеграционного теста: контейнер, строка, пул, прогоны, уборка.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты БД-кода
Песочница урока не исполняет Go-тесты и pgx: этот материал гоняется локально — для мок-тестов хватит одного go test, для интеграционных нужен Docker с работающим демоном. Дальше по курсу: двадцать первый урок — миграции, которые в интеграционных тестах применяются к контейнерной базе и через WithInitScripts, и кодом; шестнадцатый — настоящие pgconn.PgError, которые мок способен имитировать, но не породить; двадцать второй — планы запросов, проверить которые можно только на живом сервере, то есть ровно в тестах из этого урока. Домашнее задание сводит три уровня в один рабочий пакет. Отдельно стоит прогнать пакет дважды: go test без флагов и с -count=1 после очистки образов — если тесты завалились на чистой машине, они зависят от состояния, оставленного прошлым прогоном. И последняя мысль: testcontainers и pgxmock не конкурируют — мок описывает намерение кода, контейнер проверяет реальность, и наличие обоих уровней отличает проверенный SQL-слой от надеющегося.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Два уровня тестов БД-кода: 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 каждой версии, которую вы поддерживаете.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup