Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +50 XP

Параллельные тесты и изоляция

Один контейнер на тест — роскошь, которую машина прощает, пока тестов мало. Двадцать параллельных тестов с Postgres — уже вопрос выживания: память раннера, лимиты процессов, время старта. Go гоняет тесты последовательно, пока вы не пометите их t.Parallel(), и тогда каждый одновременно живущий тест тянет свои контейнеры в общий Docker. На локальной машине с запасом памяти всё проходит, на CI — падает по памяти или по данным: тесты наступают друг другу на строки в общей базе. Сценарий «параллельные тесты go» распадается на два вопроса: сколько контейнеров выдержит машина и как развести данные. Здесь же закрывается сценарий «testcontainers параллельно»: изоляция схемами, базами и топиками, штатный ParallelContainers, цена Reuse, лимиты Docker и сессии уборки.

Содержание урока · 10 разделов

Контейнер — не бесплатный объект: под ним образ на сотни мегабайт, процессы, файловые дескрипторы и порт. Пока go test исполняет тесты пакета по одному, цена размазывается во времени: один контейнер живёт, отработал и умер. t.Parallel() ломает эту последовательность: помеченные тесты стартуют одновременно, и их контейнеры живут вместе. Суммарная память растёт линейно от числа одновременных тестов, а вместе с ней растёт и нагрузка на демон Docker: каждый create — это обращение к кэшу образов, создание сетей и ожидание готовности. Второй уровень параллельности — пакеты: go test ./... запускает каждый пакет отдельным процессом, и одновременно живёт уже несколько процессов со своими контейнерами. Есть и скрытая цена последовательности: тесты, идущие по одному, делят всё и не замечают этого — гонки на данных проявляются ровно в тот день, когда пакет становится параллельным. Маршрут контейнера от декларации до Terminate разобран в уроке про жизненный цикл — здесь он остаётся тем же, меняется только плотность: сколько таких маршрутов живёт одновременно.

t.Parallel() и цена контейнера#

t.Parallel() — маркер в теле теста: он говорит фреймворку, что тест можно приостановить и досчитать вместе с другими параллельными. Механика такая: параллельные тесты доходят до маркера и засыпают; после завершения всех последовательных тестов пакета они просыпаются и идут толпой, ограниченной флагом -parallel — по умолчанию он привязан к GOMAXPROCS машины. В выводе go test такие тесты помечены словом PAR, и по этой пометке удобно проверять, что параллельность вообще случилась. Каждый проснувшийся тест со своим контейнером получает честную изоляцию: свой Postgres, свои таблицы, свой Redis, а Docker раздаёт каждому случайный свободный порт хоста через MappedPort — конфликтов портов не будет, если не фиксировать их руками. Цена — память и старт: восемь одновременных Postgres — это восемь буферных пулов и восемь ожиданий готовности. Когда сценарий «параллельные тесты go» становится повседневностью, решение принимают по формуле: лёгкий образ — контейнер на тест без раздумий, тяжёлый — сначала попробуйте изоляцию данных на общем инстансе.

Контейнер на тест: хелпер для параллельных тестов

GO
func 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

GO
func 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 внизменьше одновременных контейнеров
Несколько пакетов разомпакеты — отдельные процессы, своя сессияуборка сессии по меткам

Общий контейнер против контейнера на тест#

Контейнер на тест и общий контейнер — не конкуренты, а два полюса шкалы. Первый полюс: максимум изоляции, нулевая связность, максимум памяти и стартов. Второй: один контейнер на пакет, изоляция данными, а связность появляется там, где её не ждали: общие кэши, счётчики, статусные таблицы. Миграции на общем инстансе должны быть идемпотентными и согласованными: несколько параллельных тестов, накатывающих схему в одну служебную базу, — отдельная гонка, которую снимают прогоном миграций один раз до старта тестов. Переиспользование контейнера по имени — режим 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 на каждый успешный контейнер, как и в одиночном прогоне.

Пачка зависимостей одним вызовом

GO
requests := 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.

Соберите параллельный тест

Восстановите тест: маркер параллельности, ожидание готовности и получение порта.

func TestSessionStore(t *testing.T) { () ctx := context.Background() ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "redis:7-alpine", ExposedPorts: []string{"6379/tcp"}, : wait.ForListeningPort("6379/tcp"), }, Started: true, }) if err != nil { t.Fatalf("redis: %v", err) } t.Cleanup(func() { _ = ctr.Terminate(context.Background()) }) port, _ := ctr.(ctx, "6379/tcp") _ = port }

Типовые ошибки параллельных прогонов#

  • фиксированный Name у параллельных тестов: второй create падает с конфликтом имён
  • Reuse и t.Parallel с одним именем: мьютекс спасает запуск, но не данные
  • общая база без изоляции: тесты перезаписывают строки и сбивают счётчики друг друга
  • один топик Kafka или очередь RabbitMQ на всех: сообщения уходят соседу
  • фиксированный порт хоста вместо MappedPort: порты разбирает первый успевший
  • безлимитный t.Parallel на слабом раннере: память и лимит pid кончаются
  • общие префиксы ключей Redis: тесты читают чужие записи и флапают
  • правка таймаутов вместо изоляции: флейки лечатся разводкой данных, а не сном

Собранная дисциплина параллельного пакета укладывается в несколько привычек. Хелпер с контейнером на тест — для лёгких образов; изоляция схемами, базами и уникальными именами — для тяжёлых; Reuse из параллельного прогона убирается вовсе. Одновременность подгоняется ручками -parallel и WorkersCount под память раннера, а не наоборот: сначала считается бюджет машины, потом выбирается число одновременных тестов, и это число фиксируется в конфигурации CI, а не оставляется на усмотрение железа. Фикстуры пакета — один контейнер на TestMain, схемы на тест, общие пулы соединений — собраны в уроке про Testify и TestMain, а страховку на случай смерти процесса даёт уборщик сессии. Проверяйте параллельность на слабой локальной машине до отправки в CI: восемь тестов на ноутбуке с скромной памятью скажут о пакете больше, чем зелёный прогон на мощном воркере.

Карточки: параллельность

Термин

t.Parallel()

нажмите, чтобы перевернуть
Определение

Маркер параллельного теста: тест засыпает до конца последовательных тестов пакета, затем идёт толпой, ограниченной флагом -parallel. Каждый параллельный тест со своим контейнером получает полную изоляцию.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 и убедитесь, что схемы соседей не пересекаются: вставьте одинаковую строку в две схемы и проверьте, что тесты не заметили друг друга. Затем смените подход на контейнер на тест, сравните время прогона и память, и запишите, при каком масштабе какой путь выгоднее.

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · Параллельные тесты и изоляция)

Вопросы и ответы по уроку

Почему параллельные тесты конфликтуют на данных, а не на портах?

Порты раздаёт Docker: MappedPort берёт случайный свободный порт хоста, поэтому два одинаковых контейнера никогда не дерутся за адрес — конфликт возможен только при фиксированном порте руками. Те же случайные порты исключают и конфликт при старте нескольких пакетов параллельно: у каждого процесса свои контейнеры и свои адреса. Данные — другое дело: хранилище внутри контейнера одно, и без изоляции все тесты делят таблицы, ключи и очереди. Поэтому сетевой уровень при параллельности почти безопасен, а уровень данных требует явной разводки: схемы, базы, префиксы, уникальные имена.

Что выбрать: -parallel или WorkersCount?

Это ручки разных уровней. -parallel ограничивает число одновременно идущих тестов внутри одного пакета — это про то, сколько контейнеров и данных живут одновременно. WorkersCount в ParallelContainers ограничивает число горутин, стартующих пачку контейнеров одним вызовом, — это про скорость поднятия стенда из нескольких зависимостей. Для пакета тестов пользуются -parallel, для запуска нескольких зависимостей разом — WorkersCount; одно другое не заменяет.

Почему уборщик один на сессию, а не на пакет?

Сессия агрегирует все процессы одного прогона: идентификатор выводится из родительского pid, поэтому пакеты, запущенные одним go test, получают одинаковые метки и одного уборщика, который зачищает всё, что помечено его сессией. Если бы уборщик заводился на каждый пакет, их было бы столько же, сколько пакетов, — лишние контейнеры и нагрузка. При параллельных процессах одной сессии спавнер находит уже живого уборщика, ожидая со случайными интервалами до двадцати секунд, чтобы все процессы не ломились к нему одновременно; фиксация сессии через TESTCONTAINERS_SESSION_ID закрепляет такую схему и для сборочных систем без общего родителя.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Go Base25 уроков~12 чновичок25 уроков уже доступны

Go с нуля: базовый синтаксис и конструкции

Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.

  • Писать, компилировать и запускать программы на Go
  • Свободно работать с переменными, типами, операторами
  • Управлять потоком выполнения: if, switch, for, range
  • Хранить данные в массивах, срезах и картах