Содержание урока · 10 разделов
- GenericContainer: один вход для любого образа
- GenericContainerRequest: декларация и обёртка
- Модуль против GenericContainer
- Started: true против ручного Start
- Host и MappedPort: адрес живого сервиса
- Terminate: уборка через t.Cleanup
- Типовые ошибки с произвольным образом
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
GenericContainer — самая старая и самая универсальная функция библиотеки: она принимает декларацию произвольного образа и возвращает контейнер, не спрашивая, что это за сервис. Вся остальная библиотека построена вокруг неё: модули прошлых уроков — пресеты, которые собирают ту же декларацию за вас, а современная функция Run — тонкая обёртка над ней. Универсальность оборачивается ответственностью: модулю порты и стратегию готовности подсказывает автор модуля, здесь их выбираете вы. Ничего нового учить не придётся: декларация ContainerRequest разобрана в третьем уроке, а список нужных полей короткий — образ, окружение, порты, команда, wait-стратегия. Незнакомый демон перестаёт быть проблемой: если сервис работает в docker run, он заработает и в тесте — тот же образ, те же аргументы, тот же маппинг портов, только с гарантированной уборкой. Отдельный бонус — скорость: новый сторонний сервис в цепочке зависимостей больше не блокирует тесты — декларация, вызов, проверка. GenericContainer часто становится первым шагом знакомства с библиотекой и остаётся рабочей лошадкой для служебных контейнеров.
GenericContainer: один вход для любого образа#
Сигнатура короткая: GenericContainer принимает контекст и GenericContainerRequest, возвращает пару — контейнер и ошибку. Тип результата принципиален: не конкретная структура, а интерфейс testcontainers.Container, одинаковый для любого образа. В интерфейсе собрано всё, что нужно тесту: Host и MappedPort дают адрес, Logs отдаёт поток логов для разбора упавшего старта, Exec запускает команду внутри, CopyFileToContainer кладёт файл, Terminate прибирает. Переход от модуля к произвольному образу не меняет клиентский код: методы те же. Связка «genericcontainer go» ценна именно этим: сценарий «testcontainers любой образ» — nginx, minio, файловый прокси, внутренний демон — закрывается одним вызовом без поиска подходящего модуля и без лишней зависимости в go.mod. Ошибка возвращается на руки вместе с контейнером, если тот успел создаться: вызывающему оставляют шанс прибрать за собой — к этому моменту мы вернёмся отдельно. Интерфейс шире, чем нужно одному тесту: Inspect возвращает сырой инспект Docker, State отличает бегущий контейнер от остановленного, а SessionID отдаёт идентификатор сессии, по которому уборщик находит ресурсы прогона.
Произвольный образ в тесте
GOfunc TestNginxServes(t *testing.T) {
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "nginx",
ExposedPorts: []string{"80/tcp"},
WaitingFor: wait.ForHTTP("/"),
},
Started: true,
})
if err != nil {
t.Fatalf("запуск: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(ctx) })
host, err := ctr.Host(ctx)
if err != nil {
t.Fatalf("хост: %v", err)
}
port, err := ctr.MappedPort(ctx, "80/tcp")
if err != nil {
t.Fatalf("порт: %v", err)
}
resp, err := http.Get(fmt.Sprintf("http://%s:%s/", host, port.Port()))
if err != nil {
t.Fatalf("запрос: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("статус: %d", resp.StatusCode)
}
}Декларация минимальна: образ, порт с протоколом и стратегия ForHTTP, которая дёргает корень и ждёт ответа. Started: true — вызов не вернётся, пока nginx не начнёт отвечать. После возврата Host и MappedPort собирают адрес: наружный порт Docker назначил из свободных, поэтому узнают его только после старта. Terminate зарегистрирован в t.Cleanup — сработает при провале и панике. Финал — обычный http.Get: сервис проверен как настоящий.
GenericContainerRequest: декларация и обёртка#
Запрос к GenericContainer — обёртка с вложенной декларацией. Внутри — знакомый ContainerRequest: Image, Env, ExposedPorts, Cmd, Labels, Files, WaitingFor; полный разбор полей — в третьем уроке. Для произвольного образа обычно хватает четырёх: Image, Env, ExposedPorts и WaitingFor, а Cmd нужен, когда демон требует аргументов запуска. Снаружи — политика запуска, у которой четыре собственных поля. Started включает автостарт: true — create, start и ожидание готовности внутри вызова, false — только create. ProviderType выбирает провайдера: нулевое значение — автодетект по переменной DOCKER_HOST, а набор провайдеров ограничен Docker и Podman. Logger подменяет логгер для этого контейнера — спасает, когда общий вывод тонет в логах параллельного прогона. Reuse включает режим переиспользования: библиотека найдёт живой контейнер с тем же Name или создаст новый, а при пустом имени вернёт ошибку ErrReuseEmptyName — поле Name нельзя оставлять незаполненным. Перед обращением к демону декларацию проверяет Validate: попытка указать одновременно Image и контекст сборки отсекается до docker create, а пустая декларация без образа и контекста отвергается с требованием указать хоть что-то — забыть Image и получить загадочный контейнер не выйдет, тест упадёт на валидации с внятной ошибкой.
Модуль против GenericContainer#
| Критерий | Модуль | GenericContainer |
|---|---|---|
| Когда выбирать | под образ есть модуль: Postgres, Redis, Kafka | модуля нет: nginx, minio, внутренний демон команды |
| Вход в API | Run из пакета модуля с опциями | GenericContainer с декларацией в руках |
| Возвращаемый тип | конкретный: RedisContainer, MinioContainer | общий интерфейс testcontainers.Container |
| Готовность | wait-стратегия встроена авторами модуля | стратегию выбираете сами в WaitingFor |
| Удобные методы | ConnectionString, Brokers и подобные | общие: Host, MappedPort, Logs, Exec |
| Настройка | опции модуля: WithUsername, WithConfigFile | поля декларации: Env, Cmd, Files, Mounts |
| Движок под капотом | Run, а под ним тот же GenericContainer | сам GenericContainer |
Started: true против ручного Start#
Started: true — путь по умолчанию: GenericContainer сам создаёт, стартует и ждёт готовности, возвращая сервис, с которого сразу можно брать адрес и слать запросы. Started: false возвращает созданный, но не запущенный контейнер, и момент старта переезжает в тест: ctr.Start(ctx) вызывается в выбранной вами точке. Ручной старт нужен, когда между созданием и запуском должно что-то случиться: скопировать конфиг внутрь уже созданного контейнера, дождаться соседа по стенду, подключить сеть; или когда тестируется сам запуск — команда из Cmd обязана поднять процесс, а не уронить его мгновенно. Важная деталь механики: при ошибке старта GenericContainer возвращает и ошибку, и контейнер — тот уже создан, а значит, его можно прибрать и прочитать логи. Проверка «а что, если сервис не стартует» пишется честно: Start вернул ошибку — assert на неё, Terminate в Cleanup — как обычно. Ещё одна тонкость автостарта: Started: true не перезапускает уже работающий контейнер — перед Start библиотека проверяет IsRunning и пропускает запуск, если процесс жив. Для переиспользуемых контейнеров это критично: второй вызов с тем же Name не упадёт, а тихо вернёт существующий сервис.
Привычка «ошибка значит nil» здесь ломается: исходники GenericContainer прямо оставляют контейнер в руках вызывающего при провале create и start, чтобы тот мог вызвать уборку. Отсюда порядок из документации: CleanupContainer(t, ctr) вызывают сразу после GenericContainer, до проверки ошибки — на nil-контейнере это no-op, на живом — гарантированный Terminate. Практичный приём: логи упавшего старта печатают прямо в тесте перед Fatalf — поток достаётся через Logs(ctx), и причина «container exited» становится видной без перезапуска. Если пишете уборку руками, не привязывайте её к успешному запуску: err != nil с ранним выходом без уборки оставит контейнер до конца сессии.
Host и MappedPort: адрес живого сервиса#
Адрес сервиса собирается из двух вызовов. Host возвращает адрес, на котором демон выставил порт: при локальном docker это localhost, на удалённом демоне CI-раннера — его хост, поэтому адрес всегда берут из вызова, а не хардкодят. MappedPort принимает пару «порт/протокол» — например, 80/tcp — и возвращает наружный порт, который Docker назначил из свободных при старте; именно поэтому порт нельзя угадать заранее и нельзя зафиксировать декларацией. Кто не хочет склеивать адрес вручную, берёт готовое: PortEndpoint вернёт строку вида proto://host:port по заданному порту и протоколу, а Endpoint — то же для самого низкого выставленного порта. Нюанс сетевого режима host: маппинга нет, порт контейнера равен порту хоста, и MappedPort вернёт его как есть. Готовность к моменту возврата гарантирует wait-стратегия: без WaitingFor библиотека вернёт контейнер сразу после поднятия процесса, и первый запрос может прийти к сервису, который ещё не слушает порт. Каталог стратегий — порт, лог, HTTP-проба, команда — разобран в уроке про ожидания. Порт возвращается строкой, так что склейка адреса не требует конвертаций: port.Port() отдаёт готовое значение, и fmt.Sprintf собирает адрес за один вызов. Для образов с единственным портом удобен Endpoint: он берёт самый низкий выставленный порт, который почти всегда и есть рабочий.
Ручной Start и проверка готовности
GOfunc TestMinioReady(t *testing.T) {
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "minio/minio",
Env: map[string]string{
"MINIO_ROOT_USER": "itest",
"MINIO_ROOT_PASSWORD": "itest-secret",
},
Cmd: []string{"server", "/data"},
ExposedPorts: []string{"9000/tcp"},
WaitingFor: wait.ForHTTP("/minio/health/live").WithPort("9000"),
},
Started: false, // создан, но не запущен: старт — на совести теста
})
if err != nil {
t.Fatalf("создание: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
// между create и start можно подложить конфиг или проверить соседей
if err := ctr.Start(ctx); err != nil {
t.Fatalf("старт: %v", err)
}
endpoint, err := ctr.PortEndpoint(ctx, "9000", "http")
if err != nil {
t.Fatalf("адрес: %v", err)
}
resp, err := http.Get(endpoint + "/minio/health/ready")
if err != nil {
t.Fatalf("проба готовности: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("готовность: %d", resp.StatusCode)
}
}Started: false возвращает контейнер в состоянии created, и Start зовётся в выбранной тестом точке — например, после подкладки конфига. Стратегия WaitingFor исполняется внутри Start, поэтому после его возврата сервис уже отвечает на health-пробу. PortEndpoint собирает готовый адрес без ручной склейки хоста и порта. В Cleanup Terminate получает context.Background: t.Context() к тому моменту уже отменён.
Terminate: уборка через t.Cleanup#
Универсальный контейнер убирается по тем же правилам, что и модульный. Стандарт — t.Cleanup(func() { _ = ctr.Terminate(ctx) }): Cleanup-функции выполняются в обратном порядке и всегда — при провале assert'ов и при панике. Контекст для уборки — context.Background: если в теле теста вы пользовались t.Context(), к моменту Cleanup он уже отменён, и Terminate с мёртвым контекстом не успеет договориться с демоном. Удобнее готовая обёртка CleanupContainer(t, ctr): она регистрирует Terminate в Cleanup за вас, зовётся сразу после GenericContainer — до проверки ошибки — и молчит на nil. Для мест, где testing.TB недоступен, есть TerminateContainer(ctr). Ошибку уборки возвращайте тесту, а не глотайте: молчаливый провал Terminate вылезет исчерпанными портами в следующем прогоне. Страховка на случай смерти процесса — работа уборщика-супервизора, его разбор живёт в уроке про Ryuk; здесь достаточно привычки: создал контейнер — зарегистрируй уборку. Cleanup-функции исполняются в обратном порядке регистрации: если тест поднимает цепочку сервисов, последний созданный гаснет первым — порядок зависимостей соблюдается без ручного управления.
Тот же образ через Run
GOfunc TestNginxViaRun(t *testing.T) {
ctx := context.Background()
// Run: тот же движок, декларация собирается опциями
ctr, err := testcontainers.Run(ctx, "nginx",
testcontainers.WithExposedPorts("80/tcp"),
testcontainers.WithWaitStrategy(wait.ForHTTP("/")),
)
testcontainers.CleanupContainer(t, ctr) // до проверки ошибки: nil — no-op
if err != nil {
t.Fatalf("запуск: %v", err)
}
uri, err := ctr.PortEndpoint(ctx, "80", "http")
if err != nil {
t.Fatalf("адрес: %v", err)
}
resp, err := http.Get(uri)
if err != nil {
t.Fatalf("запрос: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("статус: %d", resp.StatusCode)
}
}Run — современная обёртка того же движка: образ идёт первым аргументом, Started: true зашит внутри, а настройки передаются опциями — WithExposedPorts, WithWaitStrategy, WithEnv и другими. Возвращается конкретный *DockerContainer, а не интерфейс. Под капотом — всё тот же GenericContainer, поэтому переезд между стилями механический. Документация рекомендует Run, сам GenericContainer устаревшим не помечен. Опции композируются: к этому же вызову добавляются WithEnv, WithCmd, WithLabels — декларация собирается по частям.
Проверка: ошибка старта
GenericContainer вернул и контейнер, и ошибку: Start не смог поднять процесс. Что делать с контейнером?
Проверка: наружный порт
Тест знает порт 8080/tcp внутри контейнера. Как узнать адрес сервиса снаружи?
Что вернёт MappedPort после ручного Start
Прочитайте код и выберите, что он выведет:
// GenericContainerRequest:
// Started: false, WaitingFor: wait.ForHTTP("/")
// Тест:
// err := ctr.Start(ctx) // вернул nil
// port, err := ctr.MappedPort(ctx, "80/tcp")
// resp, err := http.Get("http://host:" + port.Port())Соберите декларацию произвольного образа
Восстановите декларацию: окружение, порты, готовность и политика запуска.
Соберите generic-тест
Расставьте шаги теста с произвольным образом: от выбора до уборки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Сценарий «testcontainers любой образ» уложился в один маршрут: декларация поле за полем, один вызов, адрес через Host и MappedPort, уборка в Cleanup. Выбор между тремя входами теперь осознанный. Модуль — когда он есть и покрывает задачу: готовые порты, wait и удобные методы экономят десяток строк и защищают от чужих граблей. Run — короткий современный путь для быстрых деклараций. GenericContainer — когда модуля нет, служебный образ не стоит лишней зависимости в go.mod или нужен полный контроль над запуском. Логичное продолжение — свой образ, собранный из Dockerfile прямо в тесте: им займётся урок про сборку собственного образа. Правило простое: пока нужного модуля нет, generic закрывает дыру немедленно — образ из реестра, несколько полей декларации, зелёный тест. А если готовности из коробки мало — wait-стратегии из следующего урока научат ждать не порт, а осмысленный сигнал: строку в логе, HTTP-пробу, ответ команды.
Типовые ошибки с произвольным образом#
- ждать наружный порт из декларации: Docker назначает его при старте, адрес узнают через MappedPort после возврата вызова
- оставить WaitingFor пустым: контейнер вернётся сразу после поднятия процесса, первый запрос придёт в тишину
- выходить из теста при err != nil без CleanupContainer: контейнер при ошибке старта не nil и провисит до конца сессии
- писать Started во вложенном ContainerRequest: политика запуска живёт в обёртке GenericContainerRequest
- хардкодить localhost вместо Host(ctx): на удалённом демоне адрес другой
- указать одновременно Image и Context: Validate отсечёт декларацию до обращения к демону
- включать Reuse без Name: вернётся ErrReuseEmptyName
- заменять wait-стратегию сном: ForHTTP, ForListeningPort и ForLog уже умеют ждать готовность
Честная оговорка: в песочнице сайта этот урок не исполняется — GenericContainer требует живого демона Docker, а браузерная среда запускает только тематические фрагменты без контейнеров. Рабочая схема локальная: сохраните код урока в файл *_test.go рядом с go.mod и запустите go test — образы nginx и minio подтянутся из реестра при первом прогоне. Код разобран построчно, так что отсутствие Docker чтению не мешает.
Карточки: GenericContainer
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- GenericContainer — универсальный вход: GenericContainerRequest собирает декларацию ContainerRequest и политику запуска, возвращает общий интерфейс Container.
- Started: true — старт и wait внутри вызова; false — ручной Start; при ошибке старта контейнер возвращается вместе с ошибкой и остаётся на уборке.
- Адрес сервиса: Host плюс MappedPort("порт/tcp") или готовая строка PortEndpoint; наружный порт назначает Docker, декларацией его не зафиксировать.
- Уборка — Terminate в t.Cleanup или CleanupContainer(t, ctr) до проверки ошибки; Run — современная обёртка, модули построены поверх неё.
Домашнее задание
Поднимите через GenericContainer любой образ без модуля: Env, Cmd, ExposedPorts, WaitingFor, Started: true, Terminate в t.Cleanup и проверка HTTP-ответа через http.Get. Затем выключите Started, вызовите Start руками и убедитесь, что PortEndpoint даёт рабочий адрес. Напоследок сломайте Cmd заведомо несуществующей командой и проверьте, что GenericContainer вернул контейнер вместе с ошибкой, а CleanupContainer прибрал его без ручных проверок на nil. Сверьте docker ps до и после прогона: ни одного оставшегося контейнера.
Личные заметкиПусто
Вопросы и ответы по уроку
Когда нужен GenericContainer, если есть модули и Run?
Модуль — пресет: он сам выставляет порты, wait-стратегию и удобные методы вроде ConnectionString. Run — короткий современный путь с опциями. GenericContainer берут, когда модуля под образ нет, служебный контейнер не тянет отдельную зависимость или нужен полный контроль над декларацией. Под всеми тремя входами работает один и тот же движок, так что переезд между ними механический: модуль — готовый рецепт, generic — продуктовая корзина.
Что вернёт GenericContainer при ошибке?
Зависит от этапа: если create не удался и контейнера нет, вернётся nil вместе с ошибкой. Если же create прошёл, а Start упал, вернутся и ошибка, и контейнер: исходники сознательно отдают его вызывающему для уборки и чтения логов. Поэтому CleanupContainer зовут до проверки ошибки — на nil это no-op. Тип возвращаемого значения — главное отличие этого вызова: интерфейс вместо конкретной структуры.
Обязательно ли указывать WaitingFor?
Технически нет: библиотека вернёт контейнер сразу после поднятия процесса. Практически — без стратегии первый запрос может прийти к сервису, который ещё не слушает порт: готовность сегодня определяют только wait-стратегии, неявная проверка всех выставленных портов из старых версий убрана. Для HTTP-сервиса хватает ForHTTP("/"), для демона без HTTP — ForListeningPort или ForLog, а ForAll объединяет несколько сигналов в одно условие: порт плюс строка в логе надёжнее одиночного сигнала.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах