Содержание урока · 12 разделов
- Почему настоящая база, а не мок
- Строка DSN: память и её ловушки
- Фикстуры: канон микроблога в тестах
- Интеграционные тесты запросов
- Тестируем хуки: конвейер под присмотром
- Матрица: что чем тестировать
- Интерсепторы, privacy и миграции в тестах
- Чеклист тестов ent
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Маршрут урока — от первой строки 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: база в памяти за одну строку
GOimport (
"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 почти та же.
Изоляция тестов через имя
GOdsn := 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-ов.
Фикстура микроблога
GOfunc 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 тест фильтра
GOfunc 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.
Тест аудита в той же транзакции
GOfunc 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 и закрытие клиента.
Матрица: что чем тестировать#
| Что проверяем | Чем | Нюанс |
|---|---|---|
| Валидаторы полей | 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 нет — тесты живут в вашем go-проекте, и это честно: тестируем ровно то, чем будем жить. Порядок прогона: добавьте enttest-зависимость, соберите seedMicroblog из фикстуры, напишите три теста — фильтр по возрасту table-driven, дубль email с ent.IsConstraintError, аудит-хук внутри WithTx с принудительным сбоем. Запустите go test ./... -race -v и найдите свои три зелёные строки. Затем сломайте по одному: уберите _fk=1 из DSN и посмотрите, какой тест из трёх перестанет ловить правду; уберите t.Helper() из фикстуры и посмотрите, куда указал трейс; закомментируйте hook.Reject — и убедитесь, что жёсткое удаление вернулось. Каждая поломка — экранное доказательство ценности соответствующей проверки.
Жизнь интеграционного теста
Соберите порядок шагов интеграционного теста ent-кода.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше#
Вы собрали контур честных тестов: enttest.Open с SQLite в памяти и automigrate, DSN с _fk=1 и изоляцией по имени, канонические фикстуры микроблога, table-driven тесты запросов с ассертами на ошибки ent, тесты хуков — срабатывание, порядок, Reject и атомарность аудита, — интерсепторы и privacy как отрицательные тесты. Теперь вся механика курса — от билдеров девятого урока до privacy девятнадцатого — проверяемая, и вы можете рефакторить схемы без страха. Дальше — производительность: N+1 при загрузке рёбер, выборка лишних столбцов и batch-операции — то, что тесты не покажут на трёх строках фикстур, но покажет прод на миллионе. Полный план из 25 уроков — на странице курса; хуки — в семнадцатом уроке, транзакции — в шестнадцатом, мягкое удаление — в четырнадцатом, upsert и constraint-ошибки — в тринадцатом.
Карточки: тестирование ent
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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-клиент вместо базы?
Технически — да (клиенты типизированы, можно вырезать интерфейс), но вы потеряете самое ценное: SQL, ограничения, каскады, порядок хуков. Стандарт курса — настоящая база: enttest и SQLite в памяти дают её за одну строку и миллисекунды старта. Моки оставьте для внешних систем — почты, очередей, HTTP — но не для слоя данных.
Насколько SQLite в памяти репрезентативна для Postgres?
Для логики — достаточно: предикаты, рёбра, хуки, интерсепторы и транзакции ведут себя одинаково. Для диалектной специфики — нет: типы полей, поведение индексов, детали миграций и тонкости блокировок различаются. Разумный компромисс: быстрый контур тестов на SQLite на каждый коммит и подмножество критичных тестов в CI на реальном Postgres-контейнере.
Почему тесты баз не изолируют транзакцией с Rollback, как в других стеках?
Так делают на тяжёлых базах, чтобы не чистить данные между тестами. С in-memory SQLite база пересоздаётся быстрее, чем откатывается и отлаживается, — изоляция по имени DSN дешевле и не мешает проверять сами транзакции. Откат-обёртка к тому же скрывает баги: тест внутри tx не увидит COMMIT-путь, а это ровно то, что вы хотите проверить хуками аудита.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker