Go: SQL

Теория: Тестирование кода работающего с базой

Когда код начинает работать с реальной базой, простых юнит-тестов уже недостаточно. Хочется проверить не только логику функций, но и то, как запросы ведут себя на настоящей схеме: проходят ли миграции, правильно ли настроены индексы, корректно ли отрабатывают ограничения и внешние ключи. Для этого нужны интеграционные тесты, которые запускают тот же SQL, что и в продакшене, но в безопасной среде. Удобнее всего поднимать отдельную тестовую базу, накатывать на неё миграции и прогонять тесты на чистой схеме.

Инициализация тестовой базы

Хороший вариант — поднимать базу прямо из тестов в виде контейнера. Тогда на любой машине и в любом CI будет одинаковое окружение: нужная версия PostgreSQL, те же параметры, те же миграции. Контейнер стартует перед запуском тестов, миграции накатываются автоматически, соединение сохраняется в глобальной переменной и используется всеми тестами, а после завершения прогонов контейнер удаляется.

Каркас удобно разместить в файле db_test.go и запускать его только для интеграционных тестов через build-тег. В TestMain() поднимается PostgreSQL с помощью testcontainers-go, создаётся подключение и выполняются миграции через goose.

//go:build integration

package db_test

import (
	"context"
	"database/sql"
	"embed"
	"fmt"
	"log"
	"os"
	"testing"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
	"github.com/pressly/goose/v3"
	tc "github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
)

var conn *sql.DB

//go:embed ../../migrations/*.sql
var migrationsFS embed.FS

func TestMain(m *testing.M) {
	ctx := context.Background()

	pg, err := postgres.RunContainer(ctx,
		postgres.WithImage("postgres:16"),
		postgres.WithDatabase("app"),
		postgres.WithUsername("app"),
		postgres.WithPassword("secret"),
		postgres.WithInitScripts(), // без init.sql, миграции накатываются из кода
		tc.WithWaitForStartupTimeout(60*time.Second),
	)
	if err != nil {
		log.Fatalf("start pg: %v", err)
	}
	defer func() { _ = pg.Terminate(ctx) }()

	host, _ := pg.Host(ctx)
	port, _ := pg.MappedPort(ctx, "5432/tcp")
	dsn := fmt.Sprintf(
		"host=%s port=%s user=app password=secret dbname=app sslmode=disable",
		host,
		port.Port(),
	)

	conn, err = sql.Open("pgx", dsn)
	if err != nil {
		log.Fatalf("open db: %v", err)
	}
	defer conn.Close()

	ctxPing, cancel := context.WithTimeout(ctx, 10*time.Second)
	if err := conn.PingContext(ctxPing); err != nil {
		cancel()
		log.Fatalf("ping db: %v", err)
	}
	cancel()

	goose.SetBaseFS(migrationsFS)
	if err := goose.SetDialect("postgres"); err != nil {
		log.Fatalf("goose dialect: %v", err)
	}
	if err := goose.Up(conn, "."); err != nil {
		log.Fatalf("goose up: %v", err)
	}

	code := m.Run()
	os.Exit(code)
}

Миграции лежат в каталоге migrations и попадают в бинарник через go:embed. goose читает их не с диска, а из встроенной файловой системы и применяет к только что созданной базе. Благодаря этому схема в тестах всегда совпадает с актуальной схемой проекта, а сами тесты не зависят от того, что у разработчика установлено локально.

Чтобы было понятно, как это работает, нужно раскрыть идею embed и embed.FS.

Что такое go:embed

go:embed — это директива компилятора. Она позволяет “вшить” любые файлы или каталоги прямо в бинарник. То есть кода вы не меняете, но вместе с программой компилируются и ваши миграции, шаблоны, статические файлы — что угодно.

Go делает это во время сборки:

//go:embed migrations
var migrationsFS embed.FS

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

Что такое embed.FS

embed.FS — это тип, который реализует интерфейс обычной файловой системы. С ним можно работать так же, как с os.DirFS:

  • читать файлы (ReadFile)
  • перебирать каталог (ReadDir)
  • открывать файлы (Open())

Разница только в одном: данные берутся не с диска, а из встроенной секции бинарника.

Пример:

data, _ := migrationsFS.ReadFile("migrations/001_init.sql")
fmt.Println(string(data))

Файл читается так же, будто лежит в файловой системе, но на самом деле его туда положил go:embed.

Дальше все тесты используют одно и то же соединение conn. Это ускоряет прогоны: контейнер и пул соединений инициализируются один раз, а каждый конкретный сценарий работает на уже поднятой базе. Чтобы кейсы не мешали друг другу данными, каждый тест изолируется собственной транзакцией с последующим откатом.

Транзакции в тестах и откат

Тесты, которые изменяют данные, должны оставлять базу в прежнем состоянии. Самый надёжный способ — оборачивать каждый кейс в транзакцию и в конце всегда делать Rollback(). Тогда все INSERT, UPDATE и DELETE внутри теста будут отменены, а таблицы останутся такими же, как после миграций. Индексы и кеш PostgreSQL останутся прогретыми, поэтому тесты при этом остаются быстрыми.

Удобно вынести общий шаблон в вспомогательную функцию withTx(). Она принимает testing.T и функцию с параметрами контекста и Queries, создаёт транзакцию через BeginTx(), сразу регистрирует отложенный Rollback() и передаёт в колбэк экземпляр sqlc, завязанный на эту транзакцию.

func withTx(t *testing.T, fn func(ctx context.Context, q *db.Queries, tx *sql.Tx)) {
	t.Helper()

	// Базовый контекст — из теста.
	// Если нужно, можно поверх навесить timeout.
	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
	t.Cleanup(cancel)

	tx, err := conn.BeginTx(ctx, &sql.TxOptions{
		Isolation: sql.LevelReadCommitted,
	})
	if err != nil {
		t.Fatalf("begin tx: %v", err)
	}

	// Любой тест либо сам закоммитит транзакцию, либо она откатится в конце.
	t.Cleanup(func() { _ = tx.Rollback() })

	qtx := db.New(tx) // все вызовы sqlc пойдут внутри этой транзакции
	fn(ctx, qtx, tx)
}

Тесты получаются компактными. В одном случае код создаёт запись и читает её обратно, в другом — проверяет поведение при пустой базе. После завершения каждого кейса транзакция откатывается автоматически, и следующий тест снова видит только то, что описано миграциями и, возможно, начальными сид-данными.

func TestCreateAndGetUser(t *testing.T) {
	withTx(t, func(ctx context.Context, q *db.Queries, _ *sql.Tx) {
		u, err := q.CreateUser(ctx, db.CreateUserParams{
			Email: "alex@example.com",
			Name:  sql.NullString{String: "Алексей", Valid: true},
		})
		if err != nil {
			t.Fatalf("create user: %v", err)
		}

		got, err := q.GetUserByEmail(ctx, u.Email)
		if err != nil {
			t.Fatalf("get by email: %v", err)
		}
		if got.ID != u.ID {
			t.Fatalf("want id %d, got %d", u.ID, got.ID)
		}
	})
}

func TestListUsersOnCleanDB(t *testing.T) {
	withTx(t, func(ctx context.Context, q *db.Queries, _ *sql.Tx) {
		users, err := q.ListUsers(ctx, db.ListUsersParams{Limit: 10, Offset: 0})
		if err != nil {
			t.Fatalf("list users: %v", err)
		}
		_ = users // результат зависит только от миграций и сидов
	})
}

Такой шаблон особенно хорошо работает вместе с sqlc. Пакет генератора уже умеет принимать *sql.Tx в конструкторе New(), поэтому не приходится писать отдельные функции для тестов. В боевом коде используется New(conn), в интеграционных тестах — New(tx). В обоих случаях сигнатуры методов и модели остаются одинаковыми: тот же CreateUser(), тот же ListUsers(), те же структуры User и параметры запросов.

Иногда нужно протестировать бизнес-функцию, которая сама открывает и коммитит транзакцию внутри. В таком случае тест не должен накрывать её внешним withTx(), иначе вложенный Commit() не зафиксирует изменения, а внешний Rollback() отменит всё, что происходило внутри. Для таких сценариев используется прямое соединение conn и явная уборка данных в t.Cleanup().

func TestServiceCommitsInside(t *testing.T) {
	ctx := context.Background()

	svc := NewUserService(conn) // сервис сам делает Begin/Commit внутри

	id, err := svc.Register(ctx, "maria@example.com", nil)
	if err != nil {
		t.Fatalf("register: %v", err)
	}

	t.Cleanup(func() {
		_, _ = conn.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, id)
	})
}

В сложных сценариях иногда нужно откатить только часть операций внутри одной долгой транзакции, не закрывая её полностью. Для этого PostgreSQL предлагает SAVEPOINT. Точка сохраняется через savepoint sp, после неудачной части выполняется rollback to savepoint sp, а транзакция продолжает жить. Такой приём помогает моделировать ошибки в середине сценария прямо в тесте.

func TestSavepointLocalRollback(t *testing.T) {
	withTx(t, func(ctx context.Context, q *db.Queries, tx *sql.Tx) {
		if _, err := tx.ExecContext(ctx, `SAVEPOINT sp1`); err != nil {
			t.Fatalf("savepoint: %v", err)
		}

		_, err := q.CreateUser(ctx, db.CreateUserParams{
			Email: "dup@example.com",
			Name:  sql.NullString{String: "Dup", Valid: true},
		})
		if err != nil {
			t.Fatalf("insert1: %v", err)
		}

		_, err = q.CreateUser(ctx, db.CreateUserParams{
			Email: "dup@example.com",
			Name:  sql.NullString{String: "Again", Valid: true},
		})
		if err == nil {
			t.Fatalf("want unique violation, got nil")
		}

		if _, rerr := tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT sp1`); rerr != nil {
			t.Fatalf("rollback to savepoint: %v", rerr)
		}

		_, err = q.CreateUser(ctx, db.CreateUserParams{
			Email: "ok@example.com",
			Name:  sql.NullString{String: "OK", Valid: true},
		})
		if err != nil {
			t.Fatalf("insert after rollback: %v", err)
		}
	})
}

Есть два нюанса, о которых важно помнить при проектировании тестов. Последовательности вроде SERIAL и BIGSERIAL не откатывают свои счётчики, поэтому нельзя полагаться на конкретные значения id; утверждения должны проверять наличие записей и связи, а не автоинкремент. Долгие транзакции и параллельные тесты могут конфликтовать по блокировкам, поэтому контексты в тестах лучше ограничивать по времени, а кейсы, которые пишут в одни и те же таблицы, не запускать параллельно.

В результате такой схемы тестовая база становится управляемой: контейнер поднимается один раз, миграции накатываются из тех же файлов, что и в продакшене, каждый тест работает внутри своей транзакции и откатывает изменения, а методы sqlc обеспечивают типобезопасный доступ к данным без ручного Scan().

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845