Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~20 мин +20 XP

Тесты: testcontainers и pgxmock

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

Содержание урока · 10 разделов

У репозитория из седьмого урока появился баг: метод 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, чтобы второй вызов переиспользовал первый контейнер.

Проверка: 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

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: опечатка в имени таблицы, лишняя запятая, неверный тип колонки пройдут тихо, потому что строка запроса лишь сопоставляется со строкой ожидания. Всё, что связано с реальным исполнением, уезжает на уровень выше.

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

Какой дефект 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

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

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) }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

postgres.Run

нажмите, чтобы перевернуть
Определение

Точка входа модуля postgres testcontainers-go: поднимает контейнер образа вроде postgres:16-alpine и возвращает обёртку с ConnectionString и Terminate.

1 / 6

Песочница урока не исполняет 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 и лезете на фиксированный порт.

Личные заметкиПусто
Хранятся только в вашем браузере (pgx · Тесты: testcontainers и pgxmock)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

MongoDB25 уроков~12 чпрактика25 уроков уже доступны

MongoDB в Go: официальный драйвер mongo-driver v2

Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.

  • Подключаться к MongoDB и настраивать клиента
  • Моделировать документы структурами и bson-тегами
  • Делать CRUD, сложные фильтры и проекции
  • Строить агрегации $match/$group/$lookup