Содержание урока · 10 разделов
Механика одинакова для всех стратегий. Поле WaitingFor в декларации контейнера принимает значение интерфейса wait.Strategy, а исполняет его PostStart-хук: процесс стартовал — библиотека печатает строку с человекочитаемым описанием условия и зовёт его метод WaitUntilReady. Пока тот не вернёт nil, GenericContainer не отдаёт управление: тест физически не может получить недогретый контейнер. Ошибка ожидания оборачивается в wait until ready и роняет запуск — с логами, по которым видно, какого условия не дождались. Бросить опрос на полпути библиотека тоже не даст: если процесс умер, пока стратегия ещё тикала, состояние контейнера вернёт осмысленную ошибку — OOMKilled или код выхода, — а не молчаливый таймаут. Картинку полного жизненного цикла мы собрали в одиннадцатом уроке, а здесь раскладываем «testcontainers wait» по слоям: базовые стратегии, комбинаторы, тайминги и свои типы. Сценарий один и тот же у всех модулей курса, от Postgres до брокеров, — меняется лишь декларация.
Sleep вместо стратегии: почему это боль#
Фиксированная пауза — самый дорогой способ решить задачу ожидания контейнера go. Поставьте паузу на пять секунд: на локальной Docker-машине сервис готов через восемьсот миллисекунд, и каждая лишняя секунда — плата за прогон, размноженная числом тестов; на перегруженном CI всё наоборот — двадцать секунд миграции не влезли в вашу паузу, и тест падает без всякой вины сервиса. Флейк из-за сна коварнее остальных: он зелёный девять прогонов из десяти, и ловят его на ветке релиза. Стратегия меняет саму постановку: не «сколько подождать», а «какое событие означает готовность». Тест спит ровно ноль: опрос идёт каждые сто миллисекунд, и управление возвращается в момент события — не раньше и не позже. Побочный бонус — диагностика: стратегия попадает в лог прогона своим описанием вида «log message ready» или «port 5432/tcp to be listening», так что при падении видно, какое условие не сошлось, а не гадание по таймеру. Отсюда и побочный эффект: конфигурация теста перестаёт зависеть от мощности машины — один и тот же прогон занимает одинаковое время на ноутбуке и в CI, потому что нигде не хранится магическое число секунд, подобранное наугад.
Базовая декларация с ForListeningPort
GOfunc TestOrderService(t *testing.T) {
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "myapp/orders",
ExposedPorts: []string{"8080/tcp"},
WaitingFor: wait.ForListeningPort("8080/tcp").
WithStartupTimeout(30 * time.Second).
WithPollInterval(500 * time.Millisecond),
},
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")
// сервис гарантированно слушает порт — можно стучаться
base := "http://" + net.JoinHostPort(host, port.Port())
_ = base
}Стратегия в WaitingFor собирается цепочкой билдеров: ForListeningPort создаёт стратегию порта, WithStartupTimeout срезает дефолтные шестьдесят секунд до тридцати, WithPollInterval растит частоту опроса с сотни миллисекунд до полусекунды. GenericContainer не вернётся, пока порт не откроется, и к моменту возврата MappedPort уже осмыслен. Cleanup с Terminate регистрируем сразу после проверки ошибки — привычка из жизненного цикла.
ForListeningPort: порт как сигнал готовности#
Стратегия порта проверяет готовность в два слоя. Внешний — со стороны хоста: библиотека достаёт маппнутый порт и диалится по TCP, как это сделал бы настоящий клиент; соединение refused — значит, сервис ещё не слушает. Внутренний — изнутри контейнера: запускается короткая команда на /bin/sh, которая смотрит /proc/net/tcp, пробует nc и bash-овский /dev/tcp — открыт ли порт на интерфейсах контейнера. Двухслойность спасает от случая, когда Docker смаппировал порт, а процесс ещё не привязался к нему. У образов без шелла внутренняя проверка не пройдёт в принципе: библиотека заметит код выхода 126 или 127, напишет в лог и продолжит валидировать только внешний доступ — стратегия не ломается, но знать про это поведение полезно. Для нестандартных ситуаций есть переключатели: SkipInternalCheck пригодится, когда сервис привязывает порт позже дополнительных условий, ForMappedPort вообще не трогает порт и ждёт только завершение маппинга, а ForExposedPort ждёт первый объявленный порт, если указывать конкретный незачем. Знание этого двухслойного протокола пригодится при отладке: если тест завис в ожидании порта, смотрите, на каком слое застряли, — в логах прогона видны попытки внешнего диала и внутренние exec-команды.
ForLog, ForHTTP и ForSQL: событие вместо порта#
Порт отвечает раньше, чем сервис готов принимать полезную нагрузку, поэтому событийные стратегии часто честнее. ForLog читает docker logs и ждёт подстроку: модуль Redis ловит фирменную строку готовности Redis, а модуль Postgres — «database system is ready to accept connections» с WithOccurrence(2), потому что Postgres печатает её дважды при восстановлении. Подстрока ищется чувствительно к регистру; нужен шаблон — AsRegexp переключает стратегию на регулярное выражение. ForHTTP шлёт настоящие запросы: ForHTTP на путь healthz ходит на наименьший открытый TCP-порт, ждёт статус из StatusCodeMatcher — по умолчанию 200 — и опционально сверяет тело через WithResponseMatcher; порт уточняется WithPort, метод — WithMethod, Basic-доступ — WithBasicAuth, а WithForcedIPv4LocalHost чинит docker-баги с IPv6. ForSQL поднимает планку до уровня протокола: ForSQL принимает порт, имя драйвера и функцию сборки URL и до победы выполняет SELECT 1 через database/sql — драйвер должен быть зарегистрирован, а запрос меняется WithQuery. Модуль CockroachDB именно так ждёт движок: HTTP на health плюс ForSQL с драйвером pgx. Из той же серии ForExit — ожидание, пока контейнер завершится сам: идеально для одноразовых задач вроде миграций, где успех — это именно выход.
Сервис → стратегия#
| Сервис | Стратегия | Почему так |
|---|---|---|
| Web-сервис с healthz | wait.ForHTTP("/healthz").WithPort("8080/tcp") | проверяет прикладную готовность, а не факт запуска процесса |
| Postgres через модуль | ForLog готовности с WithOccurrence(2) | строка готовности печатается дважды — ждём вторую |
| Redis через модуль | ForListeningPort плюс ForLog | порт и фирменная строка готовности — двойное подтверждение |
| Kafka-брокер | ForLog с AsRegexp | готовность видна по переходу фазы в логе брокера |
| База без модуля | ForSQL("5432/tcp", "pgx", url) | SELECT 1 — честный ответ движка, а не открытый порт |
| Образ с HEALTHCHECK | wait.ForHealthCheck() | Docker сам следит за здоровьем — переиспользуем вердикт |
| Одноразовый мигратор | wait.ForExit() | контейнер должен завершиться сам — ждём выхода, а не порта |
| Сервис с маркер-файлом | wait.ForFile("/tmp/ready") | приложение пишет файл, когда прогрелось |
ForAll и ForAny: комбинируем условия#
Реальному сервису мало одного условия, и вот здесь включаются комбинаторы. ForAll — конъюнкция: стратегии исполняются по очереди, и ожидание пройдёт, когда каждая вернёт nil; порядок имеет значение — порт логично проверить раньше HTTP-пробы, чтобы запросы не улетали в ещё закрытый порт. Общий бюджет задаёт WithDeadline: дедлайн накрывает все вложенные стратегии разом, а WithStartupTimeoutDefault подставляет таймаут только тем из них, у которых личного нет. ForAny — дизъюнкция: каждая стратегия стартует в своей горутине, первый успех отменяет остальные, и ожидание завершается. У дизъюнкции жёсткая семантика провала: любая стратегия, вернувшая ошибку, немедленно роняет всё ожидание, даже если соседняя вот-вот бы прошла, — ретраев внутри комбинатора нет. Устаревший WithStartupTimeout у ForAll не сломает старый код: он просто передаёт значение в WithDeadline, и набор по-прежнему ограничивается целиком. Приятная мелочь: nil-элементы внутри списка комбинатор молча пропускает — модуль может дописывать стратегии по ходу инициализации, не проверяя каждый элемент на пустоту. Практический ориентир: два-три условия легко живут в ForAll с одним дедлайном; больше — повод задуматься, не ждёте ли вы взаимоисключающих событий, и не превратится ли набор в вечный список, где одно условие не сработает никогда.
ForAll: порт и лог вместе
GOctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:17-alpine",
Env: map[string]string{"POSTGRES_PASSWORD": "s3cret"},
ExposedPorts: []string{"5432/tcp"},
WaitingFor: wait.ForAll(
wait.ForListeningPort("5432/tcp"),
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
).WithDeadline(45 * time.Second),
},
Started: true,
})
if err != nil {
t.Fatalf("postgres не готов: %v", err)
}ForAll требует оба условия: сначала откроется порт, затем лог готовности появится второй раз — так отсекается окно, когда Postgres напечатал строку и ушёл в восстановление. WithDeadline ставит общий бюджет в сорок пять секунд на оба условия, и он не суммируется с личными таймаутами стратегий. Этот набор — дословная связка, которой живёт встроенный модуль Postgres.
ForAny обрабатывает ошибки по-жёсткому. Представьте пару «HTTP-проба или лог готовности»: проба настроена на старый путь, сервис его уже не отдаёт, и через минуту она вернёт ошибку — на тридцатой секунде лог совпал бы, но ForAny уже похоронил всё ожидание. Дизъюнкция выигрывает скорость только на честных условиях: каждое из них обязано быть валидным само по себе. Любимый костыль — подкладывать в ForAny всегда проходящую запасную стратегию: работает, но маскирует неверную настройку вместо того, чтобы её чинить.
Таймауты и интервалы: чей бюджет на что#
У каждой стратегии личный таймаут старта — по умолчанию шестьдесят секунд — и опрос каждые сто миллисекунд. WithStartupTimeout сужает личный бюджет: Redis-модуль даёт порту десять секунд, LocalStack просит сто двадцать на HTTP-пробу — тяжёлые образы просто не успевают за дефолтом. На комбинаторах арифметика другая: WithDeadline накрывает весь набор целиком и не складывается с личными таймаутами — сработает более короткий контекст. Интервал опроса — отдельная ручка WithPollInterval: сотня миллисекунд хороша для дешёвых проверок, но HTTP-проба ходит по сети на каждой итерации, и для неё ставят полсекунды или секунду. Щедрый таймаут вредит не меньше короткого: реальный сбой вместо быстрого красного теста минуту держит прогон в тишине, умножая это на число тестов. Отмена идёт через контекст, и при истечении бюджета ошибка уносит число сделанных попыток и последнюю причину — разбор не начинается с нуля. Страховка снизу тоже есть: пока стратегия ждёт, библиотека сверяет состояние контейнера, и процесс, умерший от OOM или завершившийся с кодом, возвращает ошибку немедленно — молчаливого ожидания мёртвого контейнера не случится. Заметьте: HTTP-проба ограничена и собственным таймаутом клиента в секунду на запрос — зависший сервис не заблокирует опрос дольше этого окна, и стратегия просто сделает следующую попытку.
Своя стратегия: интерфейс wait.Strategy
GOtype cmdReady struct {
cmd []string
interval time.Duration
}
// WaitUntilReady — единственный метод интерфейса wait.Strategy
func (r cmdReady) WaitUntilReady(ctx context.Context, target wait.StrategyTarget) error {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
code, _, err := target.Exec(ctx, r.cmd)
if err != nil {
return err
}
if code == 0 {
return nil
}
}
}
}
// использование в декларации:
// WaitingFor: cmdReady{
// cmd: []string{"/app/check.sh"},
// interval: 250 * time.Millisecond,
// }Интерфейс стратегии — один метод: WaitUntilReady получает контекст и StrategyTarget — обёртку над контейнером с Host, Inspect, MappedPort, Logs, Exec и State. Наша стратегия тикает с заданным интервалом и ждёт нулевой код выхода служебной команды; отмена приходит через ctx.Done, как у встроенных стратегий. Ноль кода — договорённость ExecStrategy, поэтому при желании сравнивайте и другие коды под свой протокол.
Встроенные стратегии закрывают девять десятых случаев, а остальное добирают комбинаторы и свои типы. Помните про модули: Redis, Postgres и Kafka подставляют наборы условий сами, а CustomizeRequest-опции WithWaitStrategy и WithAdditionalWaitStrategy заменяют или дополняют их — вторая собирает общую ForAll с дедлайном в шестьдесят секунд. Из экзотики пригодятся ForExec — готовность по коду выхода команды внутри контейнера — и ForNop, оборачивающий вашу функцию в стратегию без отдельного типа. Дисциплина выбора простая: порт для утилит и образов без прикладных сигналов, лог для сервисов со строкой старта, HTTP для прикладных проб, SQL для движков баз. Такое «ожидание контейнера go» переживает и медленный CI, и быстрый ноутбук, не тратя лишних секунд. И помните про параллельные прогоны: чем короче честное ожидание, тем быстрее проходит весь набор тестов. Дальше по курсу testcontainers — тринадцатый урок про логи и файлы, а драйверную часть для ForSQL удобно копать в курсе по pgx.
Проверка: образ без шелла
Вы ждёте ForListeningPort("9000/tcp") от образа, в котором нет /bin/sh. Что произойдёт?
Проверка: ForAny и медленная ошибка
В ForAny переданы две стратегии: первая вернёт ошибку через минуту, вторая — nil через пять секунд. Чем закончится ожидание?
Что вернёт GenericContainer
Прочитайте код и выберите, что он выведет:
// сервис печатает в лог:
// Server started on :8080
WaitingFor: wait.ForLog("server started").
WithStartupTimeout(10 * time.Second),Соберите комбинатор
Восстановите декларацию: комбинация порта и лога готовности плюс политика запуска.
Соберите путь ожидания
Расставьте шаги: от декларации стратегии до готового контейнера и граничного падения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Типовые ошибки ожидания#
- Sleep вместо стратегии: медленно на локальной машине и флейково на CI
- ждать лог без WithOccurrence, когда строка готовности печатается повторно
- ForHTTP без WithPort на многопортовом образе: запросы уходят на наименьший порт, а не на нужный
- шаблон регулярки без AsRegexp: строка ищется как обычная подстрока
- дефолтный интервал опроса на тяжёлой HTTP-пробе: лишние сетевые походы на каждой итерации
- щедрый таймаут вместо честного: сбой держит прогон минуту в тишине
- своя стратегия игнорирует ctx.Done: опрос продолжается после отмены теста
- ForAny как страховка от неверной настройки: первая же ошибка валит всё ожидание
Честная оговорка: в песочнице сайта wait-стратегии не исполнятся — для опроса контейнера нужен живой демон Docker, которого в браузерной среде нет. Локальная схема рабочая: сохраните код урока в файл *_test.go рядом с go.mod, поднимите Docker на своей машине и запустите go test — тайминги опроса и таймауты видны в логах прогона вместе со строкой описания стратегии. Даже без Docker урок читается целиком: каждый фрагмент разобран построчно. Домашний эксперимент: поставьте WithStartupTimeout в одну миллисекунду и рассмотрите сообщение таймаута.
Карточки: wait-стратегии
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Wait-стратегия — интерфейс wait.Strategy с единственным методом WaitUntilReady; WaitingFor исполняется PostStart-хуком, и GenericContainer не возвращается до nil.
- Базовый набор: ForListeningPort для порта, ForLog для строки в логах, ForHTTP для реальной HTTP-пробы, ForSQL для SELECT 1 через database/sql.
- ForAll требует все условия по очереди, ForAny — первый успех в параллели, но любая ошибка валит ожидание; общий бюджет задаёт WithDeadline.
- Дефолты: таймаут старта шестьдесят секунд, интервал опроса сто миллисекунд; тонкая настройка — WithStartupTimeout и WithPollInterval.
- Своя стратегия — тип с методом WaitUntilReady: тикать вручную, уважать ctx.Done и пользоваться Host, Exec и Inspect у StrategyTarget.
Домашнее задание
Соберите тест с GenericContainer и WaitingFor из ForAll: ForListeningPort плюс ForLog готовности выбранного образа. Затем смените ForLog на ForHTTP по пути healthz с WithPort и StatusCodeMatcher под свой сервис. Найдите образ без шелла и проверьте, как ForListeningPort ведёт себя без внутренней проверки. Финал: напишите стратегию на target.Exec с интервалом в четверть секунды и убедитесь, что после отмены теста она выходит по ctx.Done, а не висит.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему ForLog не видит строку, которая точно была в логах?
Частые причины — регистр и повтор: подстрочный поиск идёт по сырым байтам и чувствителен к регистру, а строка готовности может печататься не один раз — при восстановлении базы та же фраза появляется дважды. Проверьте точный текст в docker logs, поставьте WithOccurrence, если вхождений несколько, и помните: вместе с ошибкой стратегия сообщает число найденных вхождений — это сразу отделяет «строки нет» от «строка ещё не дописана». Помогает и сухой прогон с печатью логов контейнера: сверка точной фразы снимает половину таких загадок.
Нужна ли своя стратегия, если в образе уже есть HEALTHCHECK?
Если хотите переиспользовать вердикт Docker — берите ForHealthCheck: она ждёт переход состояния контейнера в healthy, полагаясь на HEALTHCHECK из образа. Порт и лог остаются независимыми проверками: healthcheck описывает здоровье процесса, а стратегия порта — доступность снаружи. В тестах разумно доверять готовому healthcheck и не дублировать его собственными HTTP-пробами — но только если он действительно есть и настроен под тестовую нагрузку.
Чем WithStartupTimeout отличается от WithDeadline?
WithStartupTimeout ставит бюджет одной конкретной стратегии: у ForListeningPort, ForLog, ForHTTP и ForSQL это личный таймер с шестьюдесятью секундами по умолчанию. WithDeadline живёт на комбинаторе ForAll или ForAny и накрывает весь набор разом, не складываясь с личными таймаутами, — сработает более короткий контекст. Устаревший WithStartupTimeout на ForAll теперь просто передаёт значение в WithDeadline, так что старый код ограничивает набор целиком.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах