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

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

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

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

Reuse: переиспользование контейнеров

Брокер сообщений или база поднимаются десятки секунд, и платить эту цену перед каждым тестом пакета — расточительство. Режим Reuse в testcontainers отвечает ровно на эту боль: контейнер с фиксированным именем ищется среди существующих и возвращается тесту как есть, а создаётся лишь однажды. В уроке — поле Reuse обёртки GenericContainerRequest и его жёсткое условие на имя, опция WithReuseByName, паттерн «один дорогой контейнер на пакет» через TestMain, чистка состояния TRUNCATE и FlushDB вместо пересоздания и границы приёма: где общее состояние превращает быстрые тесты во флейковые. Сценарий «testcontainers reuse» разобран до типовых ошибок, а обещанное «ускорение тестов go» — с честным ценником: что экономите и чем рискуете. По ходу соберём таблицу выбора стратегии контейнера под стиль теста и пройдём по типовым промахам: от Terminate в чужом Cleanup до паузы, поставленной контейнеру руками.

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

Каждый вызов GenericContainer по умолчанию заводит отдельный контейнер: create, загрузка образа при первом прогоне, старт процесса, wait-стратегия. Для nginx это секунды, для брокера с журналом и конфигурацией — десятки. Умножьте на двадцать тестов пакета, и половина времени сборки уйдёт на подъёмы, хотя логике тестов нужен не новый сервис, а живой и предсказуемый. Память и порты расходуются так же: два десятка брокеров, живущих одновременно в параллельном прогоне, — два десятка полновесных процессов с буферами и журналами, делящих хост с самим тестовым раннером. Диски раннеров тоже не бесконечны: каждый контейнер тащит журналы и слои записи, которые уборщик обязан найти и стереть. Механика известна с третьего урока: контейнер — процесс с состоянием в памяти и на диске, и всё, что тест туда положил, остаётся там до удаления. Отсюда два вопроса, на которые отвечает этот урок. Первый: как попросить библиотеку найти уже работающий сервис вместо нового — это поле Reuse и фиксированное имя. Второй: что делать с состоянием, которое пережило прошлый тест, — его чистят между тестами, а не бегут от него пересозданием.

Зачем переиспользовать дорогой контейнер#

Считать выгоды удобно на брокере. Один подъём: pull образа, создание, старт, прогрев диспетчеров тем — суммарно близко к минуте на слабой машине. Двадцать тестов с индивидуальными контейнерами — двадцать таких подъёмов, и сборка растягивается на полчаса, хотя каждый тест работает секунды. Схема с переиспользованием платит за старт один раз: первый вызов с именем и Reuse создаёт контейнер, остальные находят его и получают тот же адрес. Сценарий «testcontainers reuse» держится на простом контракте: вызов с тем же именем возвращает существующий контейнер, а не ошибку конфликта. Экономия живёт не только в CI: при локальной итерации контейнер переживает прогон, и повторный go test стартует мгновенно, без pull и прогрева. Ускорение тестов go чаще всего начинается именно здесь — с удаления повторных подъёмов, а не с тонкой настройки самого кода: десятки секунд на тест нельзя выгадать ни одной микрооптимизацией в самом тесте. Цена вопроса симметрична: пока контейнер общий, готовность придётся проверять на каждом вызове, но сверка порта — миллисекунды против минут на полный подъём с загрузкой образа. Останется следить, чтобы общий сервис не превратился в общую помойку, — этому посвящён хвост урока.

Декларация с именем и Reuse

GO
func brokerEndpoint(ctx context.Context, t *testing.T) string {
	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "confluentinc/cp-kafka:latest",
			Name:         "it-kafka",
			ExposedPorts: []string{"9093/tcp"},
			WaitingFor:   wait.ForListeningPort("9093/tcp"),
		},
		Started: true,
		Reuse:   true, // требует непустого Name
	})
	if err != nil {
		t.Fatalf("брокер: %v", err)
	}
	// Terminate здесь нет: контейнер должен пережить тест

	host, _ := ctr.Host(ctx)
	port, _ := ctr.MappedPort(ctx, "9093/tcp")
	return host + ":" + port.Port()
}

func TestConsumerReads(t *testing.T) {
	addr := brokerEndpoint(t.Context(), t)
	// ... тест читает из топика по адресу addr
}

func TestProducerWrites(t *testing.T) {
	addr := brokerEndpoint(t.Context(), t)
	// ... тот же самый брокер, второго подъёма не будет
}

Name задаёт фиксированное имя, Reuse: true просит найти контейнер по нему и вернуть, а создавать только если ничего не нашлось. Поля живут в обёртке GenericContainerRequest, как и Started. В хелпере нет Terminate: после первого теста брокер остаётся жить, и второй тест получает его мгновенно. Уборкой займётся конец пакета.

Reuse: условия, поиск, состояния#

Условие одно, но жёсткое: имя обязательно. Reuse без имени GenericContainer отсекает ещё до обращения к провайдеру — возвращается ErrReuseEmptyName, потому что искать без имени нечего. Поле при этом живёт в обёртке запроса рядом со Started: декларация описывает «что запустить», обёртка — как ведёт себя тест, и переиспользование относится ко второй категории. Поиск идёт по точному совпадению имени среди всех контейнеров машины, включая остановленные: брокер, остановленный прошлой сессией, будет найден и поднят заново. Если контейнера нет, выполняется обычный create; гонка, когда два вызова одновременно создают один контейнер, обрабатывается ожиданием его появления — конфликт имени в этой ситуации не роняет тест. Дальше провайдер смотрит состояние найденного: работающий контейнер не перезапускается, но хуки старта и wait-стратегия исполняются заново; остановленный поднимается, как будто его никто не гасил; контейнер в паузе средствами Docker переиспользованию не поддаётся — вернётся ошибка о невозможности старта паузы. Для нового API Run и модулей есть опция WithReuseByName: один вызов ставит и имя, и флаг; документация помечает её экспериментальной и предупреждает, что реализация может смениться на неименную. Практичное следствие: декларация переиспользуемого контейнера должна быть одинаковой во всех вызовах — различия в полях к живому контейнеру не применяются, и тест будет работать с тем, что задал создатель.

Один контейнер на пакет: TestMain и уборка#

Паттерн «один дорогой контейнер на пакет» опирается на TestMain — точку входа тестового пакета: до m.Run контейнер создаётся, после — убирается. Каждый тест получает адрес из пакетной переменной и не тратит ни строки на подъём. Уборка переезжает с теста на пакет: Terminate после m.Run срабатывает всегда — и на провале assert'ов, и на панике внутри теста, потому что фреймворк доводит прогон до возврата в TestMain. Регистрировать Terminate в t.Cleanup каждого теста при общем контейнере — ошибка: первый завершившийся тест снесёт брокер соседям, и те начнут пересоздание с полного подъёма на ровном месте. Провалиться пакет должен целиком и сразу: ошибка подъёма в TestMain валит прогон с одной внятной причиной вместо двадцати одинаковых красных строк. Пакетные фикстуры — тема урока про TestMain и testify, здесь важен принцип: создаёт один раз, убирает один раз, тесты только пользуются. Имя при этом стоит выбирать пакетно-уникальным: префикс с сервисом и названием пакета убережёт от коллизии, когда два разных пакета одной машины захотят одного брокера.

TestMain: создать до m.Run, убрать после

GO
var broker testcontainers.Container

func TestMain(m *testing.M) {
	ctx := context.Background()
	var err error
	broker, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "confluentinc/cp-kafka:latest",
			Name:         "it-kafka",
			ExposedPorts: []string{"9093/tcp"},
			WaitingFor:   wait.ForListeningPort("9093/tcp"),
		},
		Started: true,
		Reuse:   true,
	})
	if err != nil {
		log.Fatalf("брокер: %v", err)
	}

	code := m.Run()
	_ = broker.Terminate(ctx) // одна уборка на весь пакет
	os.Exit(code)
}

TestMain выполняется один раз на пакет: контейнер с Reuse найдёт живого брокера с прошлой итерации или создаст нового. m.Run возвращает код прогона, Terminate убирает контейнер после всех тестов, os.Exit передаёт код наружу. Ошибка подъёма валит пакет сразу с внятным сообщением, а не тиражирует провал по каждому тесту.

Чистка состояния: TRUNCATE и FlushDB#

Общий контейнер — общие данные: строки, вставленные прошлым тестом, ждут следующий. Ответ — чистка между тестами, дешевле которой только ничего: вместо пересоздания контейнера тест выполняет внутри него одну команду. Для Postgres это TRUNCATE списком таблиц с RESTART IDENTITY CASCADE — сброс автоинкрементов и зависимых ссылок одним заходом; для Redis — FLUSHALL, стирающий все ключи мгновенно; для брокера — выделенный топик или группа на тест, чтобы сообщения не переливались между сценариями. Вызов исполняется через Exec по живому контейнеру из TestMain, занимает миллисекунды и не трогает ни сеть, ни образ, ни wait-стратегию. Хелпер чистки удобно ставить в t.Cleanup теста: уборка выполнится и при провале assert'ов, и следующий тест получит предсказуемо пустую базу. Дисциплина симметрична подъёму: раз контейнер заводится один раз на пакет, чистится он много раз — по разу на тест. Клиентские запросы к базе из Go — в курсе pgx, здесь достаточно Exec с консольной утилитой внутри контейнера.

Хелпер чистки базы между тестами

GO
func resetDB(t *testing.T) {
	t.Helper()
	_, _, err := broker.Exec(t.Context(), []string{
		"psql", "-U", "app", "-d", "app",
		"-c", "TRUNCATE orders, payments RESTART IDENTITY CASCADE",
	})
	if err != nil {
		t.Fatalf("чистка базы: %v", err)
	}
}

func TestCreateOrder(t *testing.T) {
	t.Cleanup(resetDB)
	// ... тест пишет заказы в общую базу и видит только свои
}

Exec запускает psql внутри живого контейнера: TRUNCATE со списком таблиц, RESTART IDENTITY сбрасывает счётчики, CASCADE трогает зависимые. Регистрация в t.Cleanup гарантирует чистку при любом исходе теста. Для Redis хелпер отличается одной строкой: redis-cli с FLUSHALL. Пересоздание контейнера на это не тратится.

Стиль теста → стратегия контейнера#

Стиль тестаСтратегия контейнераПочему
чистый unit-тест с мокамиконтейнер не нужензависимость подменена стабом, Docker тратится зря
интеграционный тест, изоляция дороже всегосвой контейнер на каждый тестгарантия девственно чистого состояния без хелперов
дорогой брокер или база, общий пакетодин контейнер: Name + Reuse в TestMainстарт оплачивается один раз, состояние чистится между тестами
локальная итерация над одним тестомreuse по имени без Terminateконтейнер переживает прогон, повторный go test стартует мгновенно
тест на рестарт сервисаreuse по имени плюс ручной Stopостановленный контейнер будет найден и поднят следующим вызовом
параллельные тесты с общим сервисомreuse по имени или контейнер каждомувызовы сериализуются, но состояние у всех будет общим

Когда reuse вредит: грязное состояние#

Экономия секунд оборачивается флейками, когда тест опирается на состояние, о котором ничего не знает. Порядок исполнения тестов Go не фиксирует: завтра ConsumerReads отработает первым, и ProducerWrites увидит не пустой топик, а вчерашние сообщения. Автоинкременты уехали от прошлых вставок, и тест, сверяющий id с единицей, краснеет через раз. Общий кэш отдаёт попадание там, где тест ждал промах; соединения, оставленные соседом в пуле, могут упереться в лимит брокера. Симптом один: тест зелёный отдельно и падает в пакете — верный признак протечки состояния, а не бага в продакшен-коде. Рецепт из таблицы выше: тестам с жёсткими предположениями о пустом старте — индивидуальный контейнер, общему пакету — дисциплина чистки после каждого теста. Правило отбора простое: тест обязан декларировать нужное ему состояние сам и не полагаться на то, что «контейнер же свежий». Если нужная декларация невозможна без полного пересоздания — переиспользование здесь не инструмент ускорения, а источник нестабильности, и честнее заплатить подъёмом. Отдельный случай — отладка: упавший тест на переиспользуемом контейнере оставляет своё состояние внутри, и заглянуть в него через docker exec бывает полезнее любого лога — побочный бонус общей базы.

Проверка: Reuse без имени

Тест передал Reuse: true, но поле Name не заполнил. Что вернёт GenericContainer?

Параллельность добавляет ещё один слой. Поиск-или-создание под Reuse защищён внутренним мьютексом: параллельные тесты с t.Parallel не устроят гонку за имя, и все получат один и тот же контейнер — в тестах исходников десять одновременных подпроцессов сходятся ровно в один живой брокер. Контейнеры не плодятся, порты не кончаются, ошибок конфликта имени не бывает, но состояние у всех общее: параллельный тест видит чужие записи, чужие сообщения и чужие ключи в кэше. Значит, для параллельного пакета с общим сервисом чистка между тестами не пожелание, а обязательство, а тестам с жёсткими ожиданиями пустого старта нужен собственный контейнер. Мьютекс снимает только гонку на создание: после его освобождения тесты идут параллельно через один сервис, и никакая блокировка уже не защищает их данные друг от друга. Что происходит с портами и общими ресурсами при t.Parallel и как выбирать между общим контейнером и личным — отдельная тема семнадцатого урока.

Что получат два параллельных теста

Прочитайте код и выберите, что он выведет:

// Два теста одного пакета помечены t.Parallel().
// Оба вызывают GenericContainer c одинаковой декларацией:
//     Name: "it-kafka", Started: true, Reuse: true
// Машина одна, ни одного контейнера it-kafka ещё нет.

Проверка: остановили общий контейнер

Тест A вызвал Stop на переиспользуемом контейнере it-kafka. Тест B стартует GenericContainer с тем же именем и Reuse: true. Что произойдёт?

Соберите переиспользуемую декларацию

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

ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "redis:7-alpine", : "it-cache", ExposedPorts: []string{"6379/tcp"}, }, Started: true, : true, }) if err != nil { t.Fatalf("redis: %v", err) } port, _ := ctr.(ctx, "6379/tcp")

Соберите путь переиспользуемого вызова

Расставьте шаги: от декларации с именем до уборки на уровне пакета.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Типовые ошибки переиспользования#

  • Reuse: true без Name — ErrReuseEmptyName ещё до обращения к провайдеру
  • Terminate в t.Cleanup каждого теста при общем контейнере — первый завершившийся снесёт сервис соседям
  • ставка на свежее состояние: чужие строки, сообщения и ключи переезжают между тестами
  • AutoRemove вместе с reuse: контейнер стирается при останове, переиспользовать будет нечего
  • одно имя в двух независимых пакетах на одной машине — пакеты молча делят один контейнер
  • reuse там, где нужна изоляция: грязное состояние дороже сэкономленных секунд
  • ожидание, что wait-стратегия сработает единожды: при каждом переиспользовании она проходит заново
  • пауза контейнера руками Docker: переиспользование paused-контейнера вернёт ошибку

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

Термин

Reuse

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

Поле обёртки GenericContainerRequest: найти контейнер по фиксированному имени и вернуть его, создать только если имени нет. Работающий контейнер не перезапускается, остановленный поднимается, имя обязательно.

1 / 6

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Reuse в обёртке GenericContainerRequest ищет контейнер по фиксированному Name и создаёт только при отсутствии; пустое имя — ErrReuseEmptyName до провайдера.
  • Найденный контейнер адаптируется: running остаётся работать, stopped поднимается, paused даёт ошибку; хуки и wait-стратегия проходят при каждом вызове.
  • Паттерн «один дорогой контейнер на пакет»: TestMain создаёт до m.Run и убирает Terminate после; тесты чистят состояние TRUNCATE и FLUSHALL через Exec.
  • Reuse ускоряет, но разделяет состояние: тесты с жёсткими предположениями о пустом старте получают индивидуальный контейнер.

Домашнее задание

Соберите пакет из двух тестов на общем брокере: TestMain поднимает контейнер с Name и Reuse, тесты берут адрес и чистят состояние после себя. Затем уберите Terminate из TestMain, прогоните go test дважды и убедитесь по docker ps, что второй прогон стартовал мгновенно. Финал: остановите контейнер вручную между прогонами и проверьте, что следующий вызов поднял его сам.

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

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

Чем Reuse отличается от простого фиксированного Name?

Name без Reuse — просто ярлык: два вызова с одним именем устроят конфликт на create, второй упадёт, пока жив первый. Reuse меняет семантику: вызов с тем же именем ищет существующий контейнер и возвращает его, а create выполняется только для пустого результата поиска, с обработкой гонки на имя. Обратное не симметрично: фиксированное имя без Reuse ничего не ищет и переиспользованием не становится.

Кто убирает переиспользуемый контейнер?

Ответственность на схеме с пакетным контейнером лежит на TestMain: Terminate после m.Run — одна уборка на пакет, выполняющаяся и при провале тестов. Забытый Terminate не оставляет вечный мусор: контейнер, созданный в сессии, попадает под супервизор уборки — его устройство и сроки разбираются в отдельном уроке. Побочный эффект поиска без фильтра по сессии: брокер, оставленный прошлой итерацией локально, найдётся и будет переиспользован — отсюда и мгновенный рестарт. Именно поэтому тесты исходников после проверки переиспользования удаляют найденный контейнер явно, не надеясь на чужую уборку.

Совместим ли Reuse с t.Parallel?

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

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

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

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

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

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

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

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