2. Работа с базой данных в тестах
Столкновение с реальностью: почему моков недостаточно
При переходе от классических модульных тестов к интеграционным разработчики часто по инерции пытаются применить привычные приемы: изолировать всё, что выходит за рамки конкретной Go-функции. В контексте взаимодействия с реляционными базами данных это неизбежно приводит к подключению библиотек виртуализации вроде go-sqlmock.
Однако использование SQL-моков — это классический пример того самого антипаттерна, который мы разбирали в главе 7. Overmocking как анти паттерн. Мок базы данных проверяет лишь то, что ваш код сформировал ожидаемую строку SQL-запроса. Но реальный мир реляционных СУБД живет по гораздо более суровым законам. SQL-моки принципиально не способны проверить:
- Ограничения целостности: Сработают ли внешние ключи (Foreign Keys), проверки
CHECKи ограничения уникальности. - Триггеры и умолчания: Отработают ли серверные триггеры СУБД, автоинкрементные счетчики и генерация значений по умолчанию (
DEFAULT). - Драйверная сериализация: Корректно ли драйвер СУБД распарсит специфические типы данных (например, бинарный
JSONB, сетевые адресаINETили структурыUUIDв PostgreSQL) в нативные Go-структуры. - Конкурентные аномалии: Как поведет себя приложение при взаимоблокировках (Deadlocks) и строгих уровнях изоляции транзакций.
Чтобы получить подлинную гарантию надежности, бэкенд обязан тестироваться на реальной базе данных — в точности той же версии и с теми же настройками расширений, что работают в боевом кластере. Однако подключение живой СУБД немедленно обнажает главную архитектурную проблему интеграционных сьютов — проблему разделяемого глобального состояния.
Проблема глобального состояния и t.Parallel
В языке Go тесты компилируются и выполняются стремительно, а идиоматичный стиль поощряет вызов t.Parallel() для конкурентного прогона сценариев на всех ядрах процессора. Но как только тесты получают дескриптор реальной базы данных, наивный вызов t.Parallel() превращается в генератор хаоса.
flowchart TD
A["go test -parallel"] --> B["Тест A: INSERT"]
A --> C["Тест B: DELETE"]
A --> D["Тест C: SELECT"]
B --> E["sql.DB Pool<br/>sync.Mutex & []*driverConn"]
C --> E
D --> E
E -->|"Conn 1"| F["Таблица users<br/>Глобальное состояние БД"]
E -->|"Conn 2"| F
E -->|"Conn 3"| F
F -.->|"Race Condition:<br/>Flaky-поведение тестов"| A
style E fill:#1e1b4b,stroke:#38bdf8,stroke-width:2px,color:#bae6fd
style F fill:#1e1b4b,stroke:#ef4444,stroke-width:2px,color:#fca5a5
Игнорирование изоляции превращает тесты в печально известные «мигающие» сьюты (подробнее см. 6. Flaky тесты и их причины), случайным образом падающие при малейшем сдвиге квантов времени планировщика ОС.
Стратегии изоляции данных в тестах
Чтобы подружить реальную реляционную базу данных с параллельным тестовым раннером, необходимо обеспечить строгую идемпотентность: каждый тест обязан стартовать в кристально чистом окружении и бесследно убирать за собой любые мутации.
В промышленной разработке утвердились три базовые стратегии изоляции:
1. Очистка таблиц (Truncate / Delete)
Самый прямолинейный подход: перед стартом каждого теста (или в блоке завершения) выполняется пакетная очистка таблиц через команды TRUNCATE TABLE ... CASCADE или DELETE FROM ....
Преимущества:
- Гарантия абсолютно стерильного состояния таблиц перед запуском сценария.
- Отсутствие конфликтов при тестировании явных бизнес-транзакций.
Аппаратная цена (Mechanical Sympathy):
- В PostgreSQL инструкция
TRUNCATEнакладывает на таблицу самый жесткий тип блокировки —ACCESS EXCLUSIVE. Эта блокировка запрещает любые параллельные операции чтения и записи со стороны других транзакций. Как следствие, вы категорически не можете использоватьt.Parallel(): тесты вынужденно встают в строгую последовательную очередь. - Команда
DELETEблокирует строки мягче, однако порождает колоссальный объем журналов упреждающей записи (Write-Ahead Log, WAL) и засоряет страницы базы «мертвыми версиями строк» (dead tuples), нагружая дисковую подсистему операциями ввода-вывода и провоцируя фоновый автовакуум.
2. Уникальные схемы или БД под каждый тест
Для бескомпромиссного параллелизма на уровне Senior-инженерии каждый параллельный тест может динамически создавать собственную изолированную схему (SCHEMA) либо отдельную логическую БД, применять туда DDL-миграции и выполнять сценарий в полном вакууме.
Преимущества:
- Абсолютная изоляция данных на уровне метаданных СУБД.
- 100% поддержка
t.Parallel()без взаимных блокировок.
Недостатки:
- Накатывание десятков миграций на сотни динамических баз данных способно растянуть прогон CI на десятки минут.
3. Транзакционный подход (Rollback)
Наиболее элегантный и сбалансированный метод в Go: тело каждого теста оборачивается в изолированную транзакцию СУБД, а в финале вызывается принудительный откат ROLLBACK. Данные физически не фиксируются на диске, оставляя базу чистой. Подробно эту механику мы изучим в главе 3. Транзакции и rollback подход.
Идиоматичный сетап БД-теста в Go
Грамотная организация тестового подключения требует виртуозного владения инструментами пакета testing, в первую очередь хуком t.Cleanup() и директивой t.Helper().
package repository_test
import (
"context"
"database/sql"
"testing"
_ "github.com/jackc/pgx/v5/stdlib" // Высокопроизводительный драйвер PostgreSQL
"github.com/stretchr/testify/require"
)
// setupTestDB инициализирует соединение с пулом и гарантирует уборку
func setupTestDB(t *testing.T) *sql.DB {
// t.Helper() исключает внутренности хелпера из стека трейса при ошибке
t.Helper()
dsn := "postgres://user:pass@localhost:5432/test_db?sslmode=disable"
db, err := sql.Open("pgx", dsn)
require.NoError(t, err, "Не удалось инициализировать пул базы данных")
// Проверяем физическую доступность сокета через Ping
err = db.PingContext(context.Background())
require.NoError(t, err, "База данных не отвечает на ping")
// t.Cleanup гарантированно исполнится в самом конце теста
t.Cleanup(func() {
// Очищаем затронутые таблицы
_, err := db.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
if err != nil {
t.Logf("Ошибка при очистке таблиц: %v", err)
}
// Возвращаем и закрываем сокеты пула
db.Close()
})
return db
}
func TestUserRepository_CreateUser(t *testing.T) {
db := setupTestDB(t)
repo := repository.NewUserRepo(db)
ctx := context.Background()
// Вызываем боевой метод репозитория
err := repo.Create(ctx, &repository.User{Name: "Gopher", Email: "gopher@golang.org"})
require.NoError(t, err)
// Честная проверка в реальной СУБД: запись физически присутствует
var count int
err = db.QueryRow("SELECT count(*) FROM users").Scan(&count)
require.NoError(t, err)
require.Equal(t, 1, count)
}
Работа с фикстурами (Fixtures)
Для верификации сложных выборок, аналитических отчетов или вложенных соединений JOIN пустая база данных бессмысленна. Сценарию необходим подготовленный массив эталонных данных — фикстуры (Fixtures).
В Go-проектах SQL-фикстуры традиционно хранятся в изолированных .sql файлах внутри поддиректории testdata/ и загружаются перед началом тестового сценария:
func loadFixtures(t *testing.T, db *sql.DB, fixtureFile string) {
t.Helper()
query, err := os.ReadFile(fixtureFile)
require.NoError(t, err, "не удалось прочитать файл фикстур")
_, err = db.Exec(string(query))
require.NoError(t, err, "ошибка применения фикстур к БД")
}
Mechanical Sympathy при загрузке фикстур:
Никогда не выполняйте одиночные инструкции INSERT INTO ... VALUES (...) в цикле на тысячи записей. Каждый одиночный INSERT — это отдельный сетевой раунд-трип (Round-Trip Time, RTT) через сокет и отдельная фиксация в журнале транзакций СУБД. Вставка 5 000 строк по одной растянет старт теста на 10–15 секунд! Объединяйте вставку в единый пакетный INSERT (Batching) либо задействуйте нативный бинарный протокол потоковой загрузки (например, команду COPY в драйвере PostgreSQL). Это сократит время загрузки до десятков миллисекунд.
Поднятие БД: где ей жить?
Требование к каждому разработчику вручную устанавливать и настраивать локальный PostgreSQL убивает командный Developer Experience (DX).
Исторически команды полагались на общий файл docker-compose.yaml в корне репозитория. Сегодня высшим инженерным стандартом в экосистеме Go стало программное управление жизненным циклом контейнеров прямо из исходного кода теста — методология, которую мы во всех подробностях разберем в главе 4. testcontainers go.
Мы заложили фундаментальные принципы управления соединениями и изоляцией таблиц. Однако грубая очистка таблиц через TRUNCATE парализует параллельное выполнение тестов. О том, как добиться идеальной изоляции и молниеносной скорости с помощью транзакций, мы поговорим в следующей главе: 3. Транзакции и rollback подход.