Содержание урока · 10 разделов
- pgx тесты: каркас из postgres.Run и pgxpool.New
- Миграции в тесте: Exec или WithInitScripts
- CRUD на живой базе и ошибка 23505
- Изоляция данных: четыре стратегии
- Стратегии изоляции: цена и гарантии
- Почему моки ловят не всё: типы, планировщик, каскады
- Чек-лист живого теста репозитория
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
У репозитория в Go обычно тонкий слой: методы принимают структуру, отдают структуру, а между ними — SQL. Пока этот слой прикрыт моком, тесты проверяют только вашу веру в базу: что INSERT вернёт nil, что SELECT отдаст те же поля, что ошибки не случится. Живой Postgres мыслит иначе — у него свои типы, ограничения, планировщик и каскады, и он ни за что не притворится моком. Тест репозитория на настоящей базе — это договор с движком: схема создаётся так же, как в проде, запросы проходят через настоящий парсер, ошибки возвращаются с настоящими кодами SQLSTATE. Платить придётся секундами: контейнер стартует дольше, чем создался мок, и прогон пакета требует Docker на машине. Но юнит-тест с заглушкой отвечает на вопрос «вызвал ли код метод», а интеграционный — «работает ли код вместе с Postgres». Для слоя хранилища второй вопрос и есть рабочий: именно он отделяет зелёный прогон от работающей функции. В этом уроке соберём такой тест целиком — от запуска контейнера до уборки после себя — и посмотрим, где он ловит то, что мок пропускает молча.
pgx тесты: каркас из postgres.Run и pgxpool.New#
Каркас любого pgx-теста собирается из четырёх шагов, и все они уже знакомы по уроку про модуль Postgres: 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, пул и уборка
GOfunc 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
GOfunc 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: код 23505 означает unique_violation, 23503 — foreign_key_violation, 23502 — not_null_violation. Таблицы SQLSTATE живут в документации Postgres, и они стабильнее любых сообщений: текст ошибки меняется между версиями, код — нет. Тот же тест с моком вернул бы nil на обоих INSERT — потому что и индекс, и дубликат существовали только в вашей голове. NULL-колонки сюда же: в поле без указателя Scan вернёт ошибку конвертации, и увидите вы её только на настоящем выводе движка.
Тест репозитория: вставить, прочитать, обновить
GOfunc 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: запрос «прошёл» и без совпадений, и без этой проверки тест молчит о промахе.
Реальная авария: метод 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, чтобы проверить каскады. Что произойдёт?
Проверка: распознать дубликат
Как надёжно отличить нарушение уникального индекса от любой другой ошибки pgx?
Почему моки ловят не всё: типы, планировщик, каскады#
Мок — это зеркало ваших ожиданий, и четыре класса багов он не отражает в принципе. Типы: 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 и транзакций хуже неидеального выбора
Что напечатает обработчик ошибки
Прочитайте код и выберите, что он выведет:
_, 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)
}Соберите каркас теста
Восстановите четыре вызова: DSN из контейнера, пул, проверка связи и распознавание дубликата.
Расставьте шаги хелпера setupTestDB
Соберите каркас по порядку: контейнер, DSN, пул, проверка связи, уборка и возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: pgx на живой базе
Честная оговорка: в браузерной песочнице этот урок не запустится — тестам нужен локальный Docker, установленный Go и go test в терминале вашего проекта. Песочница сайта исполняет только тематическую программу на чистом Go, без контейнеров и сети. Поэтому читайте код построчно: каркас setupTestDB, миграции, CRUD и 23505 — это сценарий, который вы воспроизводите у себя; всё необходимое — образ postgres, пул pgx и один файл с тестом. Первый прогон скачает образ, второй и дальше пойдут из кэша — минуты превратятся в секунды.
Что дальше: база-контейнер на каждый пакет — цена входа, и её снижают reuse и параллельность: переиспользование контейнера между тестами разберёт шестнадцатый урок, а управление конкурентным прогоном — семнадцатый, там же — изоляция под t.Parallel. Фикстуры уровня пакета и TestMain красиво складываются в Testify-наборы из девятнадцатого урока. Если связка код — база — тесты переросла этот уровень, системная перспектива есть в продвинутом курсе go-advanced. Домашняя проверка на честность: уберите из миграции уникальный индекс и посмотрите, как поведёт себя тест на дубликат — второй INSERT запишется молча, и это лучший аргумент за живую базу, который вы увидите собственными глазами. А когда тестов станет много, вернитесь к таблице изоляции и выберите одну стратегию для пакета — она окупится уже на следующем прогоне. Порядок один и тот же: живая база, честные ошибки, уборка после себя — остальное детали.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каркас 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, где связке посвящён отдельный разбор ошибок.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах