Модуль: Тестирование · Уровень: 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():

  1. Когда subtest вызывает t.Parallel(), он немедленно приостанавливается и возвращает управление родителю.
  2. Родительская функция дорабатывает свой код (цикл запускает все subtests, каждый паркуется на t.Parallel), затем родитель возвращается.
  3. Только после возврата функции-родителя рантайм возобновляет все припаркованные параллельные 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-шардинге).