open atlas
↑ К треку
Go с нуля до senior GO · 04 · 01

Табличные тесты и сабтесты: кейсы как данные, t.Run и почему в Go нет assert-библиотеки

Табличные тесты делают кейсы данными: срез анонимных структур, один цикл, t.Run даёт строке имя — фильтруемое через -run и параллелизуемое. Падения — got/want с cmp.Diff; t.Helper держит трассы честными; golden-файлы кроют большие выводы. Assert в stdlib нет — намеренно.

GO Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

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

Вспомните перед уходом
  1. 01
    Опиши идиому табличного теста целиком: структура данных, цикл, что даёт t.Run и ловушка с t.Parallel до Go 1.22.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.