Go: SQL
Теория: Тестирование кода работающего с базой
Когда код начинает работать с реальной базой, простых юнит-тестов уже недостаточно. Хочется проверить не только логику функций, но и то, как запросы ведут себя на настоящей схеме: проходят ли миграции, правильно ли настроены индексы, корректно ли отрабатывают ограничения и внешние ключи. Для этого нужны интеграционные тесты, которые запускают тот же SQL, что и в продакшене, но в безопасной среде. Удобнее всего поднимать отдельную тестовую базу, накатывать на неё миграции и прогонять тесты на чистой схеме.
Инициализация тестовой базы
Хороший вариант — поднимать базу прямо из тестов в виде контейнера. Тогда на любой машине и в любом CI будет одинаковое окружение: нужная версия PostgreSQL, те же параметры, те же миграции. Контейнер стартует перед запуском тестов, миграции накатываются автоматически, соединение сохраняется в глобальной переменной и используется всеми тестами, а после завершения прогонов контейнер удаляется.
Каркас удобно разместить в файле db_test.go и запускать его только для интеграционных тестов через build-тег. В TestMain() поднимается PostgreSQL с помощью testcontainers-go, создаётся подключение и выполняются миграции через goose.
Миграции лежат в каталоге migrations и попадают в бинарник через go:embed. goose читает их не с диска, а из встроенной файловой системы и применяет к только что созданной базе. Благодаря этому схема в тестах всегда совпадает с актуальной схемой проекта, а сами тесты не зависят от того, что у разработчика установлено локально.
Чтобы было понятно, как это работает, нужно раскрыть идею embed и embed.FS.
Что такое go:embed
go:embed — это директива компилятора. Она позволяет “вшить” любые файлы или каталоги прямо в бинарник. То есть кода вы не меняете, но вместе с программой компилируются и ваши миграции, шаблоны, статические файлы — что угодно.
Go делает это во время сборки:
После сборки migrationsFS содержит все файлы из каталога migrations, даже если на диске они отсутствуют. В бинарник — особенно для тестов и CLI-утилит — это даёт почти идеальную изоляцию: программа запускается одинаково в любой среде.
Что такое embed.FS
embed.FS — это тип, который реализует интерфейс обычной файловой системы. С ним можно работать так же, как с os.DirFS:
- читать файлы (
ReadFile) - перебирать каталог (
ReadDir) - открывать файлы (
Open())
Разница только в одном: данные берутся не с диска, а из встроенной секции бинарника.
Пример:
Файл читается так же, будто лежит в файловой системе, но на самом деле его туда положил go:embed.
Дальше все тесты используют одно и то же соединение conn. Это ускоряет прогоны: контейнер и пул соединений инициализируются один раз, а каждый конкретный сценарий работает на уже поднятой базе. Чтобы кейсы не мешали друг другу данными, каждый тест изолируется собственной транзакцией с последующим откатом.
Транзакции в тестах и откат
Тесты, которые изменяют данные, должны оставлять базу в прежнем состоянии. Самый надёжный способ — оборачивать каждый кейс в транзакцию и в конце всегда делать Rollback(). Тогда все INSERT, UPDATE и DELETE внутри теста будут отменены, а таблицы останутся такими же, как после миграций. Индексы и кеш PostgreSQL останутся прогретыми, поэтому тесты при этом остаются быстрыми.
Удобно вынести общий шаблон в вспомогательную функцию withTx(). Она принимает testing.T и функцию с параметрами контекста и Queries, создаёт транзакцию через BeginTx(), сразу регистрирует отложенный Rollback() и передаёт в колбэк экземпляр sqlc, завязанный на эту транзакцию.
Тесты получаются компактными. В одном случае код создаёт запись и читает её обратно, в другом — проверяет поведение при пустой базе. После завершения каждого кейса транзакция откатывается автоматически, и следующий тест снова видит только то, что описано миграциями и, возможно, начальными сид-данными.
Такой шаблон особенно хорошо работает вместе с sqlc. Пакет генератора уже умеет принимать *sql.Tx в конструкторе New(), поэтому не приходится писать отдельные функции для тестов. В боевом коде используется New(conn), в интеграционных тестах — New(tx). В обоих случаях сигнатуры методов и модели остаются одинаковыми: тот же CreateUser(), тот же ListUsers(), те же структуры User и параметры запросов.
Иногда нужно протестировать бизнес-функцию, которая сама открывает и коммитит транзакцию внутри. В таком случае тест не должен накрывать её внешним withTx(), иначе вложенный Commit() не зафиксирует изменения, а внешний Rollback() отменит всё, что происходило внутри. Для таких сценариев используется прямое соединение conn и явная уборка данных в t.Cleanup().
В сложных сценариях иногда нужно откатить только часть операций внутри одной долгой транзакции, не закрывая её полностью. Для этого PostgreSQL предлагает SAVEPOINT. Точка сохраняется через savepoint sp, после неудачной части выполняется rollback to savepoint sp, а транзакция продолжает жить. Такой приём помогает моделировать ошибки в середине сценария прямо в тесте.
Есть два нюанса, о которых важно помнить при проектировании тестов. Последовательности вроде SERIAL и BIGSERIAL не откатывают свои счётчики, поэтому нельзя полагаться на конкретные значения id; утверждения должны проверять наличие записей и связи, а не автоинкремент. Долгие транзакции и параллельные тесты могут конфликтовать по блокировкам, поэтому контексты в тестах лучше ограничивать по времени, а кейсы, которые пишут в одни и те же таблицы, не запускать параллельно.
В результате такой схемы тестовая база становится управляемой: контейнер поднимается один раз, миграции накатываются из тех же файлов, что и в продакшене, каждый тест работает внутри своей транзакции и откатывает изменения, а методы sqlc обеспечивают типобезопасный доступ к данным без ручного Scan().


