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

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

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

Урок 19 из 25 ~18 мин +20 XP

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

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

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

История одного рефакторинга: в 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 называет сценарий словами, и его цитируют в баг-репорте. Правило роста: новый тег — новая строка таблицы, а не новый тест-файл; правило поменяли — меняется ожидание в строке; правило снесли — таблица краснеет, и это ровно то, что нужно. И никакого переиспользования кандидатов: каждый кейс собирает свежую структуру, иначе независимые кейсы становятся зависимыми от порядка исполнения, а зависимость от порядка — самый дорогой баг тестового набора.

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

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

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

Сравнивайте 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 падает паникой ещё на регистрации.

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

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(). Ошибка «считает не то» и ошибка «тег не зарегистрирован» диагностируются раздельно: первый слой ловит математику, второй — регистрацию.

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

Четырнадцатый урок закрепил: экземпляр валидатора один на приложение, регистрации — до первых проверок. Для тестов из этого следуют три правила. Первое: тесты переиспользуют пакетный экземпляр или зовут один конструктор 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

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

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) }

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

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

Строки

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

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

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

Термин

табличный кейс

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

name + кандидат + wantField + wantTag. Пустая пара — контракт успеха: validate.Struct обязан вернуть nil. Новый тег — новая строка, а не новый файл.

1 / 6

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

Личные заметкиПусто
Хранятся только в вашем браузере (Validator · Тесты правил: таблицы кейсов)

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

Почему нельзя проверять только длину ValidationErrors?

Потому что len больше нуля — это «какая-то ошибка где-то»: тот же зелёный свет, что и проверка err != nil. При рефакторинге правила переезжают между полями, и счётчик может остаться прежним при полностью сломанном договоре: две записи вместо одной при том же len. Честная сверка — Field() и Tag() ожидаемых записей; если правило обязано дать ровно одну запись, проверьте и длину.

Можно ли опираться на порядок записей в ValidationErrors?

Записи собираются в порядке обхода полей, но полагаться на порядок между полями не стоит: изменение состава тегов меняет список. В каркасе индекс [0] живёт в простых кейсах с одним ожидаемым провалом; для структур с несколькими правилами ищите запись по Field() и сверяйте её Tag() — так тест переживает любые перестановки.

Как тестировать правило, зависящее от времени?

Выносите время в параметр: теговая функция зовёт предикат с аргументом, а не time.Now() внутри. Тест подставляет фиксированные моменты — в прошлом, на границе, в будущем — и не зависит от часов машины. Тот же двухслойный приём, что и у кастомного тега: математика отдельно, проводка отдельно, время — данные, а не скрытая зависимость. Так правило остаётся чистым предикатом, и заодно его можно звать из кода без валидатора, когда контекст уже знает момент.

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

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

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

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

Asynq в Go: очередь фоновых задач на Redis

Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.

  • Строить клиент и воркер asynq и подключать Redis
  • Отправлять отложенные, периодические и уникальные задачи
  • Настраивать ретраи, таймауты и приоритеты очередей
  • Мониторить задачи через Inspector и asynqmon