Содержание урока · 10 разделов
- Зачем переиспользовать дорогой контейнер
- Reuse: условия, поиск, состояния
- Один контейнер на пакет: TestMain и уборка
- Чистка состояния: TRUNCATE и FlushDB
- Стиль теста → стратегия контейнера
- Когда reuse вредит: грязное состояние
- Типовые ошибки переиспользования
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Каждый вызов GenericContainer по умолчанию заводит отдельный контейнер: create, загрузка образа при первом прогоне, старт процесса, wait-стратегия. Для nginx это секунды, для брокера с журналом и конфигурацией — десятки. Умножьте на двадцать тестов пакета, и половина времени сборки уйдёт на подъёмы, хотя логике тестов нужен не новый сервис, а живой и предсказуемый. Память и порты расходуются так же: два десятка брокеров, живущих одновременно в параллельном прогоне, — два десятка полновесных процессов с буферами и журналами, делящих хост с самим тестовым раннером. Диски раннеров тоже не бесконечны: каждый контейнер тащит журналы и слои записи, которые уборщик обязан найти и стереть. Механика известна с третьего урока: контейнер — процесс с состоянием в памяти и на диске, и всё, что тест туда положил, остаётся там до удаления. Отсюда два вопроса, на которые отвечает этот урок. Первый: как попросить библиотеку найти уже работающий сервис вместо нового — это поле Reuse и фиксированное имя. Второй: что делать с состоянием, которое пережило прошлый тест, — его чистят между тестами, а не бегут от него пересозданием.
Зачем переиспользовать дорогой контейнер#
Считать выгоды удобно на брокере. Один подъём: pull образа, создание, старт, прогрев диспетчеров тем — суммарно близко к минуте на слабой машине. Двадцать тестов с индивидуальными контейнерами — двадцать таких подъёмов, и сборка растягивается на полчаса, хотя каждый тест работает секунды. Схема с переиспользованием платит за старт один раз: первый вызов с именем и Reuse создаёт контейнер, остальные находят его и получают тот же адрес. Сценарий «testcontainers reuse» держится на простом контракте: вызов с тем же именем возвращает существующий контейнер, а не ошибку конфликта. Экономия живёт не только в CI: при локальной итерации контейнер переживает прогон, и повторный go test стартует мгновенно, без pull и прогрева. Ускорение тестов go чаще всего начинается именно здесь — с удаления повторных подъёмов, а не с тонкой настройки самого кода: десятки секунд на тест нельзя выгадать ни одной микрооптимизацией в самом тесте. Цена вопроса симметрична: пока контейнер общий, готовность придётся проверять на каждом вызове, но сверка порта — миллисекунды против минут на полный подъём с загрузкой образа. Останется следить, чтобы общий сервис не превратился в общую помойку, — этому посвящён хвост урока.
Декларация с именем и Reuse
GOfunc 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: один вызов ставит и имя, и флаг; документация помечает её экспериментальной и предупреждает, что реализация может смениться на неименную. Практичное следствие: декларация переиспользуемого контейнера должна быть одинаковой во всех вызовах — различия в полях к живому контейнеру не применяются, и тест будет работать с тем, что задал создатель.
Reuse не пропускает готовность. Даже когда контейнер уже работает, вызов с тем же именем исполняет хуки и wait-стратегию заново: в тестах исходников десять подпроцессов с одним именем делят один контейнер, и каждый дожидается сигнала готовности. Это не накладная, а гарантия: вернувшийся вызов отдаёт сервис, принимающий соединения. Связка со стратегиями ожидания работает как в обычном тесте — порог, лог или HTTP-проба, только проверка случается на каждом переиспользовании.
Один контейнер на пакет: TestMain и уборка#
Паттерн «один дорогой контейнер на пакет» опирается на TestMain — точку входа тестового пакета: до m.Run контейнер создаётся, после — убирается. Каждый тест получает адрес из пакетной переменной и не тратит ни строки на подъём. Уборка переезжает с теста на пакет: Terminate после m.Run срабатывает всегда — и на провале assert'ов, и на панике внутри теста, потому что фреймворк доводит прогон до возврата в TestMain. Регистрировать Terminate в t.Cleanup каждого теста при общем контейнере — ошибка: первый завершившийся тест снесёт брокер соседям, и те начнут пересоздание с полного подъёма на ровном месте. Провалиться пакет должен целиком и сразу: ошибка подъёма в TestMain валит прогон с одной внятной причиной вместо двадцати одинаковых красных строк. Пакетные фикстуры — тема урока про TestMain и testify, здесь важен принцип: создаёт один раз, убирает один раз, тесты только пользуются. Имя при этом стоит выбирать пакетно-уникальным: префикс с сервисом и названием пакета убережёт от коллизии, когда два разных пакета одной машины захотят одного брокера.
TestMain: создать до m.Run, убрать после
GOvar 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 с консольной утилитой внутри контейнера.
Хелпер чистки базы между тестами
GOfunc 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. Что произойдёт?
Соберите переиспользуемую декларацию
Восстановите декларацию: фиксированное имя, режим переиспользования и чтение наружного порта.
Соберите путь переиспользуемого вызова
Расставьте шаги: от декларации с именем до уборки на уровне пакета.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Честная оговорка: в песочнице сайта этот урок не запустится — переиспользование требует живого демона Docker, а браузерная среда исполняет тематические фрагменты без демонов. Рабочая схема локальная: сохраните код в файл *_test.go рядом с go.mod и запустите go test. Эксперимент с терминалом: docker ps до и после первого прогона — брокер останется жить; закомментируйте Reuse и сравните время двух прогонов подряд.
Типовые ошибки переиспользования#
- Reuse: true без Name — ErrReuseEmptyName ещё до обращения к провайдеру
- Terminate в t.Cleanup каждого теста при общем контейнере — первый завершившийся снесёт сервис соседям
- ставка на свежее состояние: чужие строки, сообщения и ключи переезжают между тестами
- AutoRemove вместе с reuse: контейнер стирается при останове, переиспользовать будет нечего
- одно имя в двух независимых пакетах на одной машине — пакеты молча делят один контейнер
- reuse там, где нужна изоляция: грязное состояние дороже сэкономленных секунд
- ожидание, что wait-стратегия сработает единожды: при каждом переиспользовании она проходит заново
- пауза контейнера руками Docker: переиспользование paused-контейнера вернёт ошибку
Карточки: переиспользование
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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, что второй прогон стартовал мгновенно. Финал: остановите контейнер вручную между прогонами и проверьте, что следующий вызов поднял его сам.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем Reuse отличается от простого фиксированного Name?
Name без Reuse — просто ярлык: два вызова с одним именем устроят конфликт на create, второй упадёт, пока жив первый. Reuse меняет семантику: вызов с тем же именем ищет существующий контейнер и возвращает его, а create выполняется только для пустого результата поиска, с обработкой гонки на имя. Обратное не симметрично: фиксированное имя без Reuse ничего не ищет и переиспользованием не становится.
Кто убирает переиспользуемый контейнер?
Ответственность на схеме с пакетным контейнером лежит на TestMain: Terminate после m.Run — одна уборка на пакет, выполняющаяся и при провале тестов. Забытый Terminate не оставляет вечный мусор: контейнер, созданный в сессии, попадает под супервизор уборки — его устройство и сроки разбираются в отдельном уроке. Побочный эффект поиска без фильтра по сессии: брокер, оставленный прошлой итерацией локально, найдётся и будет переиспользован — отсюда и мгновенный рестарт. Именно поэтому тесты исходников после проверки переиспользования удаляют найденный контейнер явно, не надеясь на чужую уборку.
Совместим ли Reuse с t.Parallel?
На уровне API да: поиск-или-создание защищён мьютексом, ошибки конфликта имён при гонке обрабатываются ожиданием, и параллельные тесты получают один контейнер, а не пачку ошибок. Ограничение семантическое: состояние этого контейнера общее для всех, поэтому параллельному пакету обязательна чистка между тестами, а тестам с жёсткими предположениями о пустом старте — собственный контейнер. Подробное разложение параллельных сценариев — в семнадцатом уроке курса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах