---
title: "Тестирование с GORM — SQLite in-memory, транзакционные тесты и фабрики данных"
description: "тестирование gorm: SQLite in-memory в тестах, транзакционные тесты, фабрики данных, t.Helper и t.Cleanup, изоляция через rollback, моки против реальной базы."
course: "GORM: ORM-библиотека для Go"
url: "https://golangmaster.ru/courses/gorm/lessons/24-testirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Тестирование с GORM — SQLite in-memory, транзакционные тесты и фабрики данных

*Курс «GORM: ORM-библиотека для Go», урок 24 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gorm/lessons/24-testirovanie/*

Прошлый [урок](/courses/gorm/lessons/23-dialekty/) закрыл диалекты, но оставил обещание: in-memory-полигон openLab() с cache=shared и TranslateError был передан вам как наследство — наступило время превратить его в полноценное тестовое хозяйство. Сегодня — тестирование gorm: где живёт тестовая база (на тест или на пакет), почему «один тест = одно соединение» — самый спокойный ответ на ловушку пула, как AutoMigrate собирает схему за миллисекунды, как транзакционные тесты с rollback дают изоляцию без ручной чистки таблиц и как фабрики данных заменяют простыни конструкторов одной читаемой строкой. Запрос «sqlite тесты go» вбивают в поиск в момент, когда первый же тест падает с «no such table» или два теста вдруг видят чужие данные, — оба симптома лечатся одним звонком SetMaxOpenConns(1). Весь каркас соберём вокруг канона — репозитория TodoItemRepo с методами Create, ListOpen, Complete и BySlug — и свяжем его тесты с t.Helper и t.Cleanup из [урока про тестирование Go](/courses/go-advanced/lessons/16-testirovanie/). Финишируем честным разговором о моках: где они уместны, почему мокать *gorm.DB — пустая работа и чего тесты на SQLite никогда не докажут. И оговорка о формате, как всегда одна: GORM требует живую СУБД, поэтому runner-ов в уроке нет — код не исполнить в песочнице, вас проверяют quiz, predict, fill, order и карточки, а весь код рассчитан на локальный Go и go test. Карта курса — на его [странице](/courses/gorm/).

### Тестирование 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». Что произошло?

- ✅ Пул выдал второе соединение, а приватная in-memory-база создаётся на соединение: новое соединение увидело пустую базу
- AutoMigrate надо вызывать перед каждым запросом — он действует только на один запрос
- SQLite не умеет in-memory-базы, нужен файл на диске
- gorm.Open вернул ошибку, которую молча проглотили

Это та же ловушка прошлого урока, перевёрнутая к тестам: «:memory:» приватна на соединение, а пул database/sql держит несколько соединений. Лечение — SetMaxOpenConns(1): один тест = одно соединение, либо общей памятью на весь пул из прошлого урока.

### Тестовая схема: 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: транзакция на тест и откат в уборке. Три пропуска — по порядку.

```go
func newTx(t *testing.T, db *gorm.DB) *gorm.DB {
    t.Helper()
    tx := db.___()
    if tx.Error != nil {
        t.Fatal(tx.Error)
    }
    t.___(func() {
        tx.___() // база снова чистая, даже если тест упал
    })
    return tx
}
```

Подсказка: Транзакцию начинает Begin, уборка регистрируется в t.Cleanup, а стирает всё Rollback.

Правильные ответы в пропусках: `Begin`, `Cleanup`, `Rollback`

### Канон: 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.

### Прогноз: что останется после отката

```go
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)
}
```

**Что выведет программа?**

- ✅ 2 1: внутри транзакции видны обе записи, после Rollback осталась только та, что была создана вне её
- 1 1: Count внутри транзакции не видит её же неподтверждённых записей
- 2 2: откат не стирает созданное в транзакции, только правки
- ошибка: Count после Rollback падает — транзакция уже закрыта

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

### 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 и варианты

```go
var 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, о которых шла речь выше.

### Прогноз: фабрика без счётчика

```go
// Фабрика без счётчика: 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("дошли до конца")
}
```

**Что выведет программа?**

- ✅ тест упадёт на втором Create: Slug "task" уже занят, уникальное ограничение вернёт ошибку — с переводом ошибок это gorm.ErrDuplicatedKey, и t.Fatal остановит тест
- напечатает «дошли до конца»: GORM молча обновит существующую запись по Slug
- напечатает «дошли до конца»: второй Create получит другой ID и не столкнётся с первым
- паника: повторный Create одной модели запрещён GORM

Уникальное ограничение из тега — это база, а не библиотека: второй INSERT со Slug "task" нарушает его. Перевод ошибок, включённый в тестовом конфиге с прошлого урока, превращает специфичную ошибку SQLite в gorm.ErrDuplicatedKey, которую проверяют через errors.Is. Фабрика обязана гарантировать уникальные дефолты — наш канон делает это счётчиком taskSeq.

### Проверка: зачем фабрике фиксированное время

**Вопрос:** Почему фабрика ставит дедлайн через time.Date(2025, 3, 1, ...) от фиксированной точки, а не time.Now()?

- ✅ Детерминизм: тест на фильтр и порядок по Deadline даёт один результат в любое время и в любом часовом поясе CI
- time.Now запрещён в тестах линтером Go
- time.Date быстрее time.Now на миллисекунду
- иначе SQLite не примет time.Time в колонку

time.Now() делает тест зависимым от момента запуска: «задача просрочена» сегодня — «актуальна» ночью, а порядок сортировки может зависеть от наносекунд. Фиксированная точка фиксирует все сравнения; этот же приём вы видели в makeTaskDue.

### Соберите шаги транзакционного теста по порядку

Расставьте строки одного теста репозитория в правильном порядке: от открытия базы до изоляции для следующего теста.

Правильный порядок строк:

1. `открыть базу на тест: db := openTestDB(t) — схема уже на месте`
2. `начать транзакцию: tx := db.Begin()`
3. `привязать репозиторий: repo := NewTodoItemRepo(tx)`
4. `наполнить данные фабрикой: makeTask(t, tx, "Купить хлеб")`
5. `проверить ожидания внутри транзакции`
6. `откатить: tx.Rollback() в t.Cleanup — база снова пуста`

### Минимальный набор тестов для репозитория

| Случай | Что проверяем | Что ожидаем |
| --- | --- | --- |
| 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, а не паника и не чужая запись |

### Канон: тест-файл репозитория целиком

```go
func 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?

- ✅ *gorm.DB — конкретная структура, а не интерфейс: честно подменить её нельзя, а ограничения базы мок не охраняет
- моки запрещены стандартным пакетом testing
- *gorm.DB не работает вне функции main
- иначе тесты не компилируются без установленной базы

Шов для подмены — интерфейс репозитория, а не библиотека. Сами репозитории гоняют настоящий GORM на SQLite in-memory: уникальность, фильтры и откат транзакций существует только в настоящей СУБД. Диалектные различия закрывают отдельными интеграционными прогонами на реальном Postgres.

> **Три ловушки тестов с GORM.** Первая: тест висит намертво. При SetMaxOpenConns(1) транзакция держит единственное соединение — любой запрос через внешний db (мимо tx) будет ждать его до бесконечности. Внутри транзакционного теста все запросы идут только через tx. Вторая: t.Parallel поверх общей базы пакета. Параллельные тесты пишут в одну схему: уникальные Slug-и сталкиваются, данные перетекают, отчёты «моргают». Параллель — только с базой на тест. Третья: недетерминированная фабрика. time.Now() внутри дефолтов и Slug без счётчика превращают зелёный локально тест в рулетку на CI — фиксированная точка времени и уникальные дефолты лечат навсегда.

### Карточки урока

- **Почему «:memory:» без ограничений пула опасна в GORM-тестах?** — Пул держит несколько соединений, а приватная in-memory-база создаётся на соединение: второе соединение видит пустую базу и ловит её. Лечение — SetMaxOpenConns(1) или общая память пула.
- **Транзакционный тест — что это?** — Схема изоляции: Begin перед тестом, репозиторий и фабрики работают с tx (это тот же *gorm.DB), Rollback в t.Cleanup — база остаётся пустой для следующего теста.
- **Фабрика данных — что делает?** — Функция-конструктор makeTask(t, db, title, mods...): осмысленные дефолты, уникальный Slug счётчиком, детерминированное время, варианты через функции-модификаторы.
- **Зачем t.Helper() в тестовых хелперах?** — Помечает функцию как помощника: при t.Fatal внутри неё ошибка в отчёте указывает на строку теста, а не на строку хелпера.
- **Как работает t.Cleanup?** — Регистрирует уборку после теста (LIFO), срабатывает даже при t.Fatal и t.Skip. Порядок важен: сначала Rollback транзакции, потом Close базы.
- **Мок или реальная база — где граница?** — Мокают интерфейс репозитория в тестах его потребителей; сам репозиторий тестируют на реальной SQLite in-memory. *gorm.DB — конкретная структура, мокать её бессмысленно; диалект закрывают интеграционными прогонами на настоящем Postgres.

## Коротко: что запомнить

- Тестирование 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, где диалектные различия и ловятся.
