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

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

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

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

Жизненный цикл контейнера в тесте

Каждый контейнер в тесте проходит один и тот же маршрут: декларация в ContainerRequest, создание, запуск, ожидание готовности, работа и Terminate. Разберём его по шагам: как поля Env, ExposedPorts, Cmd, Labels и Files превращаются в docker create, зачем существует Started: false, чем t.Context() из Go 1.24 лучше context.Background, почему уборку пишут руками даже при Terminate и что случится с контейнерами при панике теста. Порядок шагов не перестановочен: нарушите его — и тест либо краснеет на ровном месте, либо оставляет за собой мусор. Сценарий «testcontainers жизненный цикл» здесь закрыт целиком, а связка «containerrequest go» разобрана поле за полем.

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

Контейнер в интеграционном тесте — не магия, а конечный автомат с коротким списком состояний: создан, запущен, готов, отработал, удалён. Пока тест жив, этим автоматом управляете вы: какая декларация ушла в Docker, когда поднимется процесс, кто вызовет останов и удаление. Библиотека берёт на себя проводку — docker create, docker start, ожидание готовности, docker rm, — но порядок и гарантии расставляет тест: одна пропущенная уборка на быстрой машине проходит незамеченной, а на CI превращается в рой зависших контейнеров и исчерпанные порты. Маршрут стоит усвоить один раз целиком: он не меняется от модуля к модулю, меняются только декларации поверх него. Этот урок — карта всего маршрута: декларация ContainerRequest поле за полем, роль Started и ручной Start, путь через wait-стратегию, Terminate с гарантиями. Механика первого контейнера из второго урока здесь уже знакома — теперь она собирается в дисциплину, которая переживёт и панику, и падение сборки.

ContainerRequest: декларация будущего контейнера#

Вся будущая жизнь контейнера описывается одной структурой — ContainerRequest из пакета testcontainers: образ, переменные окружения, порты, команда, метки, файлы, стратегия готовности. Структуру ContainerRequest Go применяет внутри GenericContainer, когда тест передаёт ему GenericContainerRequest: в этой обёртке рядом с вложенной декларацией живут свои поля — Started, провайдер, логгер, режим Reuse. Дизайн осмысленный: декларация отвечает на вопрос «что запустить», обёртка — на вопрос «как ведёт себя тест». До обращения к Docker декларацию проверяет Validate: он отсекает недопустимые комбинации, например образ одновременно с контекстом сборки. В момент создания провайдер дописывает к вашим меткам служебные — семейство org.testcontainers с языком, версией и идентификатором сессии, — и именно по ним уборщик в конце прогона находит ресурсы, которые создал тест. Декларацию удобно читать как docker run, записанный типами: одно поле — один флаг запуска, и ничего из теста в образ не просачивается. Когда модуль базы в четвёртом уроке подставит декларацию поверх вашей, вы уже будете знать, что именно он дописывает.

Тест с полной декларацией

GO
func TestServiceConfig(t *testing.T) {
	ctx := context.Background()

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/service",
			Env: map[string]string{
				"APP_ENV":     "test",
				"CONFIG_PATH": "/etc/app/config.yaml",
			},
			ExposedPorts: []string{"8080/tcp"},
			Cmd:          []string{"--log-level=debug"},
			Labels: map[string]string{
				"team":  "payments",
				"suite": "integration",
			},
			Files: []testcontainers.ContainerFile{
				{
					HostFilePath:      "testdata/config.yaml",
					ContainerFilePath: "/etc/app/config.yaml",
					FileMode:          0o644,
				},
			},
			WaitingFor: wait.ForListeningPort("8080/tcp"),
		},
		Started: true,
	})
	if err != nil {
		t.Fatalf("запуск контейнера: %v", err)
	}
	t.Cleanup(func() { _ = ctr.Terminate(ctx) })

	host, _ := ctr.Host(ctx)
	port, _ := ctr.MappedPort(ctx, "8080/tcp")
	log.Printf("сервис готов: %s:%s", host, port)
	// ... запросы к http://host:port
}

Декларация собирается литералом прямо в тесте. Env передаёт конфиг сервиса без пересборки образа, ExposedPorts объявляет порт, который тест заберёт наружу, Cmd дописывает аргументы команде образа, Labels помечают прогон для фильтрации, Files копирует конфиг внутрь до старта процесса. WaitingFor прячется в PostStart-хук: GenericContainer не вернётся, пока порт не откроется. Started: true стоит в обёртке, а не во вложенной декларации — это политика запуска теста.

Env, Cmd и Files: конфигурация сервиса#

Env — главный канал конфигурации сервиса: большинство образов читает настройки из переменных окружения, и тест подставляет свои значения вместо продакшен-конфига, не пересобирая образ. Cmd — аргументы, которые дописываются к точке входа образа: уровень логов, режим работы, адрес порта. ExposedPorts объявляет, какие порты контейнера тест хочет видеть снаружи, вместе с протоколом — 8080/tcp; маппнутый порт хоста вы узнаёте уже после старта через MappedPort, потому что Docker назначает его из свободных. Объявляйте ровно те порты, с которыми тест реально работает: каждый лишний порт замедляет старт и проверку готовности. Files копирует файлы внутрь при старте: каждый элемент — ContainerFile с путём на хосте или готовым Reader, путём внутри контейнера и правами FileMode; это идеальный канал для конфигов — точный снимок содержимого на момент прогона. Mounts решает другую задачу: он подключает каталоги и тома насквозь, и изменения видны в обе стороны, пока контейнер жив. Конфиг — Files, живые данные — Mounts, и путать их не стоит.

Поля ContainerRequest: кто за что отвечает#

ПолеЗа что отвечаетПример значения
Imageкакой образ запускать; сердце декларации"myapp/service"
Envпеременные окружения — конфиг сервиса без пересборки образаmap[string]string{"APP_ENV": "test"}
ExposedPortsпорты, которые тест заберёт наружу, с протоколом[]string{"8080/tcp"}
Cmdаргументы, дописываемые к точке входа образа[]string{"--log-level=debug"}
Labelsметки для группировки и фильтрации ресурсов прогонаmap[string]string{"suite": "integration"}
Filesфайлы, копируемые в контейнер при старте[]testcontainers.ContainerFile{{...}}
Nameфиксированное имя вместо сгенерированного; без нужды не ставить"billing-it"
AutoRemoveпопросить Docker стереть контейнер сразу после остановаtrue

Labels, Name и AutoRemove: метки, имена, удаление#

Labels — карта строковых меток, ложащихся на контейнер как docker-лейблы. Тесту они дают группировку и фильтрацию: помечайте прогон командой, фичей или билдом — потом один docker ps --filter label=suite=integration покажет, кто остался висеть. Библиотека складывает ваши метки со своими: служебные метки сессии обязательны, и уборщик находит ресурсы прогона именно по ним, так что ваши метки просто мержатся поверх. Name задаёт контейнеру фиксированное имя вместо сгенерированного. Звучит безобидно, но в параллельном мире это мина: два теста с одним именем на одной машине — это два create, и второй упадёт с конфликтом. Имя оправдано, когда оно нужно протоколу: режим Reuse требует имени, а отладочный docker logs приятнее с говорящим. AutoRemove перекладывает удаление на сам Docker: контейнер стирается сразу при останове, и Terminate уже не найдёт, что убирать. Поле в структуре есть, но исходники помечают его Deprecated в пользу HostConfigModifier — современный путь конфигурирует то же самое через хост-настройки, а гарантированную уборку всё равно держит Terminate.

Started: создать, но не запустить#

Started в обёртке GenericContainerRequest — выключатель политики запуска. При true GenericContainer создаёт контейнер и сам же стартует его: вызов не вернётся, пока процесс не поднят и wait-стратегия не пройдена — тест принимает готовый сервис. При false возвращается созданный, но не запущенный контейнер, и момент старта становится вашим: ctr.Start(ctx) вызывается руками в выбранной точке. Зачем это нужно: проверить поведение сервиса, начавшего жить без зависимости, — состояние created вместо running; подправить файлы или окружение после создания, но до запуска; испытать сам старт — что Cmd поднимает процесс, а не роняет его мгновенно. Отдельный сценарий — проверка ошибки запуска: битая команда вернётся из Start, и тест фиксирует причину assert'ом, а не гадает по зелёному экрану. Механика честная: Start исполняет хуки, поднимает процесс и только после запуска ждёт готовности — ранняя смерть процесса вернётся ошибкой из Start, и у теста остаются логи для разбора. Забыли Start? Тест пройдёт зелёным, а контейнер-зомби будет ждать Terminate из Cleanup — или уборщика.

Ручной Start и уборка в Cleanup

GO
func TestManualStart(t *testing.T) {
	ctx := t.Context() // отменится вместе с тестом (Go 1.24)

	ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: testcontainers.ContainerRequest{
			Image:        "myapp/service",
			ExposedPorts: []string{"8080/tcp"},
		},
		Started: false, // создан, но процесс не поднят
	})
	if err != nil {
		t.Fatalf("создание: %v", err)
	}

	// в Cleanup t.Context() уже отменён — Terminate получает свежий контекст
	t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })

	if err := ctr.Start(ctx); err != nil {
		t.Fatalf("старт: %v", err)
	}

	// ... работа с сервисом

	if err := ctr.Stop(ctx, nil); err != nil {
		t.Fatalf("останов: %v", err)
	}
	// Terminate после Stop — не ошибка: force-remove доведёт уборку до конца
	if err := ctr.Terminate(ctx); err != nil {
		t.Fatalf("уборка: %v", err)
	}
}

Started: false возвращает контейнер в состоянии created, и Start вызывается в выбранной тестом точке. t.Context() из Go 1.24 отменяется перед вызовом Cleanup-функций, поэтому внутри Cleanup Terminate получает context.Background — старый контекст к тому моменту уже мёртв. Stop гасит процесс штатно, а Terminate после него ошибкой не падает: безопасные ситуации вроде «уже остановлен» проглатываются, force-remove с томами выполняется. Cleanup срабатывает даже при панике теста.

Путь «testcontainers жизненный цикл» в одном тесте укладывается в шесть шагов, и половина из них скрыта внутри одного вызова. Первый — create: GenericContainer по декларации создаёт контейнер, ещё не запущенный. Второй — start: при Started: true это тот же вызов, при false — ручной ctr.Start. Третий — wait: после поднятия процесса исполняется стратегия из WaitingFor, и урок об ожиданиях покажет, как выбирать между портом, логом и HTTP-пробой; GenericContainer не вернётся, пока она не пройдёт. Четвёртый — работа: тест берёт хост и маппнутый порт и общается с сервисом как с настоящим. Пятый — Terminate: останов с таймаутом и force-remove с томами. Шестой — страховка: уборщик приберёт то, что тест не успел, — его разбор ждёт в восемнадцатом уроке про Ryuk. Порядок шагов фиксирован, и это хорошая новость: один выученный маршрут закрывает и базы, и брокеров, и подменные серверы — меняется лишь декларация.

Соберите путь контейнера

Расставьте этапы жизненного цикла: от декларации до страховки уборщика.

Строки

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

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

Terminate, t.Cleanup и t.Context(): уборка с гарантией#

Terminate — единственный штатный выход из жизни контейнера: сигнатура Terminate(ctx, opts...) принимает контекст и опции, а внутри делает две вещи — останавливает контейнер (с таймаутом, после которого Docker убьёт процесс силой) и удаляет его вместе с томами. Ошибку уборки Terminate возвращает на руки: логируйте её, а не глотайте. Регистрация в t.Cleanup — стандарт: Cleanup-функции выполняются в обратном порядке и всегда — при провале assert'ов и при панике тоже. У библиотеки есть и готовая обёртка: testcontainers.CleanupContainer(t, ctr) регистрирует Terminate в Cleanup за вас — её даже зовут до проверки ошибки создания, потому что nil-контейнер она игнорирует. Контекст для уборки — отдельная тонкость. context.Background живёт до конца процесса: честный, но грубый вариант. t.Context() из Go 1.24 точнее — отменяется вместе с тестом и гасит висящие на нём операции, — но именно поэтому внутри Cleanup он уже отменён, и Terminate там зовут со свежим context.Background. Наконец, Terminate после ручного Stop не ошибка: библиотека считает безобидными ситуации «уже остановлен» и «удаление уже идёт» и всё равно доводит удаление до конца, поэтому дублировать уборку безопасно, а пропустить — нет.

Паника теста — штатный сценарий для уборки: фреймворк testing перехватывает панику, помечает тест проваленным и всё равно прогоняет Cleanup-функции, поэтому Terminate из Cleanup исполнится и после самого ожесточённого падения. Настоящая дыра — смерть процесса: kill по зависшему прогону, os.Exit в чужом коде, съеденный CI-раннер. Ни defer, ни Cleanup при этом не выполнятся, и контейнеры остались бы висеть до ручной зачистки. На этот случай у библиотеки есть супервизор уборки: он заводится вместе с сессией, помечает созданные ресурсы служебными метками и следит за процессом-владельцем — умрёт процесс, супервизор зачистит всё, что тот насоздавал. Как он устроен, сколько живёт и когда его отключают — тема восемнадцатого урока. Привычка проверять уборку на краевых случаях — убить процесс и посмотреть, кто прибрал, — из тех навыков, что прокачивает продвинутый курс по Go: там контексты, горутины и жизненные циклы процессов разбираются глубже.

Проверка: Started false и тишина

Тест передал Started: false и после GenericContainer ни разу не вызвал Start. Что происходит?

Проверка: Terminate после Stop

Тест вызвал ctr.Stop, затем Terminate. Что вернёт Terminate?

Что упадёт при параллельном прогоне

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

// Тест A и тест B помечены t.Parallel().
// У обоих в ContainerRequest:
//     Name: "billing-it", Started: true
// Оба теста стартуют одновременно на одной машине.

Соберите декларацию

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

ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "myapp/service", : map[string]string{"APP_ENV": "test"}, ExposedPorts: []string{"8080/tcp"}, : wait.ForListeningPort("8080/tcp"), }, : true, })

Чек-лист жизненного цикла#

  • декларация — ContainerRequest: Image, Env, ExposedPorts, Cmd, Labels, Files
  • Started: true — запуск и wait внутри GenericContainer; false — ручной ctr.Start
  • wait-стратегия исполняется после старта: тест принимает готовый контейнер
  • Name без нужды не ставить: имя конфликтует при параллельных прогонах
  • Terminate регистрировать в t.Cleanup — сработает при провале и панике
  • внутри Cleanup контекст — context.Background: t.Context() уже отменён
  • Terminate после ручного Stop — не ошибка: force-remove всё равно пройдёт
  • уборка при смерти процесса — работа супервизора: тема урока про Ryuk

Карточки: жизненный цикл

Термин

ContainerRequest

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

Декларация контейнера: Image, Env, ExposedPorts, Cmd, Labels, Files, WaitingFor и прочие поля запуска. Валидируется до обращения к Docker, а провайдер дополняет её служебными метками сессии.

1 / 6

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

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

Вывод

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

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

  • Декларация ContainerRequest описывает весь запуск: Image, Env, ExposedPorts, Cmd, Labels, Files и WaitingFor — docker run, записанный типами.
  • Started живёт в обёртке GenericContainerRequest: true — запуск и wait внутри GenericContainer, false — ручной ctr.Start в выбранной точке.
  • Terminate в t.Cleanup — стандарт уборки: Cleanup выполняется при провале и панике; внутри Cleanup контекст — context.Background, не t.Context().
  • Terminate после ручного Stop не ошибка: безопасные ситуации проглатываются, force-remove с томами доводится до конца.

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

Соберите тест на образ с env-конфигом: Env, ExposedPorts, Cmd, Labels и Files, Started: true, Terminate в t.Cleanup. Затем выключите Started, вызовите Start руками, а после работы выполните Stop и убедитесь, что последующий Terminate вернул nil. Финал: закомментируйте t.Cleanup, запустите go test и найдите контейнер-зомби через docker ps.

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

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

Почему Terminate зовут в t.Cleanup, а не через defer?

Cleanup даёт единый механизм фреймворка: он выполняет функции в обратном порядке при любом исходе теста — провале assert'ов, t.FailNow и панике, — и не зависит от того, доживёт ли функция до выхода. Регистрацию можно сделать сразу после создания контейнера, рядом с ним, и уборка не потеряется среди прочих defer. Отдельный бонус: при t.Parallel порядок уборки фреймворк разруливает сам, а ручные defer в параллельных горутинах легко перепутать местами.

Можно ли обойтись без Terminate — уборщик же зачистит?

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

Что вернёт MappedPort, если wait-стратегия ещё не прошла?

Ничего полезного: сам маппинг порт назначает быстро, но сервис на нём ещё не готов — вы получите число и упадёте на первом же запросе. Дисциплина жизненного цикла в том и состоит, что контейнер возвращается тесту готовым: wait-стратегия исполняется до выхода GenericContainer или Start, поэтому после их возврата хост и порт можно забирать смело.

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

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

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

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

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

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

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