Модуль: Тестирование · Уровень: Middle+/Senior
TL;DR#
Table-driven тесты — идиоматичный для Go способ покрыть множество входов/выходов одной логикой проверки: срез структур-кейсов + цикл. t.Run создаёт subtests — изолированные узлы дерева тестов с собственным именем, фильтрацией (-run Foo/case_x) и независимым fail. t.Parallel() помечает тест как параллельный: он приостанавливается, пока не завершатся последовательные тесты родителя, затем запускается одновременно с другими параллельными. Главная историческая ловушка — захват переменной цикла замыканием (tc); в Go < 1.22 это давало все кейсы с последним значением. В 1.22+ переменная цикла per-iteration, ловушка ушла, но на собеседовании про неё всё равно спрашивают.
Теория#
Базовая форма#
func TestSplit(t *testing.T) {
tests := []struct {
name string
input string
sep string
want []string
}{
{name: "simple", input: "a,b,c", sep: ",", want: []string{"a", "b", "c"}},
{name: "no_sep", input: "abc", sep: ",", want: []string{"abc"}},
{name: "empty", input: "", sep: ",", want: []string{""}},
{name: "trailing", input: "a,", sep: ",", want: []string{"a", ""}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := strings.Split(tc.input, tc.sep)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Split(%q, %q) = %#v, want %#v", tc.input, tc.sep, got, tc.want)
}
})
}
}Ключевые элементы:
nameв каждом кейсе — без него subtests получают автогенерируемые имена#0,#1, и при падении непонятно, какой кейс упал. Имя с пробелами превращается в_в пути теста.- Карта vs срез. Срез сохраняет порядок и детерминирован.
map[string]struct{...}иногда используют, чтобы имя кейса было ключом, но порядок итерации рандомизирован — это плюс для выявления неявных зависимостей между кейсами, но минус для воспроизводимости вывода. Для большинства случаев берите срез.
Subtests: зачем t.Run#
t.Run(name, func(t *testing.T)) запускает вложенный тест синхронно (если внутри нет t.Parallel) и возвращает bool — прошёл ли. Что это даёт:
- Изоляция падений. Без subtests
t.Fatalв середине цикла прервёт весь тест и оставшиеся кейсы не выполнятся. Сt.Runпадение одного кейса не мешает остальным —t.Fatalfзавершает только горутину subtest. - Точечный запуск.
go test -run 'TestSplit/trailing'— регэксп по сегментам пути, разделённым/.-run 'TestSplit/^empty$'— точное совпадение. - Setup/teardown на группу. Можно вкладывать
t.Runвt.Runи делать общий setup на уровне группы.
func TestUserService(t *testing.T) {
db := setupDB(t)
t.Cleanup(func() { db.Close() }) // вызовется после ВСЕХ subtests
t.Run("create", func(t *testing.T) { /* ... */ })
t.Run("update", func(t *testing.T) { /* ... */ })
}t.Cleanup — предпочтительнее defer в setup-хелперах: он привязан к жизненному циклу *testing.T и корректно работает с subtests и параллельностью (LIFO порядок).
Параллельные тесты#
func TestParallel(t *testing.T) {
tests := []struct {
name string
in int
want int
}{
{"a", 1, 2},
{"b", 2, 4},
}
for _, tc := range tests {
tc := tc // НЕ нужно в Go 1.22+, обязательно в <1.22
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := double(tc.in)
if got != tc.want {
t.Errorf("double(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
}Как работает t.Parallel():
- Когда subtest вызывает
t.Parallel(), он немедленно приостанавливается и возвращает управление родителю. - Родительская функция дорабатывает свой код (цикл запускает все subtests, каждый паркуется на
t.Parallel), затем родитель возвращается. - Только после возврата функции-родителя рантайм возобновляет все припаркованные параллельные subtests одновременно.
Это объясняет классический баг: setup, размещённый ПОСЛЕ цикла в родителе, или ресурсы, которые родитель освобождает по defer, исчезнут к моменту реального запуска параллельных детей:
func TestBad(t *testing.T) {
srv := startServer()
defer srv.Close() // ВЫПОЛНИТСЯ до запуска параллельных subtests!
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// srv уже закрыт здесь
})
}
}Решение: t.Cleanup(srv.Close) вместо defer, либо обернуть параллельную группу во внешний t.Run, который не вернётся, пока дети не закончат.
Степень параллелизма регулируется -parallel N (по умолчанию GOMAXPROCS). Тесты из разных пакетов и так идут параллельно (-p N), t.Parallel — про параллельность внутри одного пакета.
Ловушка с замыканием (loop variable capture)#
До Go 1.22 переменная цикла for _, tc := range tests была одной на весь цикл. При t.Parallel тело subtest исполнялось ПОСЛЕ завершения цикла, когда tc уже держала последний элемент — все параллельные кейсы проверяли один и тот же (последний) вход. Лечилось tc := tc (shadowing) или tt := tc внутри цикла, либо передачей через параметр t.Run(tc.name, func(...) { run(t, tc) }).
В Go 1.22+ семантика изменилась: переменная цикла создаётся заново на каждой итерации (GOEXPERIMENT=loopvar стал дефолтом). Строчка tc := tc теперь no-op. Но: ловушка проявлялась только при t.Parallel или при сохранении замыканий за пределы итерации — в синхронном table-driven её не было и до 1.22.
На собеседовании ожидается, что вы:
- знаете, что ловушка была и почему (синхронный vs отложенный запуск),
- знаете, что 1.22 её устранил для loop variables,
- понимаете, что аналогичная проблема всё ещё есть с
deferв цикле, с захватом любых не-loop переменных, и сerrgroup/go func(){...}().
Подводные камни / gotchas#
deferпосле цикла в параллельных тестах освобождает ресурс раньше времени. Используйтеt.Cleanup.t.Fatalиз другой горутины не работает корректно —FailNowобязан вызываться из горутины самого теста. В параллельном/конкурентном коде собирайте ошибки и проверяйте в основной горутине теста, либо используйтеt.Error(он горутино-безопасен для пометки fail, но всё равно вызывать его из чужой горутины — ошибка).- Общее изменяемое состояние между параллельными кейсами (общая мапа, счётчик, глобал) → гонки. Запускайте
-race. - Пустое имя кейса или дубликаты имён. Дубликаты получают суффикс
#01. Делайте имена уникальными. - Имена с
/и пробелами ломают-runфильтрацию: пробел →_, а/интерпретируется как разделитель уровней. - Слишком толстый кейс. Когда в структуре кейса появляются поля-функции (
setup func(),assert func()), table-driven вырождается — проще написать отдельные тесты. Таблица хороша для гомогенной логики. reflect.DeepEqualна временах/флоатах/protobuf даёт ложные несовпадения. Используйтеcmp.Diffс опциями (cmpopts.EquateApprox,protocmp.Transform).
Вопросы на собеседовании#
В: В чём была ловушка с переменной цикла в параллельных table-driven тестах и как её чинили?
О: До Go 1.22 переменная tc была одна на весь цикл. t.Parallel() откладывает выполнение тела subtest до возврата родителя, к этому моменту tc держит последний элемент — все параллельные кейсы тестировали последний вход. Чинили локальной копией tc := tc или передачей значения параметром. В 1.22+ переменная цикла per-iteration, ловушка устранена.
В: Объясни порядок выполнения при t.Parallel().
О: Вызов t.Parallel() паркует subtest и возвращает управление родителю. Родитель доводит свою функцию до конца (запустив и припарковав всех детей) и возвращается. Только после возврата родителя рантайм одновременно возобновляет все параллельные subtests. Поэтому ресурсы, освобождённые через defer в родителе, к моменту запуска детей уже мертвы.
В: Когда использовать срез кейсов, а когда мапу? О: Срез — детерминированный порядок, читаемый вывод, дефолтный выбор. Мапа — рандомизированный порядок итерации, что помогает ловить скрытые зависимости между кейсами, и имя кейса становится ключом. Минус мапы — нестабильный порядок вывода и невозможность кейсов, зависящих от порядка.
В: Чем t.Cleanup лучше defer в тестах?
О: t.Cleanup привязан к жизненному циклу *testing.T/*testing.B, корректно отрабатывает с subtests и параллельностью (срабатывает после завершения всех зависимых параллельных детей), вызывается в LIFO. defer в родителе исполняется при возврате функции — то есть до запуска параллельных subtests.
В: Как точечно запустить один кейс table-driven теста?
О: go test -run 'TestName/case_name'. Аргумент — регэксп по сегментам пути теста, разделённым /. Для точного совпадения — якоря: -run 'TestName/^case_name$'. Пробелы в имени кейса заменяются на _.
В: Почему нельзя вызывать t.Fatal из горутины внутри теста?
О: t.FatalNow/t.Fatal вызывает runtime.Goexit на текущей горутине, помечая тест проваленным. Если вызвать из не-тестовой горутины, завершится только она, а тест продолжит выполнение с неопределённым состоянием. Документация требует вызывать FailNow-семейство только из горутины теста. Из других горутин — t.Error + синхронизация (через канал/WaitGroup) и проверка в основной горутине.
В: Когда table-driven перестаёт быть удачным выбором? О: Когда кейсы гетерогенны и в структуру лезут поля-функции setup/assert, ветвления по типу кейса — логика проверки перестаёт быть общей. Тогда читаемее отдельные именованные тесты или хелперы. Таблица оптимальна для одинаковой проверки разных данных.
На что копают на senior+#
- Понимание планировщика тестов: дерево T, как Parallel взаимодействует с Cleanup, почему cleanup параллельной группы ждёт детей.
- Воспроизводимость и флаки: детерминированность порядка, отсутствие общего состояния, обязательный
-raceна параллельных тестах в CI. - Сравнение значений: отказ от
reflect.DeepEqualв пользуgo-cmpс опциями, кастомныеEqualметоды, диффы вместо «got X want Y». - Генерация кейсов: комбинаторные таблицы, property-based на стыке с fuzzing, golden-кейсы.
- Масштаб: организация больших таблиц (вынос в
testdata, генерация), баланс между «одна таблица на всё» и читаемостью, именование как часть контракта (-runв CI-шардинге).