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

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

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

Урок 23 из 25 ~18 мин +50 XP

Тестирование ent-кода

Хуки семнадцатого урока писались до SQL, интерсепторы вшивают фильтры в запросы, счётчик posts_count живёт в базе — проверить всё это моками невозможно: мок репозитория честно повторяет ваш код, но не SQL. Сегодня — тестирование ent-кода на настоящей базе: enttest поднимает SQLite в памяти с автомиграцией за одну строку, фикстуры создают анну, бориса и веру с их постами, а тесты прогоняют реальные SELECT и INSERT с теми же хуками, интерсепторами и приватностью, что и продакшен. Разберём строку подключения с _fk=1, ловушку общих in-memory-баз, table-driven тесты и границы: что тестировать на базе, а что — на компиляторе. Если вы искали «ent тесты» или «sqlite в памяти» — это тот урок.

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

Маршрут урока — от первой строки enttest.Open до теста транзакции. Сначала — принцип: ent-код тестируется на настоящей базе, потому что смысл генерируемого кода — SQL; разберём enttest.Open и его опции. Затем — строка DSN: mode=memory, _fk=1 и cache=shared, каждая из которых меняет поведение базы по-своему. Третья часть — фикстуры: канон микроблога как переиспользуемые хелперы с t.Helper и t.Cleanup. Четвёртая — интеграционные тесты запросов и предикатов: table-driven, ассерты на сущности и ошибки ent.IsNotFound и ent.IsConstraintError. Пятая — тестируем хуки и интерсепторы: аудит в той же транзакции, Reject на жёстком удалении, фильтр тенанта. Финал — матрица «что чем тестировать», чеклист и порядок жизни теста. Если вы искали «тестирование ent» или «enttest» — это тот урок.

Почему настоящая база, а не мок#

ent существует ради сгенерированного SQL: билдер из девятого урока компилируется в запрос, и вся семантика — предикаты, рёбра, каскады из пятого и шестого уроков, хуки до SQL, интерсепторы — живёт на границе с базой. Мок репозитория повторяет ваши функции, но не SQL: он не отловит, что Where не сузил выборку, что UNIQUE ограничение из третьего урока сработало раньше хука, что порядок хуков конвейера из семнадцатого урока испортил значение. Поэтому тесты ent-кода — интеграционные: поднимаем настоящую базу, исполняем настоящие запросы, проверяем настоящие сущности. Стандарт де-факто — SQLite в памяти: не требует демона, стартует за миллисекунды, живёт внутри процесса теста. Цена честности — расхождение диалектов: SQLite и ваш боевой Postgres не идентичны, поэтому критичные к диалекту вещи (конкретные типы полей, поведение индексов из двадцатого урока, миграции Atlas-а) проверяйте ещё и на локальном Postgres — CI с контейнером базы для этого и заводят. А быстрые сотни тестов логики гоняйте в памяти: их скорость позволяет запускать их на каждый коммит.

enttest.Open: база в памяти за одну строку

GO
import (
        "entgo.io/ent/dialect"
        "entgo.io/ent/dialect/sql/schema"
        "entgo.io/ent/enttest"
        _ "github.com/mattn/go-sqlite3"
)

func TestUserCreate(t *testing.T) {
        client := enttest.Open(t, dialect.SQLite,
                "file:ent?mode=memory&_fk=1",
                enttest.WithMigrateOptions(schema.WithDropColumn(true)),
        )
        defer client.Close() // t.Cleanup внутри enttest — тоже вариант

        u, err := client.User.Create().
                SetName("анна").SetEmail("anna@entgo.io").
                Save(ctx)
        // schema.Create уже исполнен: таблицы на месте
}

enttest.Open(t, dialect, dsn, opts...) открывает клиент, прогоняет automigrate (Schema.Create) и в случае провала валит тест через t.Fatal. Третий аргумент — обычная DSN SQLite: mode=memory — база в памяти, _fk=1 — включить enforcement внешних ключей (об этом ниже). enttest.WithMigrateOptions пробрасывает опции миграции — WithDropColumn(true) и WithDropIndex(true) делают automigrate агрессивнее, что удобно в тестах, где схему меняют часто.

Проверка: зачем _fk=1

Почему в DSN тестовой базы почти всегда пишут _fk=1?

Строка DSN: память и её ловушки#

file:ent?mode=memory — не одна общая база, а отдельная in-memory-база на каждое открытое соединение. Пока ent держит пул из одного соединения, всё хорошо; но пул database/sql может открыть второе соединение — и оно увидит пустую базу: таблиц нет, тест падает загадочным no such table. Два выхода. Первый — cache=shared: file:ent?mode=memory&cache=shared открывает общую именованную память, и несколько соединений видят одну базу; при этом появляется блокировочная возня и общая судьба — база живёт, пока живо хотя бы одно соединение. Второй, надёжный путь — один *sql.DB на тест: откройте соединение сами, отдайте ent через entsql.OpenDB, как в двадцать втором уроке, и пул не разбежится. Для параллельных тестов изолируйтесь именем: file:test_%s?mode=memory&_fk=1 с t.Name() в формате даёт каждому тесту собственную базу — при условии одного соединения на клиент. И держите драйвер в уме: github.com/mattn/go-sqlite3 требует cgo; чистый Go-драйвер modernc.org/sqlite тоже работает — меняется имя драйвера в import, семантика DSN почти та же.

Изоляция тестов через имя

GO
dsn := fmt.Sprintf("file:test_%s?mode=memory&_fk=1", t.Name())
client := enttest.Open(t, dialect.SQLite, dsn)
defer client.Close()

// TestCreateUser      → file:test_TestCreateUser?mode=memory&_fk=1
// TestDeleteCascade   → file:test_TestDeleteCascade?mode=memory&_fk=1
// каждая база — своя: параллельные тесты не видят чужих строк

Имя теста в DSN — дешёвая изоляция: t.Name() уникален (в table-driven это TestX/subtest), каждая база рождается пустой и умирает с клиентом. Осторожно с символами в t.Name() для subtest-ов: слэши и пробелы нормализуйте, если драйвер придирается к DSN.

Проверка: две базы с одной строкой

Два теста открыли enttest.Open(t, dialect.SQLite, "file:ent?mode=memory&_fk=1") с одинаковым DSN, оба defer client.Close(). Что увидит второй тест?

Фикстуры: канон микроблога в тестах#

Тесты нужны данные, и данные должны быть каноничными: анна 26 лет с тремя постами, борис 19 с одним, вера 17 без постов, группа go-ninja и дружба анны и бориса — тот же мир, что и в основных уроках, только собранный хелпером. Фикстура — обычная Go-функция с testing.T и ent.Client в аргументах: она создаёт сущности через знакомые билдеры из восьмого урока и возвращает карту созданных объектов для ассертов. Три правила гигиены. Первое: t.Helper() — чтобы строки трейса указывали на тест, а не на хелпер. Второе: фикстуры не молчат об ошибках — t.Fatal вместо err-протаскивания, потому что ошибка фиксации — проблема теста, а не тестируемого кода. Третье: откат базы не нужен — in-memory база умирает с тестом; cleanup нужен только для клиентов на файлах и внешних ресурсах: t.Cleanup(func() { client.Close() }). Транзакционная обёртка «каждый тест в tx + Rollback» — альтернатива для тяжёлых баз, но с in-memory SQLite она не нужна: скорость перегенерации базы выше, чем отладка savepoint-ов.

Фикстура микроблога

GO
func seedMicroblog(t *testing.T, c *ent.Client) Users {
        t.Helper()
        users := Users{}
        var err error
        users.anna, err = c.User.Create().
                SetName("анна").SetAge(26).SetEmail("anna@entgo.io").
                AddPosts( /* или пусто — посты ниже */ ).
                Save(ctx)
        ...
        users.group, err = c.Group.Create().
                SetName("go-ninja").AddMembers(users.anna, users.boris).
                Save(ctx)
        if err != nil {
                t.Fatalf("seed: %v", err)
        }
        return users
}

Скелет фикстуры: тот же канон, что и в основных уроках, — анна, борис, вера, go-ninja. t.Helper() убирает строки хелпера из трейса; t.Fatal честно валит тест на ошибке посева. Хелпер возвращает структуру с созданными сущностями: тестам нужны их ID для ассертов связей и счётчиков.

Интеграционные тесты запросов#

Тест запроса — тот же тест функции, только вместо чистого результата вы проверяете сущности и ошибки. Форма — table-driven, как учит стандартная библиотека: список кейсов с входными предикатами и ожиданиями, t.Run на каждый, свежие данные из фикстуры на весь набор. Ассерты двух сортов: позитивные — поля сущности совпали, With-загрузки из одиннадцатого урока принесли нужные рёбра, счётчик posts_count сошёлся с числом постов; негативные — ошибки ожидаемого типа: ent.IsNotFound(err) после Only по отсутствующему id (поведение Only из девятого урока), ent.IsConstraintError(err) при нарушении UNIQUE по email из тринадцатого и четырнадцатого уроков. Проверяйте типы ошибок хелперами ent, а не строками: text сообщения меняется между драйверами, а семантика IsConstraintError стабильна. И не забывайте считать: Race-режим go test -race обязателен для всего, что трогает clients из горутин; счётчик запросов (лог драйвера или костыль-обёртка) выловит N+1 до того, как его найдёт прод.

Table-driven тест фильтра

GO
func TestUserFilters(t *testing.T) {
        client := enttest.Open(t, dialect.SQLite, testDSN(t))
        defer client.Close()
        seedMicroblog(t, client) // анна 26, борис 19, вера 17

        cases := []struct {
                name    string
                pred    predicate.User
                want    []string
        }{
                {"возраст", user.AgeGTE(18), []string{"анна", "борис"}},
                {"имя", user.NameContains("вер"), []string{"вера"}},
                {"пусто", user.AgeGT(99), nil},
        }
        for _, tc := range cases {
                t.Run(tc.name, func(t *testing.T) {
                        got, err := client.User.Query().
                                Where(tc.pred).OrderBy(user.FieldName).
                                All(ctx)
                        if err != nil {
                                t.Fatal(err)
                        }
                        // сравнить имена с tc.want
                })
        }
}

Предикаты — обычные значения, их можно класть в таблицу кейсов: тот же механизм из десятого урока, только в тестах. Порядок OrderBy(user.FieldName) делает ожидания детерминированными. Внутри t.Run данные не пересеиваются: фикстура одна на набор, кейсы только читают — для мутационных кейсов пересеивайте в каждом subtest.

Дубль email на чистой базе

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

client := enttest.Open(t, dialect.SQLite, testDSN(t))
defer client.Close()

_, e1 := client.User.Create().
        SetName("анна").SetEmail("anna@entgo.io").Save(ctx)
_, e2 := client.User.Create().
        SetName("анна-двойник").SetEmail("anna@entgo.io").Save(ctx)

// что верно про e1 и e2?

Тестируем хуки: конвейер под присмотром#

Хуки из семнадцатого урока — самые недооценённые кандидаты на тесты, потому что их баги тихие: забытый next.Mutate не падает, а молча убивает мутацию; перепутанный порядок хуков меняет итог без ошибок. enttest подключает их так же, как продакшен: enttest.WithOptions(ent.WithHooks(...)) — или они уже в schema.Hooks(), и тогда тест видит конвейер целиком, включая хуки схемы, которые раньше Use-хуков. Что проверять. Первое — срабатывание: аудит-хук после мутации оставил строку в audit_logs с верными entity/action; на Create — id уже известен, на обычном Create без tx — m.Tx() вернул ошибку, и вы это либо учли, либо фиксируете как контракт. Второе — порядок: два SetField-хука на одно поле — в базе итог последнего в конвейере. Третье — Reject: hook.Reject(ent.OpDelete) обязано вернуть ошибку до SQL, и счётчик строк после неудачного DeleteOneID не изменился. Четвёртое — атомарность: аудит-хук внутри WithTx из шестнадцатого урока — принудительно вернуть ошибку из хука и доказать, что и пользователь, и запись аудита не создались: один COMMIT, один ROLLBACK.

Тест аудита в той же транзакции

GO
func TestAuditAtomic(t *testing.T) {
        client := enttest.Open(t, dialect.SQLite, testDSN(t))
        defer client.Close()

        err := func() error {
                tx, err := client.Tx(ctx)
                if err != nil {
                        return err
                }
                defer tx.Rollback() // страховка

                if _, err := tx.User.Create().
                        SetName("борис").SetEmail("boris@entgo.io").
                        Save(ctx); err != nil {
                        return err
                }
                return fmt.Errorf("ломаем хвост: аудита не будет")
        }()
        if err == nil {
                t.Fatal("ожидали провал")
        }

        n, err := client.User.Query().Count(ctx)
        if err != nil || n != 0 {
                t.Fatalf("пользователь должен откатиться: n=%d err=%v", n, err)
        }
        // тот же шаблон — для audit_logs: один COMMIT на всё

Шаблон «ломаем посередине»: сущность создаётся, затем тест (или хук) возвращает ошибку — ассерт проверяет, что в базе пусто. Для аудит-хука из семнадцатого урока добавьте третий шаг: после успешной мутации в той же tx посчитайте строки audit_logs — они обязаны существовать и откатиться вместе с пользователем. Именно такие тесты отличают работающий аудит от «кажется, работает».

Соберите открытие тестового клиента

Впишите пакет-помощник, диалект, ключи DSN и закрытие клиента.

client := enttest.(t, dialect., "file:ent?mode=memory&cache=shared&=1", enttest.WithMigrateOptions(schema.WithDropColumn(true)), ) defer client.()

Матрица: что чем тестировать#

Что проверяемЧемНюанс
Валидаторы полейCreate с плохим значением на enttestошибка до SQL; счётчик строк не меняется
Запросы и предикатыреальные SELECT + table-drivenдетерминированный OrderBy в каждом кейсе
Рёбра и каскадыфикстуры + DeleteOneIDбез _fk=1 база не проверит половину правды
Хукиenttest.WithOptions(ent.WithHooks(...))порядок, Reject, аудит в tx; забытый next.Mutate
Интерсепторыфильтр тенанта/мягкого удаленияи прямые запросы, и загрузки через рёбра
ТранзакцииWithTx + принудительный сбойодин COMMIT/ROLLBACK: атомарность аудита
Миграциипустая база + Schema.Create дваждывторой прогон — идемпотентность
Privacy-политикизапрос без Allow-правилent.ErrPrivacyPolicyViolated как ожидаемая ошибка

Интерсепторы, privacy и миграции в тестах#

Интерсепторы из восемнадцатого урока проверяются двухсторонне: прямой запрос к постам видит только свой тенант — и ленивая загрузка через ребро тоже, потому что TraverseFunc вшивается во всякий обход. Тест простой: два тенанта, по посту каждому, фильтр в интерсепторе — чтение под контекстом Acme возвращает один пост, под пустым контекстом падает ошибкой; именно этот тест ловит «забытый фильтр», который в проде стоил бы данных. Privacy из девятнадцатого урока тестируется ещё проще: клиент без Allow-правил на любой Query или Mutation возвращает ent.ErrPrivacyPolicyViolated — превратите это в отрицательный тест, чтобы случайное снятие privacy-правила с миграцией схемы заметил CI. Миграции: Schema.Create на чистой базе обязана пройти, а второй прогон подряд — не изменить схему (идемпотентность automigrate); WithDropColumn/WithDropIndex в тестах проверяют деградацию удаляемых колонок. Наконец, помните про границу: генерация типов, подписи билдеров, несуществующие поля — территория компилятора, тесты туда не ходят; тесты ходят туда, где SQL, порядок и транзакции.

Чеклист тестов ent#

  • enttest.Open(t, dialect.SQLite, dsn) — клиент с automigrate за одну строку; t.Fatal внутри на провале миграции.
  • DSN: mode=memory для скорости, _fk=1 обязательно (иначе SQLite игнорирует внешние ключи), cache=shared или один *sql.DB против no such table.
  • Изоляция — имя в DSN: file:test_<t.Name()>?mode=memory&_fk=1; параллельные тесты не видят чужих строк.
  • Фикстуры — канон микроблога: t.Helper(), t.Fatal на ошибках посева, возврат сущностей для ассертов; cleanup только для внешних ресурсов.
  • Ошибки сравнивайте хелперами ent.IsNotFound / ent.IsConstraintError / ent.ErrPrivacyPolicyViolated, а не текстом — сообщения зависят от драйвера.
  • Хуки: срабатывание, порядок двух SetField-хуков, Reject до SQL, атомарность аудита в WithTx — четыре обязательных теста из семнадцатого урока.
  • Интерсепторы: фильтр тенанта/мягкого удаления проверяется и на прямых запросах, и на загрузках рёбер.
  • Диалектная честность: критичное к диалекту гоняйте и на локальном Postgres в CI; -race — всегда.

Жизнь интеграционного теста

Соберите порядок шагов интеграционного теста ent-кода.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Что дальше#

Вы собрали контур честных тестов: enttest.Open с SQLite в памяти и automigrate, DSN с _fk=1 и изоляцией по имени, канонические фикстуры микроблога, table-driven тесты запросов с ассертами на ошибки ent, тесты хуков — срабатывание, порядок, Reject и атомарность аудита, — интерсепторы и privacy как отрицательные тесты. Теперь вся механика курса — от билдеров девятого урока до privacy девятнадцатого — проверяемая, и вы можете рефакторить схемы без страха. Дальше — производительность: N+1 при загрузке рёбер, выборка лишних столбцов и batch-операции — то, что тесты не покажут на трёх строках фикстур, но покажет прод на миллионе. Полный план из 25 уроков — на странице курса; хуки — в семнадцатом уроке, транзакции — в шестнадцатом, мягкое удаление — в четырнадцатом, upsert и constraint-ошибки — в тринадцатом.

Карточки: тестирование ent

Термин

enttest.Open

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

enttest.Open(t, dialect, dsn, opts...) — клиент ent для тестов: открывает соединение, прогоняет Schema.Create (automigrate), валит тест через t.Fatal на провале. Опции: enttest.WithOptions(ent.WithHooks(...), ent.WithInterceptors(...)) — хуки и интерсепторы как в проде; enttest.WithMigrateOptions(schema.WithDropColumn(true), schema.WithDropIndex(true)).

1 / 6

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Тестирование ent — интеграционное: смысл кода в SQL, поэтому мок репозитория не ловит несуженные Where, UNIQUE-ограничения, каскады и порядок хуков.
  • enttest.Open(t, dialect.SQLite, dsn) — клиент с automigrate за одну строку; enttest.WithOptions подключает хуки и интерсепторы, WithMigrateOptions — опции миграции.
  • DSN: mode=memory — база на соединение (ловушка no such table при многосоединённом пуле), cache=shared или один *sql.DB как решение, _fk=1 обязательно — иначе SQLite игнорирует внешние ключи.
  • Изоляция параллельных тестов — имя теста в DSN: file:test_<t.Name()>?mode=memory&_fk=1; каждая база рождается пустой и умирает с клиентом.
  • Фикстуры — канон микроблога как хелпер: t.Helper(), t.Fatal на ошибках посева, возврат сущностей; cleanup только для внешних ресурсов.
  • Ассерты на ошибки — хелперами ent: IsNotFound, IsConstraintError, ErrPrivacyPolicyViolated; тексты сообщений зависят от драйвера и в тестах не участвуют.
  • Хуки тестировать четырьмя способами: срабатывание, порядок SetField-хуков, Reject до SQL, атомарность аудита внутри WithTx; интерсепторы — на прямых запросах и загрузках рёбер.
  • Граница: типы и билдеры проверяет компилятор, SQL, порядок и транзакции — тесты; диалектную специфику — ещё и локальный Postgres в CI с go test -race.

Домашнее задание

Работайте в своём ent-проекте микроблога. Первое: подключите enttest и поднимите клиент с DSN file:ent?mode=memory&_fk=1 — замерьте время Schema.Create и убедитесь, что второй вызов automigrate в другом тесте идемпотентен. Второе: соберите фикстуру seedMicroblog (анна 26, борис 19, вера 17, группа go-ninja, по посту у анны и бориса) с t.Helper() и t.Fatal; напишите table-driven тест фильтров по возрасту и имени, у которого ожидания детерминированы OrderBy(FieldName). Третье: напишите тест дубля email: второй Create с тем же SetEmail обязан вернуть ошибку, для которой ent.IsConstraintError(err) == true; затем перепишите его через OnConflictColumns из восьмого урока и проверьте ветку UpdateNewValues. Четвёртое: подключите аудит-хук из семнадцатого урока и напишите два теста — успешный (в audit_logs появилась строка с entity User) и атомарный (ошибка внутри WithTx откатывает и пользователя, и запись аудита); снимите _fk=1 из DSN и опишите, какой из ваших тестов перестал ловить правду и почему. Пятое: добавьте интерсептор мягкого удаления из восемнадцатого урока (DeletedAtIsNil на чтение) и докажите тестом, что после SetDeletedAt сущность исчезает и из прямого запроса, и из загрузки через ребро WithPosts. Вопрос на подумать: какие из ваших тестов завалились бы на настоящем Postgres из-за расхождений диалектов, и что из этого стоит добавить в CI-контейнер?

Личные заметкиПусто
Хранятся только в вашем браузере (ent · Тестирование ent-кода)

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

ent тесты: можно ли мокировать ent-клиент вместо базы?

Технически — да (клиенты типизированы, можно вырезать интерфейс), но вы потеряете самое ценное: SQL, ограничения, каскады, порядок хуков. Стандарт курса — настоящая база: enttest и SQLite в памяти дают её за одну строку и миллисекунды старта. Моки оставьте для внешних систем — почты, очередей, HTTP — но не для слоя данных.

Насколько SQLite в памяти репрезентативна для Postgres?

Для логики — достаточно: предикаты, рёбра, хуки, интерсепторы и транзакции ведут себя одинаково. Для диалектной специфики — нет: типы полей, поведение индексов, детали миграций и тонкости блокировок различаются. Разумный компромисс: быстрый контур тестов на SQLite на каждый коммит и подмножество критичных тестов в CI на реальном Postgres-контейнере.

Почему тесты баз не изолируют транзакцией с Rollback, как в других стеках?

Так делают на тяжёлых базах, чтобы не чистить данные между тестами. С in-memory SQLite база пересоздаётся быстрее, чем откатывается и отлаживается, — изоляция по имени DSN дешевле и не мешает проверять сами транзакции. Откат-обёртка к тому же скрывает баги: тест внутри tx не увидит COMMIT-путь, а это ровно то, что вы хотите проверить хуками аудита.

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

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

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

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

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

go-kit25 уроков~14 чпродвинутый25 уроков уже доступны

go-kit: микросервисный тулкит

Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.

  • Понимать архитектуру endpoint → transport → service
  • Оборачивать сервисы middleware: логи, метрики, трейсинг
  • Подключать HTTP и gRPC транспорты
  • Ставить rate limiting и circuit breaker