В table-driven тесте Go один кейс изменяет входной срез, из-за чего результат следующего зависит от порядка запуска. Какой механизм создаёт эту связанность и как сделать кейсы независимыми?
Причина — несколько кейсов используют один backing array среза. Копирование значения среза копирует только его заголовок, поэтому изменения элементов могут быть видны через другие срезы. Чтобы сделать кейсы независимыми, создавайте отдельную копию данных для каждого запуска или не допускайте мутации входных данных.
Table-driven tests применяют в Go, чтобы описывать множество входов и ожидаемых результатов одной структурой и общим тестовым кодом. Такой подход уменьшает дублирование, но делает особенно важным контроль состояния между кейсами.
В обычном тесте каждый сценарий часто получает заново созданные данные. При переходе к таблице разработчик может вынести общий срез наружу и незаметно создать разделяемое изменяемое состояние.
Срез в Go — это дескриптор с указателем на массив, длиной и ёмкостью. Если несколько срезов ссылаются на один массив, запись через один из них изменяет данные, которые читают остальные.
Из-за этого тест может зависеть от порядка кейсов: отдельно каждый сценарий проходит, а вместе один изменяет вход для другого. При параллельном выполнении такая ошибка дополнительно может привести к гонке данных, но даже последовательный тест уже логически некорректен.
Копируйте содержимое среза перед передачей в код, который может его изменить. Например:
Выражение append([]byte(nil), base...) создаёт новый массив и копирует элементы. Важно, что t.Run сам по себе не копирует срезы и не изолирует память между subtests.
Одного присваивания вроде copy := base недостаточно: copy и base будут ссылаться на один backing array. Метод append также не даёт универсальной гарантии: если у целевого среза уже есть подходящая ёмкость, добавление может использовать существующий массив, поэтому для явной копии лучше использовать append([]byte(nil), src...) или make с последующим copy.
Лучший дизайн production-кода — по возможности не изменять входные срезы. Но если функция документированно работает in-place, тест обязан выдавать ей независимый буфер и отдельно проверять, какие изменения допустимы.
В тестах нормализатора несколько кейсов использовали общий шаблон байтов. Первый вызов преобразовывал его in-place, поэтому второй кейс получал уже нормализованные данные и проходил только при определённом порядке.
Рассматривались три варианта. Общий глобальный срез был самым коротким, но сохранял связанность. Запуск кейсов параллельно не решал проблему и мог добавить гонку. Создание копии перед каждым вызовом требовало небольшой дополнительной операции, зато давало предсказуемую изоляцию и позволяло безопасно расширять таблицу.
Выбрали копирование входа на границе теста. После этого каждый кейс стал независимым, порядок выполнения перестал влиять на результат, а намеренная in-place семантика функции осталась явно покрыта тестом.
Нет. Присваивание копирует дескриптор среза, но не его backing array. Поэтому запись элементов через одну переменную видна через другую. Для независимости нужно скопировать сами элементы в новый массив.
append изменяет исходный массив среза?Нет. Если ёмкости недостаточно, Go выделит новый массив; если ёмкость позволяет, новые элементы могут попасть в прежний массив. Поэтому поведение зависит от len и cap, а полагаться на случайное перевыделение нельзя. Для явного копирования используйте пустой целевой срез или заранее выделенный новый буфер.
t.Parallel зависимость кейсов друг от друга?Нет. t.Parallel меняет расписание выполнения, но не копирует входные данные и не устраняет общий backing array. При совместной записи он может сделать проблему более заметной через гонку данных, поэтому сначала нужно обеспечить независимость памяти, а уже затем решать, допустим ли параллельный запуск.