Содержание урока · 10 разделов
- Табличные тесты: кейс, структура, ожидаемый тег
- Тег, а не текст: что сравнивать в ошибке
- Граничные кейсы: граница, пустота, nil, dive
- Что проверяем — как
- Кастомные функции и StructLevel под тестом
- Один экземпляр: тесты переиспользуют словарь
- Тесты правил одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
История одного рефакторинга: в RegisterForm «почистили дубли», и из тега Password ушло required — компилятор доволен, прод зелёный, вечером в базу легли пароли из трёх символов. Строки тегов для компилятора — просто строки: снесённое правило, опечатка в имени, переставленный omitempty — всё это обнаруживается только в рантайме, на реальных данных, и никогда — на этапе сборки. Против этого один инструмент: тесты, которые прогоняют плохие структуры через validate.Struct и требуют конкретных ошибок на конкретных полях. Механика уже есть — ValidationErrors и FieldError разобраны в пятом уроке; недостающая часть — дисциплина: на каждый тег — таблица кейсов, на каждую границу — строка, на каждый успех — кейс с пустым ожиданием. Тег в структуре не компилируется в проверку, он исполняется — и проверяться должен так же, как исполняется: до продакшена, а не после.
Табличные тесты: кейс, структура, ожидаемый тег#
Каркас собирается из трёх деталей: срез кейсов, цикл с t.Run и сверка. Каждый кейс — структура с четырьмя полями: name для отчёта, in — кандидат, wantField и wantTag — ожидаемая пара; пустая пара означает «ошибки не ждали». В цикле validate.Struct исполняет теги над кандидатом, дальше развилка: ошибок нет и пара пустая — кейс зелёный; ошибка есть — errors.As вытаскивает ValidationErrors, и первая запись сверяется по Field() и Tag(). Сверка по обоим значениям принципиальна: Field() защищает от тега на чужом поле, Tag() — от снесённого правила, и обе ловушки остаются незамеченными при проверке одного только err != nil. Имя кейса — не украшение, а инструмент: провалившийся t.Run называет сценарий словами, и его цитируют в баг-репорте. Правило роста: новый тег — новая строка таблицы, а не новый тест-файл; правило поменяли — меняется ожидание в строке; правило снесли — таблица краснеет, и это ровно то, что нужно. И никакого переиспользования кандидатов: каждый кейс собирает свежую структуру, иначе независимые кейсы становятся зависимыми от порядка исполнения, а зависимость от порядка — самый дорогой баг тестового набора.
Каркас табличного теста
GOfunc TestRegisterRules(t *testing.T) {
tests := []struct {
name string
in RegisterForm
wantField string
wantTag string
}{
{"пароль короче восьми", RegisterForm{Email: "a@b.co", Password: "qwerty1"}, "Password", "min"},
{"пароль ровно восемь", RegisterForm{Email: "a@b.co", Password: "qwerty12"}, "", ""},
{"почта без собаки", RegisterForm{Email: "a-b.co", Password: "qwerty12"}, "Email", "email"},
{"пустая почта", RegisterForm{}, "Email", "required"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validate.Struct(&tc.in)
if tc.wantTag == "" {
if err != nil {
t.Fatalf("ожидали успех, получили %v", err)
}
return
}
var verrs validator.ValidationErrors
if !errors.As(err, &verrs) {
t.Fatalf("тип ошибки не ValidationErrors: %v", err)
}
if got := verrs[0].Field(); got != tc.wantField {
t.Fatalf("поле: %s, ожидали %s", got, tc.wantField)
}
if got := verrs[0].Tag(); got != tc.wantTag {
t.Fatalf("тег: %s, ожидали %s", got, tc.wantTag)
}
})
}
}Каркас один на весь курс: таблица с именем кейса, кандидатом и ожидаемой парой поле-тег; пустые wantField и wantTag означают «ошибки не ждали». Ветка успеха и ветка ошибки живут в одном t.Run — кейс читается целиком. errors.As вместо прямого приведения проверяет тип ошибки явно, и InvalidValidationError не притворится валидационной.
Проверка: почему err != nil недостаточно
Кейс проверяет только err != nil: ошибка есть — кейс зелёный. Какой дефект такой тест скроет?
Тег, а не текст: что сравнивать в ошибке#
Сравнивайте Field() и Tag(), а не текст Error(). Текст принадлежит слою сообщений: каталог русских текстов из шестого урока переписывает формулировки, локализация меняет язык целиком — тест на текст сломается при первой правке стиля, тест на Tag() переживёт всё, потому что это имя правила, а не формулировка. Уточнение для алиасов: Tag() возвращает имя, под которым правило зарегистрировано, — если свой тег из десятого урока живёт под именем slug, тест ждёт slug; ActualTag() понадобится, когда алиас разворачивается в цепочку встроенных правил, и нужно имя исходного. Param() иногда стоит зафиксировать тоже: min=8 даёт параметр «8», oneof — перечень значений; строка таблицы с ожидаемым параметром ловит не только снесённое правило, но и переписанное условие, когда правило формально живо, а договор уже другой. И всегда конкретное поле: Field() в сверке — страховка от «тег тот же, но поле переехало», когда после рефакторинга min проверяет ник вместо пароля, а зелёный тест не замечает подмены. Сообщения, согласование числительных, RegisterTagNameFunc — всё это проверяется слоем каталога отдельно, и его тесты сравнивают строки сознательно: там текст и есть контракт. Разделение простое: тесты правил держат имена правил, тесты сообщений — формулировки, и смешивать их в одной таблице не стоит.
Кейс «пять» при min=5
Прочитайте код и выберите, что он выведет:
type SlugForm struct {
Slug string `validate:"required,min=5"`
}
f := SlugForm{Slug: "5"}
err := validate.Struct(&f)
// что в err?Граничные кейсы: граница, пустота, nil, dive#
Таблица обязана содержать четыре рода кейсов. Первый — граница: min=5 проходит на значении ровно пять, границы включительные, и строка «на границе» отличает нестрогое правило от строгих gt и lt. Второй — пустота: пустая строка при required падает, строка из пробелов проходит — required проверяет не-нулевость, и эта нестрогость из урока про нормализацию заслуживает собственного кейса, потому что её иначе не увидишь. Третий — nil и omitempty: указатель nil под omitempty проходит все правила, omitempty глушит их на нулевом поле — кейс с nil фиксирует, что пустота легальна, а указатель на строку из ста одного символа падает на max; пара кейсов не даст снести omitempty при рефакторинге незаметно. Четвёртый — dive: срез тегов, где ломается второй элемент. Правила после dive исполняются по элементам слева направо, ошибки приезжают отдельными записями, и Field() вернёт имя с индексом — Tags[1], то есть запись относится именно ко второму элементу. Кейс, где первая строка валидна, а вторая — нет, ловит ровно поэлементность: без него тест не отличит проверку контейнера от проверки элементов — с этой путаницы начинался восьмой урок, и кейс с индексом — его прямое продолжение в тестах. Четыре рода кейсов — минимальный алфавит таблицы: правило, прошедшее только «счастливый путь», не тестировано вовсе.
Что проверяем — как#
| Что проверяем | Как | Что фиксирует тест |
|---|---|---|
| тег обязательности | вход без значения → Field()/Tag() | required упал на нужном поле |
| граница длины | значения ниже, на и выше границы | min/max: на границе ошибки нет |
| кастомная функция | предикат напрямую + структура с тегом | логика bool и проводка тега раздельно |
| вложенность | кандидат с nil-веткой и заполненной | Namespace() называет сломанную ветку |
| элементы среза | провал во втором элементе | Field() вида Tags[1], не первой строки |
| успех | заполненный кандидат, пустая пара | err nil: правило не стреляет по валидному |
Кастомные функции и StructLevel под тестом#
Свой тег из десятого урока тестируется в два слоя. Первый — предикат: чистая логика выносится из функции-обёртки в обычную функцию validSlug(s string) bool, и таблица гоняет её напрямую — без reflect, FieldLevel и валидатора; границы, пробелы, пустота — всё в маленькой таблице ожиданий. Второй — проводка: структура с тегом slug, validate.Struct, стандартная сверка Field() и Tag() — так проверяется, что регистрация случилась и правило подключено к полю. Ошибка «функция считает не то» и ошибка «тег не зарегистрирован» диагностируются раздельно, и каждая лечится в своём месте: первая — правкой логики, вторая — правкой регистраций. Тот же подход переносится на StructLevel из одиннадцатого урока: структурное правило тестируется через кандидата с расхождением и ожидание вашего тега в ValidationErrors — Field() вернёт имя поля из ReportError, и его стоит зафиксировать в таблице. Про пере-регистрацию держите в памяти контракт замены: повторная регистрация того же имени на общем экземпляре молча заменит правило для всего приложения — в тесте это способ тихо сломать соседние кейсы, а попытка занять служебное имя вроде required падает паникой ещё на регистрации.
Два слоя теста своего тега
GOfunc validSlug(s string) bool {
return len(s) >= 5 && !strings.ContainsAny(s, " /")
}
func slugFn(fl validator.FieldLevel) bool {
return validSlug(fl.Field().String())
}
func TestSlugPredicate(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"abcde", true},
{"my-slug", true},
{"abc", false},
{"ab cd", false},
{"", false},
}
for _, tc := range cases {
if got := validSlug(tc.in); got != tc.want {
t.Fatalf("slug %q: %v, ожидали %v", tc.in, got, tc.want)
}
}
}
func TestSlugWiring(t *testing.T) {
// wantTag — та же сверка Field()/Tag() из каркаса, вынесенная в хелпер с t.Helper
wantTag(t, validate.Struct(&SlugForm{"abc"}), "Slug", "slug")
wantTag(t, validate.Struct(&SlugForm{"ab cd"}), "Slug", "slug")
wantTag(t, validate.Struct(&SlugForm{""}), "Slug", "")
wantTag(t, validate.Struct(&SlugForm{"abcde"}), "Slug", "")
}Два слоя теста кастомного тега: предикат validSlug гоняется напрямую на границах — без reflect и валидатора, чистый bool; проводка проверяется структурой с omitempty,slug и стандартной сверкой Tag(). Ошибка «считает не то» и ошибка «тег не зарегистрирован» диагностируются раздельно: первый слой ловит математику, второй — регистрацию.
Кейс «промокод обязателен при доставке курьером» проверяли только err != nil — и тест годами был зелёным, хотя тестируемый required_if давно съехал: ошибка прилетала от oneof на Method, потому что кандидат собирали с опечаткой в способе доставки. Тест защищал что угодно, кроме своего правила: правилу требовался адрес — поле и тег. Если проверка не сверяет Field() и Tag(), то она не проверка, а фиксация факта «какая-то ошибка случилась»: заведите в таблице wantField и wantTag и сравнивайте оба значения — иначе зелёный кейс означает лишь то, что структура чем-то плоха, а дефект ловит тот, кто следующим запустит сервис.
Один экземпляр: тесты переиспользуют словарь#
Четырнадцатый урок закрепил: экземпляр валидатора один на приложение, регистрации — до первых проверок. Для тестов из этого следуют три правила. Первое: тесты переиспользуют пакетный экземпляр или зовут один конструктор buildValidator(), собирающий полный набор регистраций, — тестируется именно словарь приложения, а не параллельный мир со своим тегом phone: у нового экземпляра свои кэши, и прогрев приложение и тест платят дважды. Второе: пере-регистрация тега в тесте на общем экземпляре — мутация общего состояния: правило заменится для всего процесса, и порядок запуска тестов станет скрытой зависимостью; если тесту нужен особый словарь — собирайте отдельный экземпляр тем же конструктором, а не правьте общий. Третье: InvalidValidationError — тоже кейс. validate.Struct(nil) возвращает ошибку декодера, не ValidationErrors, и тест на это поведение занимает одну строку в той же таблице — errors.As с InvalidValidationError вместо FieldError. Регистрации в тестовом init подчиняются тому же порядку, что и в приложении: до первой проверки, потому что правило, зарегистрированное после разбора типа, в разбор не попадёт — кэш из четырнадцатого урока работает и в тестах, и это одна из самых тихих ловушек всей библиотеки.
Тесты правил одним списком#
- на каждый тег — строка в таблице: name, кандидат, wantField, wantTag
- ошибку сверяем парой Field() и Tag(), не текстом сообщения
- пустые wantField и wantTag — контракт «ошибки не ждали»: err обязан быть nil
- границы — обязательные кейсы: ниже границы, ровно на границе, выше
- omitempty и nil: пустота проходит, заполненное поле проверяется
- dive: кейс с провалом во втором элементе — Field() с индексом
- кастомный тег — два теста: предикат напрямую и проводка через структуру
- регистрации — до первых проверок; тесты переиспользуют экземпляр приложения или его конструктор
Проверка: nil под omitempty
Поле Comment *string с тегом omitempty,max=100. В кандидате указатель nil. Что должен ожидать тест?
Достаньте пару из ValidationErrors
Четыре пропуска: тип среза ошибок, безопасное приведение и два метода чтения записи.
Шаги одного кейса табличного теста
Разложите путь одного кейса с ожидаемой ошибкой от таблицы до вердикта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты правил
testing — стандартная библиотека, но каркас урока в песочнице целиком не запустится: go test и внешний модуль validator остаются локальными. Соберите файл *_test.go рядом со структурами, подключите валидатор go get-ом и прогоните go test ./... — таблица из кейсов станет вашей. Лучшая проверка каркаса: сломайте один тег в структуре и убедитесь, что конкретный кейс поймал именно его, а не соседний.
Табличный каркас обслуживает все правила курса: теги, условные обязательности, структурные функции — везде одна и та же пара Field() и Tag(). Дальше по курсу — производительность валидации: что именно кэширует экземпляр и почему таблицы кейсов не платят за прогрев дважды. Механика кейса — имя, кандидат, ожидание — переносится на любые рантайм-контракты: от конфигов восемнадцатого урока до ответов внешних API, и везде каркас тот же, меняется только способ получения ошибки. Пока тег живёт без теста, он существует с точностью до первого рефакторинга: добавьте строку в таблицу — и правило станет незыблемым. Если таблица разрастается на одну структуру за пределы экрана — это не повод городить мега-файл, а сигнал: правило пересматривают в месте его объявления, а тест лишь фиксирует договор, который стороны не меняют без билета.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тег живёт в рантайме: компилятор строки тегов не читает, и правило без теста существует до первого рефакторинга.
- Кейс табличного теста: name, кандидат, wantField, wantTag; пустая пара — контракт «ошибки не ждали».
- Сверяйте Field() и Tag(), а не текст сообщения: тексты меняются, имена правил стабильны.
- Границы включительные: ровно пять при min=5 проходит — кейс на границе обязателен.
- nil с omitempty проходит, заполненное поле проверяется; dive ловится кейсом со вторым элементом.
- Кастомный тег — два теста: предикат напрямую и проводка через структуру с ожидаемым Tag().
- Экземпляр один: тесты переиспользуют его конструктор, а не пере-регистрируют теги на живом словаре.
Домашнее задание
Возьмите форму из любого раннего урока и накройте каждое её правило табличным тестом: минимум восемь кейсов, среди них значение ровно на границе, пустая строка с required, nil-указатель с omitempty и срез, где проваливается второй элемент после dive. Сверяйте пару Field() и Tag() в каждом кейсе. Затем сломайте один тег в структуре и убедитесь, что конкретный кейс поймал именно его. Для своего тега из десятого урока добавьте оба слоя: таблицу предиката напрямую и тест проводки с ожидаемым Tag(). Финал — кейс на InvalidValidationError с nil-кандидатом и проверка, что каркас различает типы ошибок.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя проверять только длину ValidationErrors?
Потому что len больше нуля — это «какая-то ошибка где-то»: тот же зелёный свет, что и проверка err != nil. При рефакторинге правила переезжают между полями, и счётчик может остаться прежним при полностью сломанном договоре: две записи вместо одной при том же len. Честная сверка — Field() и Tag() ожидаемых записей; если правило обязано дать ровно одну запись, проверьте и длину.
Можно ли опираться на порядок записей в ValidationErrors?
Записи собираются в порядке обхода полей, но полагаться на порядок между полями не стоит: изменение состава тегов меняет список. В каркасе индекс [0] живёт в простых кейсах с одним ожидаемым провалом; для структур с несколькими правилами ищите запись по Field() и сверяйте её Tag() — так тест переживает любые перестановки.
Как тестировать правило, зависящее от времени?
Выносите время в параметр: теговая функция зовёт предикат с аргументом, а не time.Now() внутри. Тест подставляет фиксированные моменты — в прошлом, на границе, в будущем — и не зависит от часов машины. Тот же двухслойный приём, что и у кастомного тега: математика отдельно, проводка отдельно, время — данные, а не скрытая зависимость. Так правило остаётся чистым предикатом, и заодно его можно звать из кода без валидатора, когда контекст уже знает момент.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon