2. Работа с базой данных в тестах

Столкновение с реальностью: почему моков недостаточно

При переходе от классических модульных тестов к интеграционным разработчики часто по инерции пытаются применить привычные приемы: изолировать всё, что выходит за рамки конкретной Go-функции. В контексте взаимодействия с реляционными базами данных это неизбежно приводит к подключению библиотек виртуализации вроде go-sqlmock.

Однако использование SQL-моков — это классический пример того самого антипаттерна, который мы разбирали в главе 7. Overmocking как анти паттерн. Мок базы данных проверяет лишь то, что ваш код сформировал ожидаемую строку SQL-запроса. Но реальный мир реляционных СУБД живет по гораздо более суровым законам. SQL-моки принципиально не способны проверить:

  1. Ограничения целостности: Сработают ли внешние ключи (Foreign Keys), проверки CHECK и ограничения уникальности.
  2. Триггеры и умолчания: Отработают ли серверные триггеры СУБД, автоинкрементные счетчики и генерация значений по умолчанию (DEFAULT).
  3. Драйверная сериализация: Корректно ли драйвер СУБД распарсит специфические типы данных (например, бинарный JSONB, сетевые адреса INET или структуры UUID в PostgreSQL) в нативные Go-структуры.
  4. Конкурентные аномалии: Как поведет себя приложение при взаимоблокировках (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().

Go
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/ и загружаются перед началом тестового сценария:

Go
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 подход.