---
title: "Тесты правил: таблицы кейсов"
description: "тесты валидации go: табличный каркас на каждый тег — Field() и Tag() вместо текста ошибки, граница min, nil и omitempty, dive и свои функции."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/19-testy-validacii/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Тесты правил: таблицы кейсов

*Курс «Валидация в Go: go-playground/validator», урок 19 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/validator/lessons/19-testy-validacii/*

Тесты валидации go — это табличный каркас, где каждая строка — кейс: имя, структура-кандидат и ожидаемая пара поле-тег. Тег в validate-строке не проверяется компилятором — он живёт в рантайме, и единственный способ не потерять правило при рефакторинге — тест, который вызывает validate.Struct и сверяет Field() и Tag(). Разбираем каркас, границу min, пустую строку и nil с omitempty, dive по второму элементу и два слоя теста для кастомных функций.

История одного рефакторинга: в RegisterForm «почистили дубли», и из тега Password ушло required — компилятор доволен, прод зелёный, вечером в базу легли пароли из трёх символов. Строки тегов для компилятора — просто строки: снесённое правило, опечатка в имени, переставленный omitempty — всё это обнаруживается только в рантайме, на реальных данных, и никогда — на этапе сборки. Против этого один инструмент: тесты, которые прогоняют плохие структуры через validate.Struct и требуют конкретных ошибок на конкретных полях. Механика уже есть — ValidationErrors и FieldError разобраны [в пятом уроке](/courses/validator/lessons/05-oshibki-validacii/); недостающая часть — дисциплина: на каждый тег — таблица кейсов, на каждую границу — строка, на каждый успех — кейс с пустым ожиданием. Тег в структуре не компилируется в проверку, он исполняется — и проверяться должен так же, как исполняется: до продакшена, а не после.

### Табличные тесты: кейс, структура, ожидаемый тег

Каркас собирается из трёх деталей: срез кейсов, цикл с t.Run и сверка. Каждый кейс — структура с четырьмя полями: name для отчёта, in — кандидат, wantField и wantTag — ожидаемая пара; пустая пара означает «ошибки не ждали». В цикле validate.Struct исполняет теги над кандидатом, дальше развилка: ошибок нет и пара пустая — кейс зелёный; ошибка есть — errors.As вытаскивает ValidationErrors, и первая запись сверяется по Field() и Tag(). Сверка по обоим значениям принципиальна: Field() защищает от тега на чужом поле, Tag() — от снесённого правила, и обе ловушки остаются незамеченными при проверке одного только err != nil. Имя кейса — не украшение, а инструмент: провалившийся t.Run называет сценарий словами, и его цитируют в баг-репорте. Правило роста: новый тег — новая строка таблицы, а не новый тест-файл; правило поменяли — меняется ожидание в строке; правило снесли — таблица краснеет, и это ровно то, что нужно. И никакого переиспользования кандидатов: каждый кейс собирает свежую структуру, иначе независимые кейсы становятся зависимыми от порядка исполнения, а зависимость от порядка — самый дорогой баг тестового набора.

### Каркас табличного теста

```go
func 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: ошибка есть — кейс зелёный. Какой дефект такой тест скроет?

- Провалится только на пустых структурах: для заполненных err всегда nil
- ✅ Любая ошибка на любом поле даст зелёный тест: чужой тег сымитирует проверяемое правило
- Тест упадёт при компиляции: err != nil без сравнения — синтаксическая ошибка
- Такой тест пропустит только InvalidValidationError

err != nil отвечает «сломалось ли что-то», а не «сломалось ли проверяемое правило на проверяемом поле». Рефакторинг, снёсший min с Password, оставит на структуре email — и кейс «пароль короче восьми» останется зелёным: ошибка на Email не nil. Сверяйте Field() и Tag().

### Тег, а не текст: что сравнивать в ошибке

Сравнивайте Field() и Tag(), а не текст Error(). Текст принадлежит слою сообщений: каталог русских текстов из шестого урока переписывает формулировки, локализация меняет язык целиком — тест на текст сломается при первой правке стиля, тест на Tag() переживёт всё, потому что это имя правила, а не формулировка. Уточнение для алиасов: Tag() возвращает имя, под которым правило зарегистрировано, — если свой тег из десятого урока живёт под именем slug, тест ждёт slug; ActualTag() понадобится, когда алиас разворачивается в цепочку встроенных правил, и нужно имя исходного. Param() иногда стоит зафиксировать тоже: min=8 даёт параметр «8», oneof — перечень значений; строка таблицы с ожидаемым параметром ловит не только снесённое правило, но и переписанное условие, когда правило формально живо, а договор уже другой. И всегда конкретное поле: Field() в сверке — страховка от «тег тот же, но поле переехало», когда после рефакторинга min проверяет ник вместо пароля, а зелёный тест не замечает подмены. Сообщения, согласование числительных, RegisterTagNameFunc — всё это проверяется слоем каталога отдельно, и его тесты сравнивают строки сознательно: там текст и есть контракт. Разделение простое: тесты правил держат имена правил, тесты сообщений — формулировки, и смешивать их в одной таблице не стоит.

### Кейс «пять» при min=5

```go
type SlugForm struct {
        Slug string `validate:"required,min=5"`
}

f := SlugForm{Slug: "5"}
err := validate.Struct(&f)
// что в err?
```

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

- nil: пять равно пяти — граница включительная
- ✅ Ошибка min: для строк min считает руны, «5» — один символ
- Ошибка required: строка с цифрой — нулевое значение
- Паника: min не работает со строками

min полиморфен: для чисел это значение, для строк — число рун. «5» — одна руна, один меньше пяти — правило падает. Граница на строке проверяется пятью символами: «abcde» ровно пять — проходит, «abcd» — нет. Такие кейсы и фиксирует таблица: вход, ожидаемый тег, одна строка — и полиморфный тег перестаёт быть сюрпризом на ревью.

### Граничные кейсы: граница, пустота, nil, dive

Таблица обязана содержать четыре рода кейсов. Первый — граница: min=5 проходит на значении ровно пять, границы включительные, и строка «на границе» отличает нестрогое правило от строгих gt и lt. Второй — пустота: пустая строка при required падает, строка из пробелов проходит — required проверяет не-нулевость, и эта нестрогость из урока про нормализацию заслуживает собственного кейса, потому что её иначе не увидишь. Третий — nil и omitempty: указатель nil под omitempty проходит все правила, omitempty глушит их на нулевом поле — кейс с nil фиксирует, что пустота легальна, а указатель на строку из ста одного символа падает на max; пара кейсов не даст снести omitempty при рефакторинге незаметно. Четвёртый — dive: срез тегов, где ломается второй элемент. Правила после dive исполняются по элементам слева направо, ошибки приезжают отдельными записями, и Field() вернёт имя с индексом — Tags[1], то есть запись относится именно ко второму элементу. Кейс, где первая строка валидна, а вторая — нет, ловит ровно поэлементность: без него тест не отличит проверку контейнера от проверки элементов — с этой путаницы начинался [восьмой урок](/courses/validator/lessons/08-srezy-i-dive/), и кейс с индексом — его прямое продолжение в тестах. Четыре рода кейсов — минимальный алфавит таблицы: правило, прошедшее только «счастливый путь», не тестировано вовсе.

### Что проверяем — как

| Что проверяем | Как | Что фиксирует тест |
| --- | --- | --- |
| тег обязательности | вход без значения → Field()/Tag() | required упал на нужном поле |
| граница длины | значения ниже, на и выше границы | min/max: на границе ошибки нет |
| кастомная функция | предикат напрямую + структура с тегом | логика bool и проводка тега раздельно |
| вложенность | кандидат с nil-веткой и заполненной | Namespace() называет сломанную ветку |
| элементы среза | провал во втором элементе | Field() вида Tags[1], не первой строки |
| успех | заполненный кандидат, пустая пара | err nil: правило не стреляет по валидному |

### Кастомные функции и StructLevel под тестом

Свой тег из [десятого урока](/courses/validator/lessons/10-kastomnye-funkcii/) тестируется в два слоя. Первый — предикат: чистая логика выносится из функции-обёртки в обычную функцию validSlug(s string) bool, и таблица гоняет её напрямую — без reflect, FieldLevel и валидатора; границы, пробелы, пустота — всё в маленькой таблице ожиданий. Второй — проводка: структура с тегом slug, validate.Struct, стандартная сверка Field() и Tag() — так проверяется, что регистрация случилась и правило подключено к полю. Ошибка «функция считает не то» и ошибка «тег не зарегистрирован» диагностируются раздельно, и каждая лечится в своём месте: первая — правкой логики, вторая — правкой регистраций. Тот же подход переносится на StructLevel из одиннадцатого урока: структурное правило тестируется через кандидата с расхождением и ожидание вашего тега в ValidationErrors — Field() вернёт имя поля из ReportError, и его стоит зафиксировать в таблице. Про пере-регистрацию держите в памяти контракт замены: повторная регистрация того же имени на общем экземпляре молча заменит правило для всего приложения — в тесте это способ тихо сломать соседние кейсы, а попытка занять служебное имя вроде required падает паникой ещё на регистрации.

### Два слоя теста своего тега

```go
func 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 и сравнивайте оба значения — иначе зелёный кейс означает лишь то, что структура чем-то плоха, а дефект ловит тот, кто следующим запустит сервис.

### Один экземпляр: тесты переиспользуют словарь

[Четырнадцатый урок](/courses/validator/lessons/14-odin-ekzemplar/) закрепил: экземпляр валидатора один на приложение, регистрации — до первых проверок. Для тестов из этого следуют три правила. Первое: тесты переиспользуют пакетный экземпляр или зовут один конструктор 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. Что должен ожидать тест?

- Ошибка max: nil-указатель нарушает максимум
- Ошибка required: поле пустое, обязательность нарушена
- ✅ err равен nil: omitempty глушит правила на нулевом поле
- InvalidValidationError: nil нельзя валидировать

nil для указателя — нулевое значение: omitempty пропускает все остальные правила тега, max не выполняется, err nil. Обратный кейс — указатель на строку из ста одного символа: omitempty не мешает, max падает. Пара кейсов фиксирует оба поведения.

### Достаньте пару из ValidationErrors

Четыре пропуска: тип среза ошибок, безопасное приведение и два метода чтения записи.

```go
var verrs validator.___
if !errors.___(err, &verrs) {
        t.Fatalf("не ValidationErrors: %v", err)
}
if got := verrs[0].___(); got != "Email" {
        t.Fatalf("поле: %s", got)
}
if got := verrs[0].___(); got != "email" {
        t.Fatalf("тег: %s", got)
}
```

Подсказка: Тип — validator.ValidationErrors; приведение через errors.As; имя поля — Field(), имя правила — Tag().

Правильные ответы в пропусках: `ValidationErrors`, `As`, `Field`, `Tag`

### Шаги одного кейса табличного теста

Разложите путь одного кейса с ожидаемой ошибкой от таблицы до вердикта.

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

1. `Таблица кейсов собрана: name, кандидат, wantField, wantTag`
2. `t.Run запускает кейс по имени`
3. `validate.Struct исполняет теги над кандидатом`
4. `Ошибка есть: errors.As проверяет тип ValidationErrors`
5. `verrs[0].Field() сравнивается с wantField`
6. `verrs[0].Tag() сравнивается с wantTag`
7. `Несовпадение — Fatalf с фактическими значениями`
8. `Ветка успеха живёт рядом: пустая пара требует err nil`

### Карточки: тесты правил

- **табличный кейс** — name + кандидат + wantField + wantTag. Пустая пара — контракт успеха: validate.Struct обязан вернуть nil. Новый тег — новая строка, а не новый файл.
- **что сравнивать** — Field() и Tag(), не текст сообщения: тексты меняются каталогом и локализацией, имена правил стабильны. Для алиасов Tag() вернёт имя алиаса, ActualTag() — исходное.
- **граница min** — Включительная: ровно пять при min=5 проходит. Кейс «на границе» отличает нестрогое правило от строгих gt и lt.
- **omitempty и nil** — nil-указатель проходит — omitempty глушит правила на нулевом поле; заполненное поле проверяется. Два кейса на пару защищают порядок в теге.
- **dive-кейс** — Провал во втором элементе: Field() вернёт имя с индексом вроде Tags[1]. Кейс с валидной первой строкой ловит именно поэлементность, а не проверку контейнера.
- **кастомный тег под тестом** — Два слоя: предикат напрямую на границах (чистый bool) и проводка через структуру с ожидаемым Tag(). Пере-регистрация на общем экземпляре молча заменяет правило.

> **Про песочницу.** testing — стандартная библиотека, но каркас урока в песочнице целиком не запустится: go test и внешний модуль validator остаются локальными. Соберите файл *_test.go рядом со структурами, подключите валидатор go get-ом и прогоните go test ./... — таблица из кейсов станет вашей. Лучшая проверка каркаса: сломайте один тег в структуре и убедитесь, что конкретный кейс поймал именно его, а не соседний.

Табличный каркас обслуживает все правила курса: теги, условные обязательности, структурные функции — везде одна и та же пара Field() и Tag(). Дальше по курсу — производительность валидации: что именно кэширует экземпляр и почему таблицы кейсов не платят за прогрев дважды. Механика кейса — имя, кандидат, ожидание — переносится на любые рантайм-контракты: от конфигов восемнадцатого урока до ответов внешних API, и везде каркас тот же, меняется только способ получения ошибки. Пока тег живёт без теста, он существует с точностью до первого рефакторинга: добавьте строку в таблицу — и правило станет незыблемым. Если таблица разрастается на одну структуру за пределы экрана — это не повод городить мега-файл, а сигнал: правило пересматривают в месте его объявления, а тест лишь фиксирует договор, который стороны не меняют без билета.

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

- Тег живёт в рантайме: компилятор строки тегов не читает, и правило без теста существует до первого рефакторинга.
- Кейс табличного теста: 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() внутри. Тест подставляет фиксированные моменты — в прошлом, на границе, в будущем — и не зависит от часов машины. Тот же двухслойный приём, что и у кастомного тега: математика отдельно, проводка отдельно, время — данные, а не скрытая зависимость. Так правило остаётся чистым предикатом, и заодно его можно звать из кода без валидатора, когда контекст уже знает момент.
