Go: GORM

Теория: Тестирование кода с GORM

Работа с базой данных всегда добавляет тестам сложности. Нужно, чтобы код ходил в «настоящую» БД, проверял связи, ограничения, ошибки миграций — и при этом тесты запускались быстро, не ломали друг друга и не требовали отдельного PostgreSQL на машине каждого разработчика.

С GORM удобно выстроить три слоя:

  • поднимать лёгкую тестовую базу в памяти (обычно SQLite);
  • изолировать каждый тест транзакцией и откатом;
  • работать с фиксированным набором тестовых данных (фикстурами) и уметь быстро очищать базу.

Разберём, как это сделать в Go-проекте с GORM.

SQLite в памяти как тестовая база

Для юнит-уровня нам не нужна вся мощь продакшн-PostgreSQL. Гораздо удобнее использовать SQLite в памяти. Она живёт в процессе теста, поднимается за миллисекунды, не требует установки и отлично поддерживается GORM.

Идея такая: на старте тестов открываем in-memory SQLite, включаем проверку внешних ключей, один раз прогоняем миграции — и дальше используем это подключение во всех тестах.

Пример вспомогательной функции:

package store_test

import (
	"testing"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

type User struct {
	ID    uint
	Email string `gorm:"uniqueIndex"`
}

type Post struct {
	ID     uint
	Title  string
	UserID uint
	User   User
}

func newTestDB(t *testing.T) *gorm.DB {
	t.Helper()

	db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
		Logger:                                   logger.Default.LogMode(logger.Silent),
		DisableForeignKeyConstraintWhenMigrating: false,
	})
	if err != nil {
		t.Fatalf("open sqlite: %v", err)
	}

	sqlDB, err := db.DB()
	if err != nil {
		t.Fatalf("unwrap sql.DB: %v", err)
	}

	// В SQLite foreign keys по умолчанию выключены, их нужно включить вручную.
	_, _ = sqlDB.Exec(`PRAGMA foreign_keys = ON;`)

	// Закрываем соединение по завершении тестового набора.
	t.Cleanup(func() { _ = sqlDB.Close() })

	// Один раз поднимаем схему.
	if err := db.AutoMigrate(&User{}, &Post{}); err != nil {
		t.Fatalf("migrate: %v", err)
	}

	return db
}

Здесь важны несколько деталей.

Во-первых, строка подключения file::memory:?cache=shared. Это не просто «какая-то память», а общий in-memory стор для всех соединений внутри процесса. GORM использует пул соединений, и без ?cache=shared данные могли бы теряться между разными коннектами.

Во-вторых, PRAGMA foreign_keys = ON. Без него SQLite никак не реагирует на нарушения внешних ключей. Для нас это критично: тесты должны вести себя так же строго, как и продакшн-база. Если нельзя создать пост без пользователя, тест обязан упасть.

В-третьих, миграции выполняются один раз в newTestDB. Не нужно дёргать AutoMigrate() в каждом тесте — это медленно и создаёт гонки, если тесты идут параллельно.

Изоляция тестов транзакциями

Даже с быстрой базой в памяти тесты легко начать «мусорить» данными: один тест создал пользователя, другой тест внезапно его нашёл и прошёл, хотя по смыслу должен был работать с пустой базой.

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

Вводим небольшой хелпер:

func withTx(t *testing.T, db *gorm.DB, fn func(tx *gorm.DB)) {
	t.Helper()

	tx := db.Begin()
	if tx.Error != nil {
		t.Fatalf("begin tx: %v", tx.Error)
	}

	// Rollback гарантированно выполнится даже при t.FailNow или панике.
	defer tx.Rollback()

	fn(tx)
}

Теперь сам тест работает только через tx:

func TestCreateAndQueryPost(t *testing.T) {
	db := newTestDB(t)

	withTx(t, db, func(tx *gorm.DB) {
		alice := User{Email: "alice@example.com"}
		if err := tx.Create(&alice).Error; err != nil {
			t.Fatalf("create user: %v", err)
		}

		post := Post{Title: "Hello", UserID: alice.ID}
		if err := tx.Create(&post).Error; err != nil {
			t.Fatalf("create post: %v", err)
		}

		var got Post
		if err := tx.Preload("User").First(&got, post.ID).Error; err != nil {
			t.Fatalf("query post: %v", err)
		}

		if got.User.Email != "alice@example.com" {
			t.Fatalf("want email alice@example.com, got %q", got.User.Email)
		}
	})
}

После завершения withTx() вызывается Rollback(), и созданные записи исчезают. Можно проверить это прямо в тесте:

func TestTxIsolation(t *testing.T) {
	db := newTestDB(t)

	withTx(t, db, func(tx *gorm.DB) {
		_ = tx.Create(&User{Email: "tmp@example.com"}).Error
	})

	var n int64
	if err := db.Model(&User{}).Count(&n).Error; err != nil {
		t.Fatalf("count: %v", err)
	}
	if n != 0 {
		t.Fatalf("expected 0 users after tx, got %d", n)
	}
}

Если всё сделано правильно, счётчик после транзакции будет нулевой.

Ключевой момент — дисциплина: всё, что касается базы в тесте и внутри тестируемых функций, должно работать через *gorm.DB, полученный из withTx(). Если где-то в глубине кода вы возьмёте глобальный db и сделаете db.Create(...), эта запись окажется вне транзакции и никуда не денется при Rollback().

Фикстуры и очистка базы

Тесты редко ограничиваются одной вставкой и выборкой. Чаще нужно сложное стартовое состояние: несколько пользователей, посты, связи, справочники. При этом состояние должно быть:

  • предсказуемым;
  • легко читаемым;
  • быстро подготавливаемым.

Здесь помогают фикстуры — заранее описанные наборы данных. Их можно задавать Go-кодом (фабрики), JSON/YAML-файлами или сырими SQL-скриптами.

В связке с транзакциями схема обычно такая: тест открывает транзакцию, заливает фикстуры, прогоняет проверки, делает Rollback(). База возвращается к нулевому состоянию, соседние тесты никак не зависят от этого набора данных.

Примитивные фабрики:

type UserFactory struct {
	n int
}

func (f *UserFactory) New(email string) User {
	if email == "" {
		f.n++
		email = fmt.Sprintf("user%02d@example.com", f.n)
	}
	return User{Email: email}
}

func NewPost(u User, title string) Post {
	if title == "" {
		title = "Post"
	}
	return Post{Title: title, UserID: u.ID}
}

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

func seedBasic(tx *gorm.DB) (User, Post) {
	var f UserFactory

	u := f.New("alice@example.com")
	if err := tx.Create(&u).Error; err != nil {
		panic(err)
	}

	p := NewPost(u, "Hello")
	if err := tx.Create(&p).Error; err != nil {
		panic(err)
	}

	return u, p
}

Тест с такими фикстурами выглядит понятно:

func TestFindPostByUser(t *testing.T) {
	db := newTestDB(t)

	withTx(t, db, func(tx *gorm.DB) {
		u, p := seedBasic(tx)

		var got Post
		if err := tx.Preload("User").
			Where("user_id = ?", u.ID).
			First(&got).Error; err != nil {
			t.Fatalf("query: %v", err)
		}

		if got.ID != p.ID || got.User.Email != "alice@example.com" {
			t.Fatalf("unexpected post or user")
		}
	})
}

Все данные живут только внутри транзакции. После Rollback() база снова чистая.

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

Простейший пример для SQLite:

func truncateAllSQLite(t *testing.T, db *gorm.DB) {
	t.Helper()

	sqlDB, err := db.DB()
	if err != nil {
		t.Fatalf("unwrap: %v", err)
	}

	// Временно отключаем проверки внешних ключей.
	sqlDB.Exec(`PRAGMA foreign_keys = OFF;`)
	_ = db.Exec(`DELETE FROM posts;`).Error
	_ = db.Exec(`DELETE FROM users;`).Error
	// Сбрасываем автоинкрементные счётчики.
	sqlDB.Exec(`DELETE FROM sqlite_sequence;`)
	sqlDB.Exec(`PRAGMA foreign_keys = ON;`)
}

Для PostgreSQL часто используют TRUNCATE ... RESTART IDENTITY CASCADE для нескольких таблиц сразу. В любом случае принцип один: перед тестом очистили, засеяли фикстуры, прогнали сценарий, при необходимости очистили ещё раз.

На что обращать внимание

Тестовая база с GORM хорошо работает, если помнить несколько правил.

Во-первых, не перенагружать AutoMigrate(). Поднять схему один раз при создании *gorm.DB и полагаться на транзакции и фикстуры. Запускать миграции в каждом тесте — простой способ замедлить проект и получить гонки.

Во-вторых, везде использовать tx, где нужна изоляция. Любой вызов через «голый» db внутри теста обходит транзакцию и сохраняет данные навсегда.

В-третьих, следить за внешними ключами в фикстурах. С включёнными PRAGMA foreign_keys = ON (или в PostgreSQL по умолчанию) порядок вставки важен: сначала «родители», потом «дети» и таблицы связей. Попытка создать пост с UserID, которого ещё нет, должна падать, и тест должен это увидеть.

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

+7 800 100 22 47

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

+7 495 085 21 62

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

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