Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 24 из 25 ~20 мин +50 XP

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

Прошлый [урок](/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/).

Содержание урока · 12 разделов

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

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 }

Канон: 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 и варианты

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

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

Прочитайте код и выберите, что он выведет:

// Фабрика без счётчика: 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, а не паника и не чужая запись

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

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?

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

Термин

Почему «:memory:» без ограничений пула опасна в GORM-тестах?

нажмите, чтобы перевернуть
Определение

Пул держит несколько соединений, а приватная in-memory-база создаётся на соединение: второе соединение видит пустую базу и ловит её. Лечение — SetMaxOpenConns(1) или общая память пула.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 — SQLite in-memory, транзакционные тесты и фабрики данных)

Вопросы и ответы по уроку

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

sqlc25 уроков~12 чпрактика25 уроков уже доступны

sqlc: типобезопасный код из SQL-запросов

Курс sqlc: настройка, запросы, параметры, NULL-типы, транзакции, джойны, пагинация, CI и сравнение с ORM. 25 уроков с практикой.

  • Настраивать sqlc.yaml для Postgres/MySQL/SQLite
  • Писать аннотированные запросы с типизированными параметрами
  • Работать с NULL, массивами и JSON-колонками
  • Организовывать транзакции через WithTx