Содержание урока · 12 разделов
- Тестирование gorm: что проверять в слое данных
- База на тест или база на пакет: вопрос пула соединений
- Тестовая схема: AutoMigrate на тестовой базе
- Транзакционные тесты: rollback как изоляция
- t.Helper и t.Cleanup: связка с курсом Go-advanced
- Фабрика: дефолты, варианты, детерминизм
- Минимальный набор тестов для репозитория
- Моки против реальной базы: честное мнение
- Моки против реальной базы: кто что ловит
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Тестирование gorm: что проверять в слое данных#
Слой данных — самый обманчиво надёжный код в приложении: он не мигает на экране, не отвечает роутами, но именно он первым ломается от кривого WHERE, забытого фильтра или дубля по уникальному полю. Тестирование gorm начинается с честного списка того, что вообще стоит проверять: контракты методов репозитория (создал — прочитал обратно), поведение на пустой базе, фильтры и сортировки, уникальные ограничения, счётчик затронутых строк, ошибки на некорректных данных. Заметьте: ничего из этого нельзя проверить, не исполнив настоящий SQL, — GORM не эмулирует базу, Statement собирается в текст запроса и уезжает в СУБД через диалектор. Поэтому подход здесь не юнит-в-изоляции, а интеграционный: настоящая база, пусть и in-memory. На пирамиде из урока про тестирование Go это выглядит так: снизу — быстрые тесты репозитория на SQLite в памяти (их десятки, они исполняются за миллисекунды), выше — тесты сервисов и хендлеров, где репозиторий подменён интерфейсом-моком, и лишь на вершине — немного интеграционных прогонов на настоящем Postgres. Репозиторий в этой картине — шов, точка подмены: он объявляет интерфейс, его реализация гоняет настоящий GORM, а потребители тестируются на подделке. Так вы получаете и скорость, и честность там, где она нужна. Табличные тесты — t.Run-сабтесты, t.Fatal на подготовке — приезжают сюда из курса Go-advanced без изменений: поменяется только то, что вместо чистой функции на входе у вас база со схемой, а шагов стало больше: открыть, мигрировать, наполнить, проверить, убрать за собой. Последние три шага и есть герои этого урока.
Канон урока: репозиторий TodoItemRepo
GO// Модель канона. Deadline - указатель: срока может не быть,
// это приём двадцать первого урока, здесь он просто работает.
type TodoItem struct {
ID uint
Title string
Slug string `gorm:"unique"` // уникальность: второй такой Slug создать нельзя
Done bool
Deadline *time.Time
}
// Репозиторий - единственный слой, которому нужен *gorm.DB.
type TodoItemRepo struct {
db *gorm.DB
}
func NewTodoItemRepo(db *gorm.DB) *TodoItemRepo {
return &TodoItemRepo{db: db}
}
func (r *TodoItemRepo) Create(item *TodoItem) error {
return r.db.Create(item).Error
}
func (r *TodoItemRepo) ListOpen() ([]TodoItem, error) {
var items []TodoItem
err := r.db.Where("done = ?", false).Order("deadline").Find(&items).Error
return items, err
}
func (r *TodoItemRepo) Complete(id uint) (int64, error) {
res := r.db.Model(&TodoItem{}).Where("id = ?", id).Update("done", true)
return res.RowsAffected, res.Error
}
func (r *TodoItemRepo) BySlug(slug string) (*TodoItem, error) {
var item TodoItem
err := r.db.Where("slug = ?", slug).First(&item).Error
if err != nil {
return nil, err
}
return &item, nil
}Четыре метода — и на каждом свой класс тестов: Create проверит схему и уникальность, ListOpen — фильтр и порядок, Complete — счётчик затронутых строк, BySlug — поиск и поведение на отсутствии записи. Слаг с тегом unique нужен не для красоты: без него тест на дубль просто не поймает ошибку.
База на тест или база на пакет: вопрос пула соединений#
Ловушка прошлого урока пересобирается для тестов одной фразой: приватная in-memory-база SQLite живёт на соединении, а у database/sql пул соединений. Открываете gorm.Open(sqlite.Open(":memory:")) без ограничений — пул заводит несколько соединений, AutoMigrate строит таблицы на первом, а второй тест, взявший второе соединение, получает пустую базу и «no such table». Прошлый урок показал лечение file::memory:?cache=shared — общая память для всех соединений пула. Но у тестирования свой вопрос: не «как заставить работать», а «какая граница изоляции нужна». Вариант первый — база на тест: каждый тест открывает собственный gorm.Open, а пул жёстко ограничивается одним соединением вызовом SetMaxOpenConns(1). Один тест = одно соединение = одна приватная база: пустая база невозможна по построению, тесты не видят чужих данных даже при t.Parallel, а стоимость — десяток лишних AutoMigrate, которые SQLite в памяти исполняет за миллисекунды. Вариант второй — база на пакет: TestMain открывает одну общую базу тем же приёмом, миграция выполняется один раз, все тесты живут в ней. Это быстрее и экономнее для тяжёлых схем, но данные перетекают между тестами: каждый тест обязан чистить за собой или работать в транзакции, а t.Parallel поверх общей базы превращается в гонку. Рабочее правило: база на тест — по умолчанию, база на пакет — осознанный выбор для дорогих схем, и тогда транзакционные тесты (ниже) возвращают изоляцию. Запрос «sqlite тесты go» почти всегда разрешается именно решением этого выбора, а не хитрыми запросами.
Хелпер openTestDB: своя база на каждый тест
GO// База на тест: один вызов - своя приватная in-memory-база.
func openTestDB(t *testing.T) *gorm.DB {
t.Helper() // ошибка укажет на строку теста, а не хелпера
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
TranslateError: true, // наследство прошлого урока: дубли как gorm.ErrDuplicatedKey
})
if err != nil {
t.Fatalf("не открыли тестовую базу: %v", err)
}
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1) // одно соединение = одна приватная база
if err := db.AutoMigrate(&TodoItem{}); err != nil {
t.Fatalf("не собрали тестовую схему: %v", err)
}
t.Cleanup(func() {
sqlDB.Close() // in-memory-база умирает вместе с соединением
})
return db
}
// Вариант на пакет: одна база на весь go test, миграция один раз.
// func TestMain(m *testing.M) {
// db, _ := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
// db.AutoMigrate(&TodoItem{})
// code := m.Run()
// os.Exit(code)
// }Три строки решают судьбу теста: SetMaxOpenConns(1) склеивает базу с единственным соединением, AutoMigrate собирает схему из моделей, t.Cleanup закрывает базу после теста — и она исчезает вместе с памятью. Хелпер принимает t, чтобы ошибка подготовки падала с t.Fatal и вела на строку теста.
Проверка: почему тест не видит таблицу
Тест открывает sqlite.Open(":memory:") без ограничений пула, AutoMigrate прошёл, а первый же запрос падает с «no such table». Что произошло?
Тестовая схема: AutoMigrate на тестовой базе#
Схема в тестах — отдельная тема с собственным компромиссом. В тестовой базе её собирает AutoMigrate из тех же моделей, что работают в бою: одна команда — и в памяти есть таблица со всеми колонками, уникальностями и индексами из тегов. Для in-memory-SQLite это мгновенно, поэтому вызов на каждый тест — не расточительство, а источник изоляции: база свежая, как утренний снег. Честная обратная сторона: тестовая схема и боевая могут разойтись. AutoMigrate создаёт и правит, но не удаляет колонки, а настоящие боевые переходы — это миграции четвёртого урока с их версиями и фиксациями; схема, собранная AutoMigrate-ом, доказывает, что модели корректны, но не доказывает, что боевая миграция накатила ровно то же самое. Практичный компромисс: тесты репозитория живут на AutoMigrate-схеме — им важны модели, фильтры и ограничения, а отдельный тест-на-схему (один на проект) проверяет реальные SQL-миграции на настоящей базе в CI. Второй урок этой темы — не бойтесь того, что тег уникальности в модели влияет на тесты: он и должен. Тег unique на Slug создаёт уникальное ограничение — и тест дубля получает настоящую ошибку от настоящей базы, а не от вашей фантазии. Именно поэтому моки бессильны в слое данных: ограничение живёт в базе, и только база может его охранять. Итог раздела: AutoMigrate на тест-БД — это не «миграции в тестах», а способ получить честную схему за миллисекунды, оставив настоящую миграционную дисциплину боевой инфраструктуре.
Транзакционные тесты: rollback как изоляция#
Идея транзакционных тестов проста до аскетизма и красива как следствие архитектуры GORM: оберните каждый тест в транзакцию и откатите её после. Механику Begin и Rollback вы знаете из пятнадцатого урока; здесь она превращается в инструмент изоляции. Тест открывает транзакцию, репозиторий и фабрики работают с ней — а точнее, с тем же gorm.DB: метод Begin возвращает именно gorm.DB, поэтому NewTodoItemRepo(tx) и makeTask(t, tx, ...) даже не замечают подмены. Все записи теста живут внутри транзакции, непрошенные для остальных соединений, а в конце теста Rollback стирает всё разом — не нужно чистить таблицы DELETE-ами, не нужно помнить порядок удаления из-за внешних ключей, не нужно пересоздавать схему. Изоляция даром. Обратная сторона честности ради: транзакционный тест никогда не завершится Commit-ом — это нормально, откат здесь не ошибка, а цель. И вторая грань: данные, созданные внутри транзакции, видны только в ней; если код тестируемого метода сам лезет в другую транзакцию или другой пул соединений, он чужих записей не увидит — и это уже не баг теста, а свойство транзакционной модели, которое стоит знать заранее. Схема «транзакция на тест» красиво сочетается с базой на пакет: общая in-memory-база остаётся, но каждый тест существует в собственном пузыре и оставляет после себя пустоту. Именно так транзакционные тесты реабилитируют вариант «на пакет», лишая его главного недостатка — перетекания данных.
Соберите каркас транзакционного теста
Восстановите хелпер newTx: транзакция на тест и откат в уборке. Три пропуска — по порядку.
Канон: newTx — транзакция на тест
GO// Каркас транзакционного теста: Begin в тесте, Rollback в уборке.
func newTx(t *testing.T, db *gorm.DB) *gorm.DB {
t.Helper()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
t.Cleanup(func() {
tx.Rollback() // сработает даже после t.Fatal
})
return tx
}
// Использование: tx - тот же *gorm.DB, подмена прозрачна.
func TestCreate(t *testing.T) {
db := openTestDB(t)
tx := newTx(t, db)
repo := NewTodoItemRepo(tx)
makeTask(t, tx, "Купить хлеб")
var n int64
tx.Model(&TodoItem{}).Count(&n) // внутри транзакции запись видна
if n != 1 {
t.Fatalf("внутри транзакции одна запись, получено %d", n)
}
}Сверьте с собранным в пропусках: Begin — Cleanup — Rollback. t.Cleanup гарантирует откат при любом исходе теста, поэтому следующий тест стартует с чистым листом без единого DELETE.
Прогноз: что останется после отката
Прочитайте код и выберите, что он выведет:
func TestRollbackIsolation(t *testing.T) {
db := openTestDB(t) // схема готова, MaxOpenConns(1)
makeTask(t, db, "Купить хлеб")
tx := db.Begin()
makeTask(t, tx, "Позвонить маме")
var inside int64
tx.Model(&TodoItem{}).Count(&inside)
tx.Rollback()
var after int64
db.Model(&TodoItem{}).Count(&after)
fmt.Println(inside, after)
}t.Helper и t.Cleanup: связка с курсом Go-advanced#
Два метода testing.T держат весь этот каркас, и оба вы разобрали в шестнадцатом уроке go-advanced — здесь они работают на данные. t.Helper() помечает функцию как помощника: если openTestDB или newTx упадут с t.Fatal, ошибка в отчёте будет указывать на строку теста, вызвавшего хелпер, а не на строку внутри хелпера — при двадцати тестах на одном хелпере разница между «читать отчёт» и «гадать» становится наглядной. t.Cleanup регистрирует уборку, которая сработает после завершения теста — включая случаи t.Fatal и t.Skip — и в порядке LIFO: последняя зарегистрированная уборка выполнится первой. Именно LIFO-порядок даёт правильную гигиену данных: newTx регистрирует Rollback раньше, openTestDB — Close, значит при завершении теста сначала откатится транзакция (транзакция ещё держит соединение — и правильно), а затем закроется база. Поменяйте порядок на бумаге — и увидите, что обратная последовательность попыталась бы закрыть базу под живой транзакцией. Есть и третья связка, о которой стоит помнить уже сейчас: t.Parallel. Тесты с базой на тест параллелятся бесплатно (t.Parallel()) — у каждого своя приватная in-memory-база; тесты на общей базе пакета параллелить нельзя без транзакционного пузыря и продуманных уникальных ключей. И маленькая привычка из того же урока: t.Fatal на подготовке (открыли базу — проверили ошибку), t.Error на ожиданиях, чтобы один сломанный тест не хоронил отчёт целиком. Хелперы, уборка, параллель — три букваря, на которых тестирование gorm стоит не хуже любого другого Go-кода.
Фабрики данных: makeTask и варианты
GOvar taskSeq int // счётчик уникальных Slug-ов на весь прогон
// makeTask создаёт задачу с осмысленными дефолтами и сохраняет её в базе.
func makeTask(t *testing.T, db *gorm.DB, title string, mods ...func(*TodoItem)) *TodoItem {
t.Helper()
taskSeq++
item := &TodoItem{
Title: title,
Slug: fmt.Sprintf("task-%d", taskSeq),
}
for _, mod := range mods {
mod(item)
}
if err := db.Create(item).Error; err != nil {
t.Fatalf("фабрика не смогла создать %q: %v", title, err)
}
return item
}
// makeTaskDue - вариант с дедлайном через days дней от фиксированной точки.
func makeTaskDue(t *testing.T, db *gorm.DB, days int) *TodoItem {
t.Helper()
due := fixedNow().AddDate(0, 0, days)
return makeTask(t, db, fmt.Sprintf("Задача на %d дней", days),
func(i *TodoItem) { i.Deadline = &due })
}
// fixedNow - фиксированная точка времени: тесты детерминированы.
func fixedNow() time.Time {
return time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)
}Фабрика принимает t (t.Helper и t.Fatal на подготовке), базу или транзакцию — это одна и та же *gorm.DB, — название и вариативные модификаторы. Дефолт живёт в одном месте, варианты не плодят новых функций, Slug гарантированно уникален счётчиком, а время пришито к календарю.
Фабрика: дефолты, варианты, детерминизм#
Фабрика данных — это функция-конструктор, которая делает три вещи: задаёт осмысленные дефолты, создаёт запись в базе и возвращает её. Выигрыш виден в тесте сразу: этап подготовки (arrange) сжимается до makeTask(t, tx, "Купить хлеб") — тест декларирует только то, чем ситуация отличается от нормы, а не всю механику конструирования структуры с пятью полями. Дефолты — сердце фабрики: осмысленный Title, уникальный Slug, Done равный false; всё, что тесту важно, он переопределяет модификатором func(*TodoItem) — вариативная функция с замыканием, как makeTaskDue, который приклеивает дедлайн. Так не появляется десятка фабрик makeTaskWithDeadline, makeTaskDone, makeTaskUrgent — появляется одна фабрика и точечные варианты. Второе правило — уникальность там, где её требует схема: Slug генерируется счётчиком, потому что уникальное ограничение из тега непреклонно, и второй одинаковый дефолт в общем прогоне упал бы с ошибкой дубля — предсказуемость важнее хитростей. Третье правило — детерминизм: никакого time.Now() внутри фабрик. Тест, сортирующий по Deadline, обязан давать один и тот же порядок сегодня, ночью и в CI в другом часовом поясе; фиксированная точка fixedNow превращает время из лотереи в константу. Фабрики живут в файле вида factories_test.go рядом с тестами и переиспользуются всеми тестами пакета. И заметьте, как складывается цепочка: хелпер открывает базу, newTx накрывает тест транзакцией, фабрика наполняет её — и весь этот конвейер держится на t.Helper и t.Cleanup, о которых шла речь выше.
Прогноз: фабрика без счётчика
Прочитайте код и выберите, что он выведет:
// Фабрика без счётчика: Slug всегда одинаковый.
func makeTask(t *testing.T, db *gorm.DB, mods ...func(*TodoItem)) *TodoItem {
t.Helper()
item := &TodoItem{Title: "Без названия", Slug: "task"}
for _, mod := range mods {
mod(item)
}
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
return item
}
func TestTwoTasks(t *testing.T) {
db := openTestDB(t) // TranslateError: true - как в каноне
makeTask(t, db)
makeTask(t, db, func(i *TodoItem) { i.Title = "Другое" })
fmt.Println("дошли до конца")
}Проверка: зачем фабрике фиксированное время
Почему фабрика ставит дедлайн через time.Date(2025, 3, 1, ...) от фиксированной точки, а не time.Now()?
Соберите шаги транзакционного теста по порядку
Расставьте строки одного теста репозитория в правильном порядке: от открытия базы до изоляции для следующего теста.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Минимальный набор тестов для репозитория#
| Случай | Что проверяем | Что ожидаем |
|---|---|---|
| Create: валидная задача | запись создана, ID заполнен | RowsAffected равен 1, BySlug находит её обратно |
| Create: дубль Slug | уникальное ограничение из тега | ошибка; через errors.Is ловится знакомая ошибка уникальности из прошлого урока |
| ListOpen: пустая база | поведение без записей | ошибки нет, пустой срез, len 0 — не nil и не паника |
| ListOpen: смесь открытых и закрытых | фильтр done = false и порядок | закрытые не попадают, порядок по Deadline |
| Complete: существующий id | обновление и счётчик строк | RowsAffected равен 1, Done стал true |
| Complete: несуществующий id | счётчик строк на промахе | RowsAffected равен 0 и ошибки нет — решите, баг это API или контракт |
| BySlug: неизвестный Slug | поведение на отсутствии | ошибка и nil, а не паника и не чужая запись |
Канон: тест-файл репозитория целиком
GOfunc TestTodoItemRepo(t *testing.T) {
db := openTestDB(t)
t.Run("Create и чтение обратно", func(t *testing.T) {
tx := newTx(t, db)
repo := NewTodoItemRepo(tx)
item := makeTask(t, tx, "Купить хлеб")
got, err := repo.BySlug(item.Slug)
if err != nil {
t.Fatal(err)
}
if got.ID != item.ID || got.Title != item.Title {
t.Fatalf("прочитано не то: %+v", got)
}
})
t.Run("дубль Slug - ошибка уникальности", func(t *testing.T) {
tx := newTx(t, db)
repo := NewTodoItemRepo(tx)
first := makeTask(t, tx, "Первая")
dup := &TodoItem{Title: "Вторая", Slug: first.Slug}
err := repo.Create(dup)
if !errors.Is(err, gorm.ErrDuplicatedKey) {
t.Fatalf("ждали ошибку уникальности, получено: %v", err)
}
})
t.Run("ListOpen - только открытые, порядок по дедлайну", func(t *testing.T) {
tx := newTx(t, db)
repo := NewTodoItemRepo(tx)
makeTaskDue(t, tx, 3)
makeTaskDue(t, tx, 1)
closed := makeTaskDue(t, tx, 2)
if _, err := repo.Complete(closed.ID); err != nil {
t.Fatal(err)
}
open, err := repo.ListOpen()
if err != nil {
t.Fatal(err)
}
if len(open) != 2 || open[0].Deadline.After(*open[1].Deadline) {
t.Fatalf("фильтр или порядок сломаны: %+v", open)
}
})
t.Run("Complete несуществующего id - RowsAffected 0", func(t *testing.T) {
tx := newTx(t, db)
repo := NewTodoItemRepo(tx)
n, err := repo.Complete(9999)
if err != nil || n != 0 {
t.Fatalf("n=%d, err=%v", n, err)
}
})
}Четыре сабтеста из таблицы выше — и ни одной строчки чистки: каждый сабтест живёт в своей транзакции на общей базе. Обратите внимание на третий: фабрики создают дедлайны через фиксированную точку, поэтому сравнение порядка не зависит от дня запуска. Импорты: testing, errors, time, fmt и, конечно, gorm.io/gorm с драйвером sqlite.
Моки против реальной базы: честное мнение#
Самый частый спор вокруг тестирования gorm — мокать или не мокать, и здесь уместно честное мнение вместо дипломатии. Моки оправданы выше слоя данных: тест сервиса или хендлера мокает интерфейс репозитория — это быстро, стабильно и проверяет именно вашу логику ветвлений, обработку ошибок, выбор сценариев. Мокать сам *gorm.DB бессмысленно и вредно: gorm.DB — конкретная структура, а не интерфейс, честной подмены не получится; самодельные обёртки над каждым методом GORM — это театр, в котором вы проверяете свои же заглушки, а не код. И главное: в слое данных мокам нечего проверять — контракты там охраняет база. Уникальное ограничение, NULL в указателе Deadline, порядок сортировки, счётчик затронутых строк, откат транзакции — всё это существует только в настоящей СУБД. Поэтому репозиторий тестируем на реальной базе, и SQLite in-memory — золотая середина: полноценный SQL-движок в памяти процесса, миллисекунды на старте, никаких серверов и портов. Но надо знать предел этой честности: SQLite — не Postgres. Диалектные отличия прошлого урока — типы колонок, RETURNING, диалект-операторы в строках условий — в in-memory-полигоне не воспроизводятся, и тест, зелёный на SQLite, не докажет, что на Postgres тот же запрос жив. Поэтому зрелая схема трёхслойная: десятки быстрых тестов на in-memory для логики, тесты потребителей на моках интерфейса, и небольшое число интеграционных прогонов на настоящем Postgres в CI — там, где диалект имеет значение. Это мнение стоило бы объявить правилом: мокайте то, что медленное и внешнее по смыслу, и не мокайте то, что и есть предмет проверки. А весь арсенал этого урока — база на тест, транзакционные пузыри и фабрики — соберётся в единый слой данных блога в итоговом проекте двадцать пятого урока: там каждой детали найдётся боевое место.
Моки против реальной базы: кто что ловит#
| Слой тестов | Что честно ловит | Чего не увидит |
|---|---|---|
| Мок интерфейса репозитория (тесты сервиса) | логику ветвлений, обработку ошибок, сценарии вызовов | реальный SQL, схему, уникальные ограничения |
| Реальная SQLite in-memory (тесты репозитория) | запросы GORM, схему из моделей, транзакции, дубли | диалектные отличия Postgres и MySQL |
| Реальный Postgres в CI | диалект, фактические типы колонок, конкуренцию | скорость: медленно, иногда флейкует |
| Самодельный мок *gorm.DB | ничего полезного | всё: проверяет ваши заглушки вместо кода |
Проверка: мок или реальная база
Почему GORM-код тестируют через репозиторий на реальной базе, а не моком *gorm.DB?
Первая: тест висит намертво. При SetMaxOpenConns(1) транзакция держит единственное соединение — любой запрос через внешний db (мимо tx) будет ждать его до бесконечности. Внутри транзакционного теста все запросы идут только через tx. Вторая: t.Parallel поверх общей базы пакета. Параллельные тесты пишут в одну схему: уникальные Slug-и сталкиваются, данные перетекают, отчёты «моргают». Параллель — только с базой на тест. Третья: недетерминированная фабрика. time.Now() внутри дефолтов и Slug без счётчика превращают зелёный локально тест в рулетку на CI — фиксированная точка времени и уникальные дефолты лечат навсегда.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тестирование gorm — интеграционное по природе: Statement уезжает в настоящий SQL, поэтому слой данных тестируют на реальной базе, а не на эмуляции. Пирамида: тесты репозитория на SQLite in-memory, тесты потребителей на моках интерфейса, немного интеграционных прогонов на настоящем Postgres.
- База в тестах живёт либо на тест, либо на пакет. База на тест — gorm.Open + SetMaxOpenConns(1): один тест = одно соединение = одна приватная in-memory-база, ловушка пустого соединения невозможна, t.Parallel бесплатен. База на пакет — TestMain с общей памятью и миграцией один раз, но данные перетекают.
- Тестовую схему собирает AutoMigrate из боевых моделей — за миллисекунды; теги ограничений работают в полную силу, поэтому тест дубля ловит настоящую ошибку. Соответствие боевых SQL-миграций схеме — отдельный тест в CI, материал четвёртого урока.
- Транзакционные тесты дают изоляцию даром: Begin на тест, репозиторий и фабрики работают с tx (Begin возвращает тот же *gorm.DB), Rollback в t.Cleanup стирает всё; Commit в таком тесте не случается — откат и есть цель.
- t.Helper ставит ошибку на строку теста, t.Cleanup выполняет уборку в LIFO-порядке даже после t.Fatal: сначала Rollback транзакции, затем Close базы. Связка — из шестнадцатого урока go-advanced.
- Фабрики данных: makeTask(t, db, title, mods...) с осмысленными дефолтами, уникальным Slug-счётчиком и фиксированной точкой времени time.Date; варианты — модификаторами, а не десятком новых функций.
- Минимальный набор тестов репозитория: Create и чтение обратно, дубль Slug (ошибка уникальности через errors.Is), ListOpen на пустой базе и на смеси, Complete с RowsAffected 1 и 0, BySlug на отсутствии записи.
- Моки против реальной базы: *gorm.DB — конкретная структура, мокать её нечестно и бесполезно; мокают интерфейс репозитория выше по слоям. SQLite in-memory ловит SQL, схему и транзакции, но не диалект — его проверяют на настоящем Postgres.
Домашнее задание
Соберите тестовое хозяйство для репозитория из канона. Шаг первый: напишите openTestDB с SetMaxOpenConns(1), AutoMigrate и t.Cleanup — и убедитесь, что два теста подряд стартуют с пустой базой, а пустое соединение воспроизводится, если убрать ограничение пула. Шаг второй: добавьте newTx и переведите все тесты на транзакционную схему; проверьте, что после каждого теста Count показывает ноль. Шаг третий: напишите фабрику makeTask с модификаторами и фабрику-вариант makeTaskDue от фиксированной точки времени; временно уберите счётчик Slug-ов и добейтесь падения дублем, затем восстановите и проверьте errors.Is на знакомую ошибку уникальности в тесте на дубль. Шаг четвёртый: закройте таблицу «минимальный набор тестов» — по сабтесту t.Run на каждую строку, от Create с чтением обратно до Complete на несуществующем id. Шаг пятый: сравните скорость — запустите пакет с базой на тест и вариант TestMain с общей памятью, замерьте go test -count=1 и решите, что вам ближе; заодно попробуйте t.Parallel на первом варианте и убедитесь, что он безопасен только там. Финал: поднимите настоящий Postgres, прогоните те же тесты репозитория на нём через изменение одного аргумента gorm.Open и запишите, какие различия выловил только настоящий диалект.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли тестировать GORM-код вообще без базы, на одних моках?
Слой сервисов и хендлеров — да: он зависит от интерфейса репозитория, который честно подменяется моком. Сам репозиторий — нет: *gorm.DB это конкретная структура, а не интерфейс, а его контракты (уникальные ограничения, NULL, сортировки, RowsAffected, откат транзакций) существуют только в настоящей СУБД. Поэтому нижний этаж пирамиды — реальная SQLite in-memory, и это по-прежнему быстро: база поднимается за миллисекунды.
Чем база на тест отличается от базы на пакет?
База на тест (gorm.Open + SetMaxOpenConns(1)) даёт каждому тесту приватную in-memory-базу: максимальная изоляция, безопасный t.Parallel, но миграция на каждый тест — для SQLite это миллисекунды. База на пакет (TestMain + file::memory:?cache=shared) одна на весь прогон: миграция один раз и меньше аллокаций, но данные перетекают между тестами — нужна дисциплина чистки или транзакционные тесты, и t.Parallel становится гонкой. Правило: на тест — по умолчанию, на пакет — осознанный выбор для дорогих схем.
Тесты на SQLite зеленые — значит, на Postgres всё работает?
Нет. SQLite in-memory проверяет логику запросов, схему из моделей и транзакции, но не диалект: типы колонок, RETURNING, диалект-операторы в строках условий и поведение под конкуренцией у Postgres свои — это и была тема прошлого урока. Поэтому поверх быстрых тестов на SQLite держат небольшое число интеграционных прогонов на настоящем Postgres в CI, где диалектные различия и ловятся.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
sqlc: типобезопасный код из SQL-запросов
Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.
- Настраивать sqlc.yaml для Postgres/MySQL/SQLite
- Писать аннотированные запросы с типизированными параметрами
- Работать с NULL, массивами и JSON-колонками
- Организовывать транзакции через WithTx