В проекте с параллельными subtests таблица кейсов обрабатывается в цикле: почему замыкание над переменной ц...

В проекте с параллельными subtests таблица кейсов обрабатывается в цикле: почему замыкание над переменной цикла может дать неверные результаты?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

В версиях Go до 1.22 переменная, объявленная через range, переиспользовалась на всех итерациях. Если subtest вызывает t.Parallel, его выполнение откладывается, а цикл успевает изменить переменную; поэтому несколько subtests могут прочитать одно и то же последнее значение.

Надёжное решение для старой семантики — создать копию переменной внутри итерации до передачи её в замыкание. В Go 1.22 и новее это поведение изменено для переменных цикла, объявленных через range, если версия языка в go.mod — 1.22 или выше, но явная копия может сохранить совместимость со старыми версиями.

Исторический контекст

Table-driven tests появились как практичный способ описывать множество входов, ожидаемых результатов и имён кейсов в одной структуре. Subtests позволяют запускать каждый такой кейс отдельно, получать точное имя ошибки и при необходимости выполнять независимые кейсы параллельно.

Сочетание цикла, замыканий и отложенного выполнения создало типичную ошибку времени жизни переменной. Проблема особенно заметна именно в тестах, потому что t.Parallel меняет момент фактического выполнения тела subtest.

Постановка проблемы

Цикл последовательно присваивает переменной текущий кейс и создаёт функцию для subtest. Эта функция захватывает не снимок значения, а переменную, поэтому при старой семантике все функции могут ссылаться на одну и ту же область памяти.

После вызова t.Parallel subtest обычно приостанавливается, пока родительский тест продолжает последовательную часть. К моменту возобновления цикла переменная может уже содержать последний кейс. В результате тест проверяет не тот набор данных, а иногда несколько subtests проверяют один и тот же кейс и ошибочно проходят.

Подробное решение

Замыкание захватывает переменную, а не её значение на момент создания функции. Поэтому нужно в каждой итерации получить отдельную переменную с текущим значением и захватывать уже её:

func TestAdd(t *testing.T) { cases := []struct { name string a, b, want int }{ {"one", 1, 2, 3}, {"zero", 0, 0, 0}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if tc.a+tc.b != tc.want { t.Fatalf("got %d, want %d", tc.a+tc.b, tc.want) } }) } }

Строка tc := tc создаёт новую переменную на каждой итерации. Замыкание конкретного subtest захватывает именно её, поэтому отложенное выполнение больше не меняет входные данные.

В Go 1.22 переменные цикла, объявленные самим оператором range, получают отдельную область видимости для каждой итерации, когда это разрешено версией языка модуля. Однако переменная, объявленная заранее и затем присваиваемая в цикле, по-прежнему может быть общей; кроме того, копирование элемента не делает глубокую копию вложенных срезов, карт или указателей.

Копия переменной устраняет ошибку захвата, но не делает тест полностью независимым. Общие глобальные данные, изменяемые фикстуры, элементы таблицы с указателями и внешние ресурсы всё ещё могут привести к гонкам или взаимному влиянию subtests.

Ситуация из практики

В сервисе около ста table-driven subtests проверяли преобразование сообщений. После добавления t.Parallel несколько тестов начали нестабильно падать: имена subtests были разными, но значения входных сообщений совпадали с последним элементом таблицы.

Рассматривались три варианта. Удалить t.Parallel было проще всего, но это скрывало ошибку захвата и увеличивало время тестов. Запускать каждый кейс в отдельном явно написанном тесте устраняло проблему, но резко увеличивало дублирование. Явная копия переменной в цикле сохранила структуру таблицы и параллельность.

Выбрали третий вариант и отдельно проверили, что subtests не изменяют общую фикстуру. После исправления каждый кейс получил собственные входные данные, нестабильные падения исчезли, а время набора тестов сократилось без потери изоляции.

Что кандидаты часто упускают

  1. Всегда ли t.Parallel запускает subtest немедленно?

Нет. Вызов t.Parallel отмечает тест как параллельный и приостанавливает его выполнение. Родительский тест продолжает свою последовательную часть, включая дальнейшие итерации цикла; фактическое выполнение subtest возобновляется позже в соответствии с правилами планировщика тестов. Поэтому полагаться на порядок выполнения параллельных subtests нельзя.

  1. Достаточно ли скопировать переменную цикла для полной изоляции тестов?

Нет. Копируется только значение переменной. Если это структура с указателем, срезом или картой, внутреннее содержимое может остаться общим. Для изоляции нужно отдельно копировать изменяемые вложенные данные либо строить независимую фикстуру для каждого subtest.

  1. Может ли исправленный захват всё равно привести к гонке данных?

Да. Корректная копия переменной устраняет одну логическую ошибку, но не синхронизирует доступ к общей памяти. Если параллельные subtests изменяют общий кэш, глобальную переменную или один объект-заглушку, нужна синхронизация либо раздельные экземпляры ресурсов; проверка через race detector помогает обнаружить такие конфликты.