Содержание урока · 10 разделов
- t.Parallel() и цена контейнера
- Изоляция данных: схема на тест
- Масштаб пакета → подход к контейнерам
- Общий контейнер против контейнера на тест
- ParallelContainers: штатный запуск пачкой
- Лимиты машины: память, порты, сессии
- Типовые ошибки параллельных прогонов
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Контейнер — не бесплатный объект: под ним образ на сотни мегабайт, процессы, файловые дескрипторы и порт. Пока go test исполняет тесты пакета по одному, цена размазывается во времени: один контейнер живёт, отработал и умер. t.Parallel() ломает эту последовательность: помеченные тесты стартуют одновременно, и их контейнеры живут вместе. Суммарная память растёт линейно от числа одновременных тестов, а вместе с ней растёт и нагрузка на демон Docker: каждый create — это обращение к кэшу образов, создание сетей и ожидание готовности. Второй уровень параллельности — пакеты: go test ./... запускает каждый пакет отдельным процессом, и одновременно живёт уже несколько процессов со своими контейнерами. Есть и скрытая цена последовательности: тесты, идущие по одному, делят всё и не замечают этого — гонки на данных проявляются ровно в тот день, когда пакет становится параллельным. Маршрут контейнера от декларации до Terminate разобран в уроке про жизненный цикл — здесь он остаётся тем же, меняется только плотность: сколько таких маршрутов живёт одновременно.
t.Parallel() и цена контейнера#
t.Parallel() — маркер в теле теста: он говорит фреймворку, что тест можно приостановить и досчитать вместе с другими параллельными. Механика такая: параллельные тесты доходят до маркера и засыпают; после завершения всех последовательных тестов пакета они просыпаются и идут толпой, ограниченной флагом -parallel — по умолчанию он привязан к GOMAXPROCS машины. В выводе go test такие тесты помечены словом PAR, и по этой пометке удобно проверять, что параллельность вообще случилась. Каждый проснувшийся тест со своим контейнером получает честную изоляцию: свой Postgres, свои таблицы, свой Redis, а Docker раздаёт каждому случайный свободный порт хоста через MappedPort — конфликтов портов не будет, если не фиксировать их руками. Цена — память и старт: восемь одновременных Postgres — это восемь буферных пулов и восемь ожиданий готовности. Когда сценарий «параллельные тесты go» становится повседневностью, решение принимают по формуле: лёгкий образ — контейнер на тест без раздумий, тяжёлый — сначала попробуйте изоляцию данных на общем инстансе.
Контейнер на тест: хелпер для параллельных тестов
GOfunc startRedis(t *testing.T) string {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForListeningPort("6379/tcp"),
},
Started: true,
})
if err != nil {
t.Fatalf("redis: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
host, _ := ctr.Host(ctx)
port, _ := ctr.MappedPort(ctx, "6379/tcp")
return net.JoinHostPort(host, port.Port())
}
func TestCacheHit(t *testing.T) {
t.Parallel()
addr := startRedis(t)
// ... клиент redis на addr
}
func TestCacheMiss(t *testing.T) {
t.Parallel()
addr := startRedis(t)
// ... свой redis, свои данные, ноль связности
}Хелпер startRedis поднимает собственный контейнер на каждый тест: t.Parallel() не мешает — Docker выделяет каждому свой маппнутый порт, поэтому два Redis живут на разных адресах. Terminate зарегистрирован в t.Cleanup и сработает при любом исходе теста. Пометка t.Helper() уводит строки падений на тест, а не на вспомогательную функцию. Изоляция полная: свой контейнер, свои данные, ноль синхронизации между тестами.
Изоляция данных: схема на тест#
Общий контейнер стоит дешевле десятка: один старт, одна порция памяти, одно ожидание готовности. Но данные придётся развести руками, и у каждого хранилища свой механизм. В Postgres тест заводит себе схему: CREATE SCHEMA с именем от t.Name() даёт уникальное пространство имён — таблицы одного теста не видны соседям, миграции применяются внутри схемы, строки не пересекаются. В MongoDB изоляция достигается базой на тест: имя базы и коллекций строится от имени теста, дроп в Cleanup. В Redis — префикс ключей: тест пишет и читает только под своим префиксом. В Kafka и RabbitMQ — уникальные топики, обменники и очереди: имена от t.Name() или со случайным суффиксом. Бонус такого именования — отладка: по docker ps и по списку схем или топиков сразу видно, чей ресурс остался висеть после упавшего прогона. Общий принцип один: имя ресурса строится из идентификатора теста, и тогда два параллельных теста физически не могут наступить на один и тот же кусок данных.
Схема на тест на общем Postgres
GOfunc newSchema(t *testing.T, ctr testcontainers.Container) string {
t.Helper()
ctx := context.Background()
schema := "t_" + strings.NewReplacer("/", "_").Replace(t.Name())
_, _, err := ctr.Exec(ctx, []string{
"psql", "-U", "app", "-d", "appdb",
"-c", "CREATE SCHEMA " + schema,
})
if err != nil {
t.Fatalf("схема: %v", err)
}
t.Cleanup(func() {
ctr.Exec(ctx, []string{"psql", "-U", "app", "-c", "DROP SCHEMA " + schema + " CASCADE"})
})
return schema
}Хелпер строит имя схемы из t.Name(): слэши подтестов заменяются на подчёркивания, префикс t_ отделяет тестовые схемы от служебных. Exec выполняет psql прямо в контейнере: создание до работы теста, drop cascade в Cleanup — схема исчезает вместе с таблицами даже при падении теста. Возвращённое имя тест подставляет в search_path своего соединения, и все запросы идут в изолированное пространство имён.
Масштаб пакета → подход к контейнерам#
| Масштаб пакета | Подход | Что получаем |
|---|---|---|
| Один-два теста | контейнер на тест с t.Parallel() | максимум изоляции, связность нулевая |
| Десятки тестов, лёгкие образы | хелпер с контейнером на тест | секунды на старт, память суммируется |
| Десятки тестов, тяжёлый образ | общий контейнер + изоляция данных | один старт, схема или база на тест |
| Сотни тестов | общий контейнер на пакет + схемы | фикстуры пакета в TestMain или Suite |
| Слабый раннер, мало памяти | WorkersCount и -parallel вниз | меньше одновременных контейнеров |
| Несколько пакетов разом | пакеты — отдельные процессы, своя сессия | уборка сессии по меткам |
Два параллельных теста читают один топик Kafka или одну очередь RabbitMQ: консьюмеры в одной группе делят сообщения между собой, и каждый тест получает случайную половину — тест флапает на ровном месте, хотя локально с одним прогоном всё зелёное. Лечение стоит одну строку: имя топика, очереди или префикс ключей строится от t.Name() или случайного суффикса — и флейк исчезает вместе с общим пространством имён. Спорное мнение с условием: фиксированные имена удобны в ручных прогонах одного теста — оставляйте их для -run и никогда для общего пакета.
Общий контейнер против контейнера на тест#
Контейнер на тест и общий контейнер — не конкуренты, а два полюса шкалы. Первый полюс: максимум изоляции, нулевая связность, максимум памяти и стартов. Второй: один контейнер на пакет, изоляция данными, а связность появляется там, где её не ждали: общие кэши, счётчики, статусные таблицы. Миграции на общем инстансе должны быть идемпотентными и согласованными: несколько параллельных тестов, накатывающих схему в одну служебную базу, — отдельная гонка, которую снимают прогоном миграций один раз до старта тестов. Переиспользование контейнера по имени — режим Reuse из урока про переиспользование — в параллельном мире особый случай: в исходниках доступ к переиспользуемому контейнеру защищён мьютексом, и комментарий прямо называет обе причины гонки — ParallelContainers и t.Parallel. Мьютекс гарантирует, что create и start не погоняют друг друга: второй тест получит уже созданный контейнер. Но данные он не защищает: два теста в одном контейнере перезаписывают строки, сбивают счётчики и ломают ожидания друг друга. Started: true живой контейнер не перезапускает — процесс один, данные общие. Reuse — инструмент итерации над одним тестом, а не стратегия параллельного пакета.
Проверка: пакет упёрся в память
Пакет из тридцати тестов с t.Parallel() и контейнером на тест падает на CI по памяти. Что менять первым?
Проверка: Reuse в параллельном прогоне
Тест A и тест B помечены t.Parallel() и оба используют Reuse с одним именем. Что произойдёт?
Что случится с ключами
Прочитайте код и выберите, что он выведет:
// Тест A и тест B помечены t.Parallel().
// Оба работают с одним Redis из общего хелпера,
// но без префиксов: каждый пишет ключ "session:42"
// и затем читает его обратно.ParallelContainers: штатный запуск пачкой#
Запускать testcontainers параллельно можно и без своих горутин: библиотека даёт функцию ParallelContainers. На вход — ParallelContainerRequest, срез обёрток GenericContainerRequest: те же декларации, что и поодиночке. Опции — ParallelContainersOptions с полем WorkersCount: ноль означает дефолт в восемь воркеров, каждый воркер — горутина, разбирающая запросы из канала. Возврат — срез контейнеров и агрегированная ошибка: ParallelContainersError хранит список ParallelContainersRequestError, по записи на неудавшийся запрос, с исходной декларацией внутри. Назначение — поднять сразу несколько зависимостей стенда: сервис, базу, брокера — и не ждать их по очереди. Результаты собираются по мере готовности, поэтому порядок в возвращённом срезе не обязан совпадать с порядком запросов — сопоставляйте контейнеры с запросами по данным, а не по индексу. Для пакета тестов этот вызов не нужен: тесты запускает фреймворк, а контейнеры заводит хелпер. Уборка остаётся на вас: TerminateContainer на каждый успешный контейнер, как и в одиночном прогоне.
Пачка зависимостей одним вызовом
GOrequests := testcontainers.ParallelContainerRequest{
{
ContainerRequest: testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
},
Started: true,
},
{
ContainerRequest: testcontainers.ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
},
Started: true,
},
}
res, err := testcontainers.ParallelContainers(ctx, requests, testcontainers.ParallelContainersOptions{WorkersCount: 4})
if err != nil {
var pErr testcontainers.ParallelContainersError
if errors.As(err, &pErr) {
for _, e := range pErr.Errors {
log.Printf("запрос %s: %v", e.Request.ContainerRequest.Image, e.Error)
}
}
}
for _, c := range res {
c := c
defer func() { _ = testcontainers.TerminateContainer(c) }()
}Пачка запросов уходит воркерам: WorkersCount 4 означает четыре горутины, а ноль подставил бы дефолт восемь. Каждый воркер зовёт GenericContainer, ошибки собираются в ParallelContainersError — по одной записи на неудавшийся запрос, с оригинальной декларацией внутри. Успешные контейнеры возвращаются вместе с ошибкой. Уборка — TerminateContainer на каждый контейнер из среза результатов.
Соберите изоляцию на общем инстансе
Расставьте шаги: от решения о shared-контейнере до уборки схемы.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Лимиты машины: память, порты, сессии#
Раннер — конечный ресурс, и у параллельности есть физический потолок. Память: каждый Postgres, Kafka или Mongo держит свой буферный пул, и сумма одновременных контейнеров должна влезать в RAM с запасом. Процессы: лимит pid у демона и в cgroup контейнера — не абстракция, сотни одновременных контейнеров упираются в него раньше, чем в память. Порты: Docker выдаёт случайные свободные порты сам, поэтому конфликтов нет — беда начинается только с фиксированными портами хоста. Параллелизм регулируется двумя ручками: флаг -parallel ограничивает одновременные тесты внутри пакета, WorkersCount — одновременный старт контейнеров в ParallelContainers. Сессии: каждый пакет — отдельный процесс, библиотека выводит идентификатор сессии из родительского pid, помечает контейнеры меткой org.testcontainers.sessionId и держит на сессию одного уборщика, чьё имя построено из sessionID; при параллельных процессах одной сессии спавнер ищет живого уборщика со случайными интервалами ожидания в пределах двадцати секунд, чтобы попытки не совпадали. Сессию можно зафиксировать извне переменной окружения TESTCONTAINERS_SESSION_ID или свойством session.id: все процессы с одинаковым значением попадают в одну сессию и одного уборщика — приём для сборочных систем, у которых пакеты не имеют общего родителя. Как уборщик зачищает остатки — тема урока про Ryuk.
Соберите параллельный тест
Восстановите тест: маркер параллельности, ожидание готовности и получение порта.
Типовые ошибки параллельных прогонов#
- фиксированный Name у параллельных тестов: второй create падает с конфликтом имён
- Reuse и t.Parallel с одним именем: мьютекс спасает запуск, но не данные
- общая база без изоляции: тесты перезаписывают строки и сбивают счётчики друг друга
- один топик Kafka или очередь RabbitMQ на всех: сообщения уходят соседу
- фиксированный порт хоста вместо MappedPort: порты разбирает первый успевший
- безлимитный t.Parallel на слабом раннере: память и лимит pid кончаются
- общие префиксы ключей Redis: тесты читают чужие записи и флапают
- правка таймаутов вместо изоляции: флейки лечатся разводкой данных, а не сном
Честная оговорка: в песочнице сайта этот урок не запустится — параллельные тесты требуют локального демона Docker и настоящего go test, браузерная среда исполняет только тематические фрагменты. Рабочая схема: сохраните код урока в файл *_test.go рядом с go.mod и запустите go test -parallel 4 — параллельные тесты видны по пометкам в выводе. Домашний эксперимент: docker stats во время прогона покажет рост суммарной памяти; понизьте -parallel и сравните цифры — так вы найдёте потолок своей локальной машины раньше, чем его найдёт CI.
Собранная дисциплина параллельного пакета укладывается в несколько привычек. Хелпер с контейнером на тест — для лёгких образов; изоляция схемами, базами и уникальными именами — для тяжёлых; Reuse из параллельного прогона убирается вовсе. Одновременность подгоняется ручками -parallel и WorkersCount под память раннера, а не наоборот: сначала считается бюджет машины, потом выбирается число одновременных тестов, и это число фиксируется в конфигурации CI, а не оставляется на усмотрение железа. Фикстуры пакета — один контейнер на TestMain, схемы на тест, общие пулы соединений — собраны в уроке про Testify и TestMain, а страховку на случай смерти процесса даёт уборщик сессии. Проверяйте параллельность на слабой локальной машине до отправки в CI: восемь тестов на ноутбуке с скромной памятью скажут о пакете больше, чем зелёный прогон на мощном воркере.
Карточки: параллельность
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- t.Parallel() запускает тесты толпой после последовательных: -parallel режет толпу, MappedPort исключает конфликты портов, память суммируется по числу одновременных контейнеров.
- Изоляция данных: схема или база на тест, уникальные топики, очереди и префиксы ключей — имя ресурса строится от t.Name() или случайного суффикса.
- Reuse — не стратегия параллельности: мьютекс защищает запуск, но не данные; рабочий средний путь — общий контейнер с изоляцией данных.
- ParallelContainers: WorkersCount с дефолтом восемь, ошибки собираются в ParallelContainersError по запросам, уборка — TerminateContainer на каждый контейнер.
- Лимиты машины — память и pid, а не порты; сессия пакета получает одного уборщика, которого спавнер при параллельных процессах ищет со случайными интервалами, а зафиксировать сессию можно переменной TESTCONTAINERS_SESSION_ID.
Домашнее задание
Разведите десять тестов по схемам одного Postgres: общий контейнер, хелпер newSchema от t.Name(), DROP SCHEMA CASCADE в Cleanup. Прогоните с -parallel 4, снимите суммарную память через docker stats и убедитесь, что схемы соседей не пересекаются: вставьте одинаковую строку в две схемы и проверьте, что тесты не заметили друг друга. Затем смените подход на контейнер на тест, сравните время прогона и память, и запишите, при каком масштабе какой путь выгоднее.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему параллельные тесты конфликтуют на данных, а не на портах?
Порты раздаёт Docker: MappedPort берёт случайный свободный порт хоста, поэтому два одинаковых контейнера никогда не дерутся за адрес — конфликт возможен только при фиксированном порте руками. Те же случайные порты исключают и конфликт при старте нескольких пакетов параллельно: у каждого процесса свои контейнеры и свои адреса. Данные — другое дело: хранилище внутри контейнера одно, и без изоляции все тесты делят таблицы, ключи и очереди. Поэтому сетевой уровень при параллельности почти безопасен, а уровень данных требует явной разводки: схемы, базы, префиксы, уникальные имена.
Что выбрать: -parallel или WorkersCount?
Это ручки разных уровней. -parallel ограничивает число одновременно идущих тестов внутри одного пакета — это про то, сколько контейнеров и данных живут одновременно. WorkersCount в ParallelContainers ограничивает число горутин, стартующих пачку контейнеров одним вызовом, — это про скорость поднятия стенда из нескольких зависимостей. Для пакета тестов пользуются -parallel, для запуска нескольких зависимостей разом — WorkersCount; одно другое не заменяет.
Почему уборщик один на сессию, а не на пакет?
Сессия агрегирует все процессы одного прогона: идентификатор выводится из родительского pid, поэтому пакеты, запущенные одним go test, получают одинаковые метки и одного уборщика, который зачищает всё, что помечено его сессией. Если бы уборщик заводился на каждый пакет, их было бы столько же, сколько пакетов, — лишние контейнеры и нагрузка. При параллельных процессах одной сессии спавнер находит уже живого уборщика, ожидая со случайными интервалами до двадцати секунд, чтобы все процессы не ломились к нему одновременно; фиксация сессии через TESTCONTAINERS_SESSION_ID закрепляет такую схему и для сборочных систем без общего родителя.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах