Программирование GoТестированиеGo-разработчик, отвечающий за модульное тестирование

В table driven тесте Go один кейс изменяет входной срез, из за чего результат следующего зависит от порядка...

В table-driven тесте Go один кейс изменяет входной срез, из-за чего результат следующего зависит от порядка запуска. Какой механизм создаёт эту связанность и как сделать кейсы независимыми?

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

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

Причина — несколько кейсов используют один backing array среза. Копирование значения среза копирует только его заголовок, поэтому изменения элементов могут быть видны через другие срезы. Чтобы сделать кейсы независимыми, создавайте отдельную копию данных для каждого запуска или не допускайте мутации входных данных.

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

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

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

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

Срез в Go — это дескриптор с указателем на массив, длиной и ёмкостью. Если несколько срезов ссылаются на один массив, запись через один из них изменяет данные, которые читают остальные.

Из-за этого тест может зависеть от порядка кейсов: отдельно каждый сценарий проходит, а вместе один изменяет вход для другого. При параллельном выполнении такая ошибка дополнительно может привести к гонке данных, но даже последовательный тест уже логически некорректен.

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

Копируйте содержимое среза перед передачей в код, который может его изменить. Например:

package example import "testing" func lowerInPlace(b []byte) []byte { for i := range b { if b[i] >= 'A' && b[i] <= 'Z' { b[i] += 'a' - 'A' } } return b } func TestLower(t *testing.T) { base := []byte("Go") cases := []struct{ name, want string }{{"first", "go"}, {"second", "go"}} for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { in := append([]byte(nil), base...) if got := string(lowerInPlace(in)); got != tc.want { t.Fatalf("got %q", got) } }) } }

Выражение 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 семантика функции осталась явно покрыта тестом.

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

  1. Достаточно ли присвоить срез в другую переменную, чтобы получить независимые данные?

Нет. Присваивание копирует дескриптор среза, но не его backing array. Поэтому запись элементов через одну переменную видна через другую. Для независимости нужно скопировать сами элементы в новый массив.

  1. Всегда ли append изменяет исходный массив среза?

Нет. Если ёмкости недостаточно, Go выделит новый массив; если ёмкость позволяет, новые элементы могут попасть в прежний массив. Поэтому поведение зависит от len и cap, а полагаться на случайное перевыделение нельзя. Для явного копирования используйте пустой целевой срез или заранее выделенный новый буфер.

  1. Устранит ли t.Parallel зависимость кейсов друг от друга?

Нет. t.Parallel меняет расписание выполнения, но не копирует входные данные и не устраняет общий backing array. При совместной записи он может сделать проблему более заметной через гонку данных, поэтому сначала нужно обеспечить независимость памяти, а уже затем решать, допустим ли параллельный запуск.