Дайджесты новостей
Патентная схема изолированной транзакции базы данных с точками сохранения Savepoint и финальным откатом Rollback

txdb: запуск тестов на реальной базе данных с автоматическим откатом транзакций в Go

Каждый Go-разработчик, создающий бэкенд с реляционной базой данных, рано или поздно оказывается перед классической инженерной развилкой: как тестировать слой доступа к данным (Repository / DAO) быстро, надежно и без головной боли?

Первый путь — использовать SQL-моки вроде популярной библиотеки go-sqlmock. Тесты пролетают за микросекунды прямо в оперативной памяти, но у этого удобства есть темная сторона. Моки проверяют лишь то, что твой код вызвал функцию с определенной строкой запроса. Они не знают диалекта конкретной СУБД, слепы к тонкостям приведения типов, не проверяют внешние ключи (foreign keys), триггеры, уникальные составные индексы и поведение CTE-выражений. В итоге тесты зеленые, а в продакшене сервис падает с синтаксической ошибкой SQL на первой же реальной нагрузке.

Второй путь — интеграционные тесты на настоящей базе данных (PostgreSQL, MySQL или SQLite, запущенных локально в Docker или через Testcontainers). Здесь проверка стопроцентно честная, но возникает мучительная проблема изоляции тестов. Чтобы один тест не влиял на результаты другого, данные между запусками нужно чистить. Но вызов TRUNCATE для десятков связанных таблиц или пересоздание схемы через миграции превращает тестовый набор в неповоротливого монстра: вместо секунд наборы тестов выполняются минутами, отбивая у инженеров всякое желание запускать проверку перед коммитом.

Команда DATA-DOG предложила фундаментально иное и технически изящное решение этой дилеммы, создав пакет go-txdb. Это однотранзакционный драйвер базы данных, который превращает тестирование на реальной СУБД в быстрый и полностью изолированный процесс.

Как устроен txdb: магия перехвата на уровне database/sql

Секрет эффективности go-txdb кроется в архитектуре стандартного пакета database/sql/driver в Go. Вместо того чтобы писать сложные прокси-серверы или патчить байткод, библиотека реализует официальные интерфейсы драйвера Go (driver.Driver, driver.Conn, driver.Tx), выступая прозрачной оберткой над твоим боевым драйвером — например, pgx, lib/pq, go-sql-driver/mysql или modernc.org/sqlite.

Когда твой тестовый сценарий запрашивает соединение с базой, txdb перехватывает управление и выполняет следующую последовательность действий:

  1. Открывает реальное физическое соединение к СУБД через указанный базовый драйвер;
  2. Сразу же выполняет команду BEGIN, переводя сессию в режим активной транзакции;
  3. Все последующие операции бизнес-логики — Exec, Query, подготовка выражений Prepare — выполняются строго внутри этой единой транзакции;
  4. Сервисный слой работает со стандартным интерфейсом *sql.DB, даже не подозревая, что находится внутри тестовой песочницы;
  5. Когда тест завершается и соединение закрывается (db.Close()), txdb выполняет команду ROLLBACK.

База данных мгновенно откатывает абсолютно все созданные, измененные или удаленные строки до состояния на момент старта теста. Процедура отката в движке PostgreSQL занимает доли миллисекунды, не требуя ни дисковой очистки, ни сброса системных каталогов.

Эмуляция вложенных транзакций через Savepoints

Ты сейчас наверняка скептически заметишь: «Но ведь в реальном коде сервисов мы сами постоянно открываем транзакции через db.Begin() и вызываем tx.Commit(). Если код внутри теста вызовет фиксацию транзакции, данные закоммитятся на диск и сломают всю изоляцию!»

И это совершенно справедливый вопрос, в котором и заключается главная прелесть реализации txdb. Разработчики драйвера научили его виртуализировать вложенные транзакции с помощью механизма контрольных точек (Savepoints), поддерживаемого большинством современных СУБД.

Смотри, что происходит под капотом при вызовах из бизнес-кода:

  • Когда твой сервис вызывает tx, err := db.Begin(), драйвер не отправляет в базу второй BEGIN (что вызвало бы ошибку или предупреждение), а генерирует уникальную точку сохранения: SAVEPOINT tx_1;
  • Если операция прошла успешно и сервис вызывает tx.Commit(), txdb транслирует это действие в SQL-команду RELEASE SAVEPOINT tx_1, подтверждая промежуточный результат внутри общего контура;
  • Если же в логике произошел сбой и код вызвал tx.Rollback(), драйвер откатывает базу ровно до контрольной точки: ROLLBACK TO SAVEPOINT tx_1.

Физическая же родительская транзакция остается открытой до самого конца выполнения теста, гарантируя, что ни один байт данных не просочится в постоянное хранилище базы.

Практический паттерн: sql.OpenDB и t.Cleanup

Раньше txdb требовал регистрации в глобальном реестре драйверов через txdb.Register. В современном Go (начиная с версии 1.10) лучшей практикой является использование функции sql.OpenDB в связке с коннектором txdb.New, что полностью исключает глобальное состояние:

package repository_test

import (
    "context"
    "database/sql"
    "testing"
    "time"

    "github.com/DATA-DOG/go-txdb"
    _ "github.com/jackc/pgx/v5/stdlib" // драйвер PostgreSQL pgx
)

const testDSN = "postgres://postgres:secret@localhost:5432/testdb?sslmode=disable"

// setupTestDB создает изолированный пул соединений с гарантированным откатом
func setupTestDB(t *testing.T) *sql.DB {
    t.Helper()

    // Инициализируем коннектор txdb поверх pgx
    connector := txdb.New("pgx", testDSN)
    db := sql.OpenDB(connector)

    // Регистрируем обязательный откат транзакции при завершении теста
    t.Cleanup(func() {
        if err := db.Close(); err != nil {
            t.Errorf("ошибка закрытия txdb соединения: %v", err)
        }
    })

    return db
}

func TestCreateAndFetchUser(t *testing.T) {
    db := setupTestDB(t)
    ctx := context.Background()

    // Вставка строки в настоящую PostgreSQL
    insertQuery := `INSERT INTO users (email, created_at) VALUES ($1, $2) RETURNING id`
    var newID int
    err := db.QueryRowContext(ctx, insertQuery, "developer@example.local", time.Now()).Scan(&newID)
    if err != nil {
        t.Fatalf("не удалось создать запись: %v", err)
    }

    // Чтение данных внутри той же транзакции
    var fetchedEmail string
    selectQuery := `SELECT email FROM users WHERE id = $1`
    err = db.QueryRowContext(ctx, selectQuery, newID).Scan(&fetchedEmail)
    if err != nil {
        t.Fatalf("ошибка чтения пользователя: %v", err)
    }

    if fetchedEmail != "developer@example.local" {
        t.Errorf("ожидался email developer@example.local, получено: %s", fetchedEmail)
    }

    // По завершении функции t.Cleanup вызовет db.Close() -> ROLLBACK.
    // Таблица users в базе testdb останется кристально чистой!
}

Второй пример демонстрирует, как txdb прозрачно справляется с вызовами вложенных транзакций в сервисах:

func TestTransferBalanceWithNestedTransaction(t *testing.T) {
    db := setupTestDB(t)
    ctx := context.Background()

    // Бизнес-логика открывает транзакцию (txdb превратит её в SAVEPOINT tx_1)
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil {
        t.Fatalf("не удалось открыть транзакцию: %v", err)
    }

    _, err = tx.ExecContext(ctx, `UPDATE accounts SET balance = balance - 100 WHERE id = 1`)
    if err != nil {
        _ = tx.Rollback()
        t.Fatalf("ошибка списания средств: %v", err)
    }

    // Фиксация внутренней транзакции (RELEASE SAVEPOINT tx_1)
    if err := tx.Commit(); err != nil {
        t.Fatalf("ошибка фиксации промежуточной транзакции: %v", err)
    }
}

Подводные камни и границы применимости

Несмотря на очевидные преимущества, у txdb есть архитектурные границы, которые необходимо четко понимать:

  1. Нетранзакционный DDL: в СУБД вроде MySQL или MariaDB выполнение DDL-команд (ALTER TABLE, DROP TABLE, CREATE TABLE) вызывает неявную фиксацию (implicit commit) текущей транзакции. В PostgreSQL большинство DDL-операций транзакционны, однако сервисные команды (VACUUM, CREATE DATABASE) не могут выполняться внутри транзакций;
  2. Параллелизм внутри одного теста: так как весь тест привязан к одной физической транзакции, txdb сериализует запросы через мьютекс. Если твой тестируемый код запускает десятки параллельных горутин, активно пишущих в базу, они не смогут выполняться в раздельных независимых подключениях пула.

В связке с современными практиками вроде фаззинг-тестирования в Go, драйвер txdb дает идеальный баланс: достоверность настоящей продакшн-базы с микросекундной скоростью выполнения тестов.