1. Зачем параллельные тесты
Если вы когда-нибудь смотрели на вывод go test и думали «ну да, тесты прошли… но почему это заняло минуту, если мой код — три функции и одна грустная структура?», то вы уже почти у цели. Тесты часто разрастаются быстрее, чем проект: добавили один пакет, второй, немного интеграционных проверок — и внезапно прогон занимает столько времени, что успеваешь сделать чай, забыть, что делал чай, и сделать второй чай.
Параллельные тесты в Go — это способ задействовать несколько CPU (и вообще возможности раннера), чтобы разные независимые проверки выполнялись одновременно. Если тесты правда независимы, вы выигрываете время почти «бесплатно». Но если тесты не независимы, то вы получаете редкую коллекцию багов: от «тесты влияют друг на друга» до «вчера было зелёным, сегодня красным, а завтра — философским».
Полезно сразу зафиксировать мысль: t.Parallel() — это не «ускоритель тестов», а «инструмент, который моментально проявляет скрытые зависимости». И в этом его настоящая ценность: он заставляет вас писать тесты так, чтобы им можно было доверять.
Как работает t.Parallel()
Когда впервые видишь t.Parallel(), хочется представить магию уровня «тест запускается в отдельной горутине и живёт своей жизнью». На практике механика немного хитрее, и из-за этого новички часто ставят t.Parallel() «куда-нибудь в середину», а потом удивляются поведению.
Идея такая: тест, который вызвал t.Parallel(), сообщает раннеру «я могу выполняться параллельно с другими такими же тестами». После этого раннер может приостановить его выполнение в текущий момент и продолжить позже, когда можно будет запустить несколько параллельных тестов одновременно. В случае субтестов (t.Run()) есть ещё один нюанс: параллельный субтест начнёт реально выполняться только тогда, когда родительский тест дойдёт до конца (то есть когда родитель «отпустит» управление).
Если хочется держать это в голове как мини‑схему, можно представить такую «линию времени»:
flowchart TD
A["TestParent старт"] --> B["t.Run('case1')"]
B --> C["case1 вызывает t.Parallel()\nи переходит в ожидание"]
C --> D["t.Run('case2')"]
D --> E["case2 вызывает t.Parallel()\nи переходит в ожидание"]
E --> F["TestParent заканчивается\n(родитель отпускает управление)"]
F --> G["case1 выполняется"]
F --> H["case2 выполняется"]
G -. параллельно .- H
Отсюда вытекает практическое правило: если вы решили сделать тест параллельным, пишите t.Parallel() как можно ближе к началу тела теста/субтеста. Не потому что «так принято», а потому что так проще понимать, какая часть теста относится к параллельной фазе, а какая — к подготовке.
3. Изоляция: параллельный тест должен быть «в вакуумной упаковке»
Когда мы говорим «изоляция тестов», звучит как что-то из лаборатории: белые халаты, перчатки, автоклав. На самом деле смысл бытовой: параллельные тесты не должны делить изменяемые ресурсы так, чтобы порядок выполнения менял результат.
Изменяемый ресурс — это не только глобальная переменная. Это ещё и общий файл, общий каталог, общий порт, общая переменная окружения, общий синглтон‑кеш, общая база данных, общий «фейковый сервер на 8080», общий генератор случайных чисел с одним seed’ом, общий map, общий slice, общий time.Now() без контроля — список бесконечен, как количество способов случайно сломать себе вечер.
Нормальная цель такая: каждый параллельный тест создаёт свои входные данные и свои временные ресурсы внутри себя. И если ему нужно что-то «снаружи», он получает это как dependency (параметром/конструктором), а не берёт из глобального состояния.
Изоляция файлов и каталогов: t.TempDir()
Параллельность чаще всего ломает тесты не на арифметике, а на «окружении». Например, два теста пишут в один и тот же файл tmp.txt, или оба создают каталог ./testdata/out, или оба используют один и тот же порт.
Правильная модель: каждый тест создаёт собственные временные ресурсы. В Go это обычно делается через t.TempDir(), а дальше внутри этого каталога можно создавать файлы, не боясь пересечения имён.
Пример с записью в файл внутри индивидуального temp‑каталога:
package task
import (
"os"
"path/filepath"
"testing"
)
func TestWriteTempFile_ParallelSafe(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
_ = os.WriteFile(path, []byte("ok"), 0600)
}
Смысл не в том, что этот тест что-то «глубоко проверяет». Смысл в дисциплине: t.TempDir() гарантирует, что у каждого теста — свой песочничный каталог, и параллельный запуск не превращает файловую систему в поле боя.
Переменные окружения и глобальные настройки
Переменные окружения (os.Setenv()) — это глобальное состояние процесса. Если один тест меняет APP_ENV, а другой тест рассчитывает на прежнее значение, у вас будет либо flaky, либо очень странный баг «падает только на Windows / только на CI / только по вторникам».
Если уж вам нужно менять env в тесте, делайте это так, чтобы изменения были локальными и откатывались. В современных Go версиях есть удобные подходы вроде t.Setenv() (если вы его используете), но даже без него принцип один: «поставил — гарантированно вернул обратно», и лучше не делать это в параллельных тестах вообще.
Здесь полезно мыслить так: параллельный тест должен работать, даже если рядом кто-то выполняется. А env — это «общая кухня», где вы внезапно переставили все кружки и ушли.
Где ставить t.Parallel() в субтестах
Частая ошибка новичка: поставить t.Parallel() после подготовки данных, потому что «подготовка же общая, пусть будет до». Иногда это правильно, но иногда — нет.
Проблема в том, что подготовка данных может трогать общий ресурс. Например, вы создали общий объект, а потом в параллельных субтестах его меняете. Или вы наполнили общий слайс, а потом в субтестах делаете append и удивляетесь aliasing’у (да, это из тех багов, которые умеют бегать стаями).
Правильная практика для table‑driven тестов обычно такая: в родителе вы описываете кейсы (это чистые данные), а в субтесте создаёте всё, что будет меняться, включая структуры, файлы и т.п. Поэтому t.Parallel() ставится в самом начале субтеста, сразу после входа, и дальше всё создаётся локально.
Если требуется «дорогая подготовка» (например, большой набор входных данных), её можно сделать один раз и потом использовать как read‑only. Но тогда нужно честно гарантировать, что это read‑only. Как только появится «ну мы тут один флажок поставим», вы вернётесь в мир flaky.
Почему «защитить всё мьютексом» — не универсальный ответ
Иногда после знакомства с гонками возникает рефлекс: «если проблема в параллельности, давайте добавим Mutex вокруг всего». Это может убрать data race, но не обязательно уберёт flaky‑поведение и не обязательно сделает тест хорошим.
Есть разница между «данные защищены от одновременного доступа» и «тесты независимы». Если два теста делят один и тот же ресурс, то даже при идеальной блокировке они могут влиять на порядок операций и итоговое состояние. У вас не будет гонки, но будет «тест A оставил после себя состояние, и тест B это увидел».
Поэтому мысль такая: Mutex — это инструмент корректности конкурентного кода, но изоляция тестов — это инструмент корректности проверки. Иногда они пересекаются, но не подменяют друг друга.
Мини-таблица: что обычно безопасно в параллельных тестах
Иногда проще один раз свериться глазами. Списками мы злоупотреблять не будем, поэтому сделаем компактную таблицу-напоминалку.
| Ситуация | В параллельном тесте это обычно… | Почему |
|---|---|---|
| Чистые функции (string -> string, расчёты) | Отлично | Нет I/O и общего состояния |
| Создание своих структур внутри теста | Отлично | Локальные данные |
| t.TempDir() и файлы внутри него | Отлично | Изоляция по каталогу |
| Глобальные переменные/синглтоны | Плохо | Тесты влияют друг на друга |
| os.Setenv() без отката | Плохо | Env глобален на процесс |
| Порты/сервер на фиксированном :8080 | Плохо | Конфликт ресурсов |
| time.Sleep() как “подождать пока” | Подозрительно | Тайминги не гарантированы |
4. Шаблон: t.Run() + t.Parallel() в table‑driven тестах
Теперь самое важное: как красиво соединить table‑driven тесты, субтесты и t.Parallel() так, чтобы оно работало не «везением», а по правилам.
Мини‑проект: доменный пакет task и «чистая» функция
Чтобы говорить не абстрактно, будем опираться на знакомый домен «задачи» (todo/task‑трекер), который мы много раз использовали в курсе. Сегодня нам важна не архитектура всего приложения, а то, чтобы была хотя бы одна чистая функция, которую приятно и безопасно тестировать параллельно.
Представим, что у нас есть функция нормализации заголовка задачи: убираем лишние пробелы, приводим строку к аккуратному виду. Это классический пример «чистой» логики: нет I/O, нет времени, нет глобальных переменных — значит, её можно гонять параллельно хоть сотней тестов.
package task
import "strings"
func NormalizeTitle(s string) string {
s = strings.TrimSpace(s)
s = strings.Join(strings.Fields(s), " ")
return s
}
Здесь нет никакого «общего ресурса». Функция получает строку и возвращает строку. Такая логика — идеальный кандидат для t.Parallel().
Параллельные кейсы для NormalizeTitle
Представим тест для NormalizeTitle. Мы хотим несколько кейсов, и хотим, чтобы кейсы выполнялись параллельно: каждый кейс независим, значит, это безопасно.
package task
import "testing"
func TestNormalizeTitle_Parallel(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"trim", " hello ", "hello"},
{"spaces", "a b", "a b"},
}
for _, tc := range cases {
tc := tc // фиксируем переменную цикла
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := NormalizeTitle(tc.in)
if got != tc.want {
t.Fatalf("got=%q, want=%q", got, tc.want)
}
})
}
}
Ключевая строка тут — tc := tc. Она выглядит как шутка («мы присвоили переменную самой себе, ха‑ха»), но это очень серьёзная штука. Мы создаём новую переменную tc в каждой итерации цикла, чтобы замыкание в t.Run() захватило правильное значение.
Почему это важно именно для параллельных субтестов? Потому что без фиксации переменной цикл продолжит крутиться, значение tc изменится, а субтест может стартовать позже и прочитать уже «не свой» кейс.
В Go 1.22+ типичная ловушка range‑переменной стала менее болезненной в некоторых сценариях, но надеяться на «оно само исправилось» — плохая стратегия, потому что остаются случаи с переиспользованием переменной (например, когда переменную объявляют вне цикла, или когда вы вручную пишете цикл и присваиваете в одну и ту же переменную). Дополнительная страховка в виде tc := tc стоит ровно одну строку и экономит часы расследований.
Отдельно замечу: инструменты тоже помогают ловить такие вещи. Например, в релизных заметках Go упоминается, что vet научился находить больше ошибок, связанных с переменными цикла в тестах, выполняемых параллельно.
Ещё один пример из task: валидация заголовка
Пусть у нас в домене есть простая проверка: заголовок задачи не должен быть пустым после нормализации. Это снова «чистая логика», которую приятно параллелить.
package task
import "errors"
var ErrEmptyTitle = errors.New("empty title")
func ValidateTitle(s string) error {
if NormalizeTitle(s) == "" {
return ErrEmptyTitle
}
return nil
}
И параллельные субтесты:
package task
import "testing"
func TestValidateTitle_ParallelCases(t *testing.T) {
cases := []struct {
name string
in string
want error
}{
{"ok", "buy milk", nil},
{"empty", " ", ErrEmptyTitle},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := ValidateTitle(tc.in)
if err != tc.want {
t.Fatalf("err=%v, want=%v", err, tc.want)
}
})
}
}
Заметьте, мы сравниваем ошибки как значения (для sentinel‑ошибки это нормально в таком простом случае). Важно другое: каждый кейс независим, нет общего состояния, и параллельность безопасна.
5. Flaky‑тесты и anti‑flaky подход
Flaky‑тест — это тест, который иногда проходит, а иногда падает без изменения кода. Самое неприятное тут даже не падение, а потеря доверия: когда тесты “шумят”, команда перестаёт воспринимать красный прогон как сигнал. А дальше происходит типичное: «ну, это CI опять шалит, нажми rerun» — и качество летит в пропасть с очень серьёзным лицом.
Параллельность — отличный усилитель flaky‑поведения, потому что она делает порядок выполнения менее предсказуемым. То, что в последовательном прогоне «случайно всегда успевало», в параллельном начинает проявлять гонки по ресурсам, проблемы синхронизации, зависимость от времени, внешних сервисов и окружения.
Особенно коварны тесты, которые зависят от времени. Исторически даже изменения точности time.Now() ломали тесты, которые сравнивали времена «в лоб» и ожидали точного совпадения — об этом есть хороший пример из практики Go‑команды: тест сохранял time.Now(), загружал обратно и сравнивал ==, а после изменения точности начал падать.
И это важная мораль: время и параллельность почти всегда ходят парой, чтобы устроить вам сюрприз.
Пример «как не надо»: параллельность + общий стейт
Чтобы понять anti‑flaky подход, полезно один раз увидеть «плохой» тест. Он может быть без гонки (то есть без явного data race), но при этом оставаться flaky, потому что тесты влияют друг на друга через общий ресурс.
Самый простой общий ресурс — глобальная переменная.
package task
import "testing"
var globalCounter int
func TestBad_GlobalState(t *testing.T) {
t.Parallel()
globalCounter++
if globalCounter == 0 {
t.Fatalf("impossible, but flaky things happen")
}
}
Этот пример выглядит глупо (и он глупый), но механика реальная. Если у вас в тестах есть хоть что-то уровня var shared = ... и параллельные тесты это трогают, вы почти гарантированно получите нестабильность. Даже если вы защитите globalCounter мьютексом, тесты всё равно будут зависеть друг от друга семантически: порядок выполнения влияет на финальное значение. Это уже не unit‑тест, а «социальная сеть тестов», где все всех знают и обсуждают.
Anti‑flaky подход: не «чинить Sleep’ом»
Очень хочется дать «простое правило» уровня «никогда не используйте time.Sleep()». В жизни иногда Sleep допустим, но в тестах он чаще всего означает, что вы угадываете тайминг, а не проверяете контракт. А угадывание тайминга — это идеальная формула flaky‑теста.
Anti‑flaky подход звучит так: тест должен опираться на детерминированные условия завершения, а не на надежду, что «за 50ms всё точно успеет».
Если тест проверяет конкурентный код, лучшее завершение — это сигнал: закрытие done‑канала, ожидание WaitGroup, отмена контекста, получение результата из канала. В более ранних лекциях вы уже видели паттерн «таймаут через канал/контекст», чтобы тест не завис навечно. Сегодня мы добавляем мысль: таймаут — это страховка от зависания, но основной механизм синхронизации — это сигнал завершения, а не сон.
6. Типичные ошибки при t.Parallel() и субтестах
Ошибка №1: t.Parallel() стоит в середине теста, и вы не понимаете, что именно выполняется параллельно.
Такой тест читать тяжело: сверху вроде бы подготовка, потом что-то важное, потом t.Parallel(), а потом ещё что-то. На практике лучше ставить t.Parallel() как можно раньше, сразу после входа в тест/субтест. Тогда параллельная фаза очевидна, и вы не случайно не вынесете в «подготовку» то, что должно быть локальным.
Ошибка №2: в for range с t.Run() не фиксируется переменная кейса.
Симптомы классические: в отчёте у субтестов имена разные, а фактически внутри они используют один и тот же кейс (часто последний). Лечится привычкой писать tc := tc перед t.Run(). Даже если вы слышали, что «в Go это исправляли», привычка всё равно полезна: она делает намерение явным и защищает от неочевидных сценариев переиспользования переменной. А инструменты уровня vet действительно уделяют внимание таким ошибкам, особенно в параллельных тестах.
Ошибка №3: параллельные тесты делят ресурсы — файлы, каталоги, env, порты.
Тест может быть корректным “в одиночку”, но падать при параллельном прогоне. Решение почти всегда одно и то же: каждый тест создаёт свои временные ресурсы (например, через t.TempDir()), не использует фиксированные пути, не меняет глобальные настройки процесса.
Ошибка №4: flaky чинят time.Sleep(), а не синхронизацией.
Поставить Sleep(50ms) и «починилось» — это иллюзия. На другом CPU, на загруженном CI, при другой версии раннера или при параллельном прогоне оно сломается снова. Anti‑flaky подход — это ждать событий завершения (done‑канал, WaitGroup, контекст), а таймаут использовать только как страховку от зависания.
Ошибка №5: тесты сравнивают “время в лоб” и начинают падать из-за точности/округления.
Время — не строка и не int, у него есть нюансы точности и представления. Известны реальные случаи, когда изменения точности time.Now() ломали тесты, которые ожидали точного равенства после сохранения/загрузки.
Лекарство обычно в том, чтобы сравнивать время с допуском, нормализовать точность (округлять/усекать) или вообще тестировать не “равно”, а “попадает в ожидаемый диапазон”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ