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;
  

Анатомия двухфазного прогона:

  1. Маркировка и парковка: Когда внутри теста вызывается t.Parallel(), рантайм Go вызывает низкоуровневую функцию runtime.Goexit(). Горутина текущего теста немедленно замораживается и помещается в очередь ожидания параллельных тестов.
  2. Последовательный прогон: Диспетчер тестов переходит к следующему тесту в пакете. Все тесты без вызова t.Parallel() выполняются строго по очереди.
  3. Активация параллельного пула: Только когда все последовательные тесты пакета успешно или аварийно завершились, раннер открывает шлюз для очереди параллельных тестов.
  4. Конкурентное пробуждение: Отложенные тесты пробуждаются и начинают выполняться конкурентно в пуле горутин.

Правильное использование с t.Run (Subtests)

Самый популярный сценарий применения t.Parallel() — распараллеливание строк в табличных тестах (Table-Driven Tests). Здесь критически важно понимать иерархию вызовов:

Go
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 приведет к гарантированному падению второго теста с ошибкой ядра:

Text
bind: address already in use

Всегда используйте динамические эфемерные порты (:0), позволяя операционной системе назначить свободный сокет. Аналогично, при работе с диском используйте метод t.TempDir(), выделяющий каждому тесту изолированную временную песочницу на файловой системе.


Teardown: defer vs t.Cleanup в параллельной среде

Это главная причина утечек дескрипторов соединений и внезапных падений в CI-пайплайнах:

Go
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")
    })
}

Механика сбоя:

  1. Вызовы t.Parallel() внутри подтестов немедленно замораживают их горутины через runtime.Goexit().
  2. Функция t.Run возвращает управление родителю TestWithDB.
  3. Поскольку все дочерние тесты отложены, функция TestWithDB достигает своего конца и завершается.
  4. При выходе из стека срабатывает defer db.Close(). База данных закрыта!
  5. Позже, в конкурентной фазе, планировщик будит подтесты, они вызывают db.Query в закрытый пул и падают с ошибкой sql: database is closed.

Идиоматичное решение через t.Cleanup:

Go
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")
    })
}

Итог

  1. t.Parallel() разделяет прогон на последовательную и параллельную фазы, паркуя горутины вызовом runtime.Goexit().
  2. Лимит одновременно работающих тестов регулируется переменной GOMAXPROCS и флагом -parallel.
  3. В табличных тестах на версиях Go до 1.22 обязателен шэдоуинг переменной цикла (tt := tt).
  4. Параллельные тесты несовместимы с мутацией глобального состояния процесса (t.Setenv, статические порты 8080).
  5. В тестах с параллельными подтестами освобождение ресурсов родителя выполняется исключительно через t.Cleanup, а не defer.

По мере масштабирования кодовой базы ваши тесты обрастут вспомогательными функциями для генерации сущностей и верификации данных. Как научить тулчейн правильно отображать стек вызовов при падении внутри таких утилит? Об этом пойдет речь в следующей главе: 7. Helper функции и t.Helper.