6. Параллельные тесты t.Parallel
Медленный тестовый сьют — это скрытый и безжалостный налог на продуктивность инженерной команды. Если сборка и тестирование в CI/CD занимают пятнадцать-двадцать минут, разработчики перестают прогонять тесты локально, теряют фокус и отправляют изменения наугад. В современных микросервисах значительная часть сценариев упирается во взаимодействие с базами данных, внешними API и сетевыми сокетами — тесты становятся выраженно I/O-bound. Заставлять современный многоядерный процессор простаивать в ожидании ответов от сетевого стека — непозволительная роскошь.
В стандартной библиотеке Go параллелизм тестов реализован на уровне ядра тулчейна. Вызов метода t.Parallel() способен сократить время прогона тестового набора в разы. Однако многопоточная среда не прощает ошибок: она мгновенно обнажает любые дефекты изоляции, разделяемого состояния и координации ресурсов.
В этой главе мы препарируем устройство метода t.Parallel(), разберем двухфазную диспетчеризацию рантайма и изучим правила безопасного параллельного исполнения тестов.
Как работает t.Parallel() под капотом
Распространенное заблуждение среди новичков: вызов t.Parallel() якобы просто запускает новую горутину и немедленно продолжает выполнение сценария. В реальности всё устроено значительно строже. Движок пакета testing четко разделяет процесс тестирования пакета на две изолированные фазы: Последовательную (Sequential Phase) и Конкурентную (Parallel Phase).
flowchart TD
Start["Запуск go test в пакете"] --> SeqPhase["Фаза 1: Последовательное исполнение"]
SeqPhase --> T1["TestA (Обычный тест)<br/>Выполняется немедленно"]
SeqPhase --> T2["TestB (вызывает t.Parallel)<br/>Остановка через runtime.Goexit()"]
SeqPhase --> T3["TestC (вызывает t.Parallel)<br/>Остановка через runtime.Goexit()"]
SeqPhase --> T4["TestD (Обычный тест)<br/>Выполняется немедленно"]
T2 -->|"Парковка горутины"| Queue["Очередь отложенных параллельных тестов"]
T3 -->|"Парковка горутины"| Queue
T1 --> Wait["Ожидание завершения<br/>всех последовательных сценариев"]
T4 --> Wait
Wait -->|"Все последовательные тесты завершены"| ParPhase["Фаза 2: Конкурентное исполнение<br/>Лимит одновременных: GOMAXPROCS"]
Queue --> ParPhase
ParPhase --> P_T2["TestB: Возобновление горутины"]
ParPhase --> P_T3["TestC: Возобновление горутины"]
classDef phase fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#38bdf8;
classDef test fill:#1e293b,stroke:#475569,stroke-width:1px,color:#e2e8f0;
classDef queue fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#fbbf24;
class SeqPhase,ParPhase phase;
class T1,T4,P_T2,P_T3 test;
class Queue queue;
Анатомия двухфазного прогона:
- Маркировка и парковка: Когда внутри теста вызывается
t.Parallel(), рантайм Go вызывает низкоуровневую функциюruntime.Goexit(). Горутина текущего теста немедленно замораживается и помещается в очередь ожидания параллельных тестов. - Последовательный прогон: Диспетчер тестов переходит к следующему тесту в пакете. Все тесты без вызова
t.Parallel()выполняются строго по очереди. - Активация параллельного пула: Только когда все последовательные тесты пакета успешно или аварийно завершились, раннер открывает шлюз для очереди параллельных тестов.
- Конкурентное пробуждение: Отложенные тесты пробуждаются и начинают выполняться конкурентно в пуле горутин.
Правильное использование с t.Run (Subtests)
Самый популярный сценарий применения t.Parallel() — распараллеливание строк в табличных тестах (Table-Driven Tests). Здесь критически важно понимать иерархию вызовов:
func TestProcessor(t *testing.T) {
// 1. Делаем родительский тест параллельным по отношению к другим тестам пакета
t.Parallel()
tests := []struct {
name string
input string
}{
{"case 1", "data 1"},
{"case 2", "data 2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 2. Делаем подтесты параллельными между собой!
t.Parallel()
process(tt.input)
})
}
}
Механическое сочувствие (Mechanical Sympathy) иерархии:
Если вы не вызовете t.Parallel() на уровне родительской функции TestProcessor (строка 1), родительский тест будет выполняться в последовательной фазе. Дойдя до цикла for, он зарегистрирует все параллельные подтесты и заблокируется, ожидая их полного окончания.
Хотя подтесты "case 1" и "case 2" будут работать конкурентно между собой, следующий независимый тест в пакете не начнется, пока TestProcessor целиком не завершит работу. Объявление родителя параллельным позволяет раннеру отложить весь тестовый узел целиком, не задерживая последовательный пайплайн.
Ловушки конкурентности
Как только тесты начинают выполняться одновременно, любые изъяны архитектуры мгновенно превращаются в гонки данных (Data Races).
1. Переменная цикла (Loop Closure Bug)
Классическая ловушка замкнутой переменной цикла в кодовых базах, написанных до версии Go 1.22:
2. t.Setenv и глобальное состояние среды
Тестирование парсеров конфигурации часто требует изменения переменных окружения процесса. Стандартный метод t.Setenv() меняет переменную и гарантирует ее возврат в исходное состояние после теста.
Однако переменные среды в ОС — это единая глобальная таблица дескрипторов (os.Environ).
3. Общие I/O ресурсы и порты
Если параллельные интеграционные тесты поднимают локальный сетевой слушатель, использование фиксированного порта вроде 8080 приведет к гарантированному падению второго теста с ошибкой ядра:
bind: address already in use
Всегда используйте динамические эфемерные порты (:0), позволяя операционной системе назначить свободный сокет. Аналогично, при работе с диском используйте метод t.TempDir(), выделяющий каждому тесту изолированную временную песочницу на файловой системе.
Teardown: defer vs t.Cleanup в параллельной среде
Это главная причина утечек дескрипторов соединений и внезапных падений в CI-пайплайнах:
func TestWithDB(t *testing.T) {
db := startTestDB()
defer db.Close() // АРХИТЕКТУРНЫЙ КРАХ!
t.Run("parallel query 1", func(t *testing.T) {
t.Parallel()
db.Query("SELECT 1")
})
t.Run("parallel query 2", func(t *testing.T) {
t.Parallel()
db.Query("SELECT 2")
})
}
Механика сбоя:
- Вызовы
t.Parallel()внутри подтестов немедленно замораживают их горутины черезruntime.Goexit(). - Функция
t.Runвозвращает управление родителюTestWithDB. - Поскольку все дочерние тесты отложены, функция
TestWithDBдостигает своего конца и завершается. - При выходе из стека срабатывает
defer db.Close(). База данных закрыта! - Позже, в конкурентной фазе, планировщик будит подтесты, они вызывают
db.Queryв закрытый пул и падают с ошибкойsql: database is closed.
Идиоматичное решение через t.Cleanup:
func TestWithDB(t *testing.T) {
db := startTestDB()
// t.Cleanup гарантированно вызывается ТОЛЬКО после того,
// как завершатся ВСЕ параллельные подтесты
t.Cleanup(func() {
_ = db.Close()
})
t.Run("parallel query 1", func(t *testing.T) {
t.Parallel()
db.Query("SELECT 1")
})
}
Итог
t.Parallel()разделяет прогон на последовательную и параллельную фазы, паркуя горутины вызовомruntime.Goexit().- Лимит одновременно работающих тестов регулируется переменной
GOMAXPROCSи флагом-parallel. - В табличных тестах на версиях Go до 1.22 обязателен шэдоуинг переменной цикла (
tt := tt). - Параллельные тесты несовместимы с мутацией глобального состояния процесса (
t.Setenv, статические порты8080). - В тестах с параллельными подтестами освобождение ресурсов родителя выполняется исключительно через
t.Cleanup, а неdefer.
По мере масштабирования кодовой базы ваши тесты обрастут вспомогательными функциями для генерации сущностей и верификации данных. Как научить тулчейн правильно отображать стек вызовов при падении внутри таких утилит? Об этом пойдет речь в следующей главе: 7. Helper функции и t.Helper.