Содержание урока · 10 разделов
- ContainerRequest: декларация будущего контейнера
- Env, Cmd и Files: конфигурация сервиса
- Поля ContainerRequest: кто за что отвечает
- Labels, Name и AutoRemove: метки, имена, удаление
- Started: создать, но не запустить
- Terminate, t.Cleanup и t.Context(): уборка с гарантией
- Чек-лист жизненного цикла
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Контейнер в интеграционном тесте — не магия, а конечный автомат с коротким списком состояний: создан, запущен, готов, отработал, удалён. Пока тест жив, этим автоматом управляете вы: какая декларация ушла в 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, записанный типами: одно поле — один флаг запуска, и ничего из теста в образ не просачивается. Когда модуль базы в четвёртом уроке подставит декларацию поверх вашей, вы уже будете знать, что именно он дописывает.
Тест с полной декларацией
GOfunc 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.
Классическая флапающая сборка: тест объявляет Name и t.Parallel, локально всё зелёное на одном воркере, а на CI два пакета стартуют одновременно — и второй create падает с конфликтом имён, пока первый жив. Ни один тест не виноват: фиксированное имя — ресурс в единственном экземпляре, его разбирает первый успевший. Пограничный случай страшнее: контейнер с тем же именем не убрался после прошлого прогона — create падает до старта логики, и тест краснеет на пустом месте. Лечение — не задавать Name вовсе: Docker сгенерирует уникальное имя сам. Спорное мнение с условием: говорящее имя упрощает docker logs при отладке — включайте его в отдельном отладочном прогоне, а не в общем наборе.
Started: создать, но не запустить#
Started в обёртке GenericContainerRequest — выключатель политики запуска. При true GenericContainer создаёт контейнер и сам же стартует его: вызов не вернётся, пока процесс не поднят и wait-стратегия не пройдена — тест принимает готовый сервис. При false возвращается созданный, но не запущенный контейнер, и момент старта становится вашим: ctr.Start(ctx) вызывается руками в выбранной точке. Зачем это нужно: проверить поведение сервиса, начавшего жить без зависимости, — состояние created вместо running; подправить файлы или окружение после создания, но до запуска; испытать сам старт — что Cmd поднимает процесс, а не роняет его мгновенно. Отдельный сценарий — проверка ошибки запуска: битая команда вернётся из Start, и тест фиксирует причину assert'ом, а не гадает по зелёному экрану. Механика честная: Start исполняет хуки, поднимает процесс и только после запуска ждёт готовности — ранняя смерть процесса вернётся ошибкой из Start, и у теста остаются логи для разбора. Забыли Start? Тест пройдёт зелёным, а контейнер-зомби будет ждать Terminate из Cleanup — или уборщика.
Ручной Start и уборка в Cleanup
GOfunc 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 не ошибка: библиотека считает безобидными ситуации «уже остановлен» и «удаление уже идёт» и всё равно доводит удаление до конца, поэтому дублировать уборку безопасно, а пропустить — нет.
Честная оговорка: в песочнице сайта этот урок не запустится — GenericContainer требует живого демона Docker, а браузерная среда исполняет тематические фрагменты без демонов. Рабочая схема локальная: сохраните код урока в файл *_test.go рядом с go.mod и запустите go test — весь путь от create до remove пройдёт на вашей машине. Весь код урока разбирается построчно, так что отсутствие Docker чтению не мешает. Домашний эксперимент: docker ps до и после теста — контейнер обязан исчезнуть; закомментируйте t.Cleanup и найдите зомби тем же docker ps.
Паника теста — штатный сценарий для уборки: фреймворк 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
// Оба теста стартуют одновременно на одной машине.Соберите декларацию
Восстановите декларацию: конфиг сервиса, ожидание готовности и политика запуска.
Чек-лист жизненного цикла#
- декларация — 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
Карточки: жизненный цикл
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Terminate зовут в t.Cleanup, а не через defer?
Cleanup даёт единый механизм фреймворка: он выполняет функции в обратном порядке при любом исходе теста — провале assert'ов, t.FailNow и панике, — и не зависит от того, доживёт ли функция до выхода. Регистрацию можно сделать сразу после создания контейнера, рядом с ним, и уборка не потеряется среди прочих defer. Отдельный бонус: при t.Parallel порядок уборки фреймворк разруливает сам, а ручные defer в параллельных горутинах легко перепутать местами.
Можно ли обойтись без Terminate — уборщик же зачистит?
В конце сессии — да: уборщик находит ресурсы по служебным меткам и стирает их. Но между тестами контейнеры живут зря: параллельный прогон тратит память и порты, а упавшая сборка оставляет зомби до конца сессии. Terminate из t.Cleanup освобождает ресурс сразу и предсказуемо, а супервизор остаётся страховкой на случай смерти процесса — это разные слои защиты, и работают они вместе.
Что вернёт MappedPort, если wait-стратегия ещё не прошла?
Ничего полезного: сам маппинг порт назначает быстро, но сервис на нём ещё не готов — вы получите число и упадёте на первом же запросе. Дисциплина жизненного цикла в том и состоит, что контейнер возвращается тесту готовым: wait-стратегия исполняется до выхода GenericContainer или Start, поэтому после их возврата хост и порт можно забирать смело.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах