Табличные тесты и сабтесты: кейсы как данные, t.Run и почему в Go нет assert-библиотеки
Табличные тесты делают кейсы данными: срез анонимных структур, один цикл, t.Run даёт строке имя — фильтруемое через -run и параллелизуемое. Падения — got/want с cmp.Diff; t.Helper держит трассы честными; golden-файлы кроют большие выводы. Assert в stdlib нет — намеренно.
В баг-репорте значилось: счета одного мерчанта расходятся ровно на один цент. У функции округления было девятнадцать тестов — девятнадцать отдельных функций TestRoundUp, TestRoundDown, TestRoundHalfEven2, копипастившихся два года, каждая со своим сетапом и своим чуть-чуть иным сообщением об ошибке. Никто не мог сказать, какие случаи на самом деле покрыты, поэтому никто и не заметил тот, что не покрыт. Переписанная в одну таблицу, вся спецификация функции уместилась на один экран: 23 строки — имя, вход, ожидание. Недостающая строка стала видна за минуты — отрицательные суммы при округлении half-even — и фикс уехал вместе со строкой 24 как постоянный регрессионный тест. Дифф, закрывший инцидент, удалил 340 строк тестового кода и добавил 60. Табличные тесты — не вопрос стиля: они превращают покрытие из того, что приходится грепать, в то, что можно прочитать.
К концу урока вы поймёте, почему пропущенный кейс был невидим в 340 строках кода — и как сделать такие пробелы физически невозможными.
Кейсы как данные: таблица из анонимных структур
Идиома — срез анонимных структур: каждая строка — один случай, каждое поле — одно измерение контракта, и единственный цикл, прогоняющий настоящую логику:
func TestParseSize(t *testing.T) {
tests := []struct {
name string
in string
want int64
wantErr bool
}{
{name: "plain bytes", in: "512", want: 512},
{name: "kilobytes suffix", in: "4K", want: 4096},
{name: "rejects negative", in: "-1K", wantErr: true},
{name: "rejects empty", in: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseSize(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseSize(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}Выигрыш структурный. Добавить случай — одна строка, поэтому граничные случаи действительно добавляют: предельная стоимость теста номер 24 почти нулевая, тогда как копипастная функция стоит пятнадцать строк и имя. Меняется и ревью: PR, трогающий поведение, виден как изменённые строки таблицы, и ревьюер сканирует её на предмет недостающего случая, а не диффает прозу. Острый край один: если строки делят изменяемое состояние (пакетная фикстура, переиспользуемый буфер), порядок прячет связанность. Некоторые команды берут map[string]testCase именно потому, что Go рандомизирует обход map — межкейсовые зависимости тогда падают громко, а не тихо проходят в порядке среза.
Сабтесты: адресуемые, фильтруемые, параллельные
t.Run делает каждую строку полноценным сабтестом: он выполняется в своей горутине, падает независимо (t.Fatal убивает только эту строку) и получает адресуемое имя — пробелы заменяются подчёркиваниями, так что упавший случай выше достижим напрямую:
// Перезапустить ровно одну строку, не трогая код:
// go test -run 'TestParseSize/rejects_negative'
// Всё под одним тестом:
// go test -run 'TestParseSize' -vВ реальных сьютах важны две доводки. Первая — t.Helper(): вызовите его в начале любого общего хелпера-проверки, и падение будет привязано к строке вызывающего, а не внутри хелпера; без него тридцать падений указывают на helpers.go:12, и отчёт бесполезен. Вторая — t.Parallel() внутри замыкания сабтеста распараллеливает строки: родительский тест возвращается, припаркованные сабтесты возобновляются вместе, а -parallel ограничивает конкурентность. Историческая ловушка: до Go 1.22 переменная цикла tt была общей на весь цикл, так что все параллельные сабтесты захватывали одну переменную и проверяли последнюю строку — сьюты годами были зелёными, гоняя один случай N раз. Go 1.22 сделал переменные цикла per-iteration и закрыл класс багов, но в старых кодовых базах вы ещё встретите строки-тени tt := tt — это окаменелости той ловушки, а не карго-культ.
Кодовая база на Go 1.21 гоняет таблицу из 12 строк с t.Parallel() внутри каждого замыкания t.Run, захватывая переменную цикла tt напрямую. Что на самом деле тестировал сьют?
got/want, cmp.Diff и golden-файлы — позиция без assert
Прежде чем тянуться к сторонней assertion-библиотеке, спросите себя: какое сообщение об ошибке вы прочтёте в два часа ночи, когда CI красный и никакого контекста нет? Этот вопрос и объясняет, почему Go не поставляет assert.Equal (библиотеку для проверок равенства в тестах).
В stdlib нет assert.Equal — сознательно. Позиция Go FAQ: assert-DSL уводят поток управления во фреймворк, поощряют тесты, умирающие на первой проверке, и выдают сообщения на языке фреймворка, а не на вашем. Тест — обычная программа на Go: if got != want плюс t.Errorf с обоими значениями. Масштабируется это за счёт дисциплины формата — f(input) = got, want want, — чтобы строка лога CI диагностировалась без открытия файла. Для структур, срезов и map ручное сравнение тонет; де-факто стандарт — go-cmp:
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("ParseConfig() mismatch (-want +got):\n%s", diff)
}cmp.Diff печатает минимальный построчный дифф только различающихся полей — на структуре в 40 полей это разница между полезным падением и двумя стенами %+v. По умолчанию он паникует на неэкспортируемых полях (это фича: вынуждает объявить семантику сравнения через cmpopts.IgnoreUnexported или метод Equal). Когда велик сам вывод — отрендеренные шаблоны, сгенерированный SQL, маршалённый JSON — табличный паттерн расширяется golden-файлами: ожидаемые байты лежат в testdata/case.golden, тест сравнивает с ними, а перегенерация идёт через флаг -update, читаемый через flag.Bool. Дифф golden-файла попадает в код-ревью как настоящее изменение файла — в этом и смысл: новый ожидаемый вывод утверждает человек.
Общий хелпер func assertStatus(t *testing.T, got, want int) падает в CI, и все 30 падений указывают на assert.go:12. Чего не хватает?
- 01Опиши идиому табличного теста целиком: структура данных, цикл, что даёт t.Run и ловушка с t.Parallel до Go 1.22.
- 02Почему stdlib Go не поставляет assert-библиотеку и что её заменяет в масштабе — включая большие структуры и большие выводы?
Идиома табличного теста — ответ Go на читаемое покрытие: срез анонимных структур, где каждая строка — именованный случай, и один цикл, прогоняющий строки через настоящий код внутри t.Run. Сабтесты адресуемы — go test -run ‘TestParseSize/rejects_negative’ перезапускает одну строку, — падают независимо, а t.Parallel в замыкании их параллелит; историческая оговорка: до Go 1.22 общая переменная цикла заставляла все параллельные сабтесты видеть последнюю строку, per-iteration переменные 1.22 закрыли этот класс багов, а случайные tt := tt — его окаменелости. Падения остаются обычным Go: сравнения got/want с сообщениями вида ParseSize(input) = got, want want, хелперы с t.Helper, чтобы отчёт указывал на упавшую строку, а не на helpers.go:12. Когда значения перерастают !=, cmp.Diff рендерит минимальный пополевой дифф и намеренно отказывается от неэкспортируемых полей, пока не задана семантика сравнения; когда выводы перерастают таблицу, golden-файлы в testdata несут ожидаемые байты, флаг -update их перегенерирует, и изменение ожидаемого вывода превращается в ревьюируемый дифф. Отсутствующая assert-библиотека — философия в миниатюре: тест остаётся обычной программой, чьими сообщениями об ошибках владеете вы, а таблица делает дыру в контракте видимой ревьюеру с одного взгляда. Теперь, когда вы встретите файл с девятнадцатью отдельными функциями TestXxx, вы будете точно знать, что с этим делать — и как превратить покрытие из того, что грепают, в то, что читают.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.