Интеграционные тесты платформы шли 22–24 минуты в одном пакете и 11–14 в другом. Изредка какой-нибудь тест падал по таймауту ожидания порта — всегда разный, всегда воспроизводился в изоляции за секунды. Диагноз ставился привычный: машина не тянет, докер под WSL2 медленный, перезапустим.
Диагноз был верен по симптому и неверен по адресу: тесты портили себе окружение сами. Ниже — разбор, замеры и приём, который убрал минуты, а вместе с ними и падения. Плюс два раздела, без которых это была бы реклама: границы приёма и честная черта между тем, что измерено, и тем, что осталось правдоподобной гипотезой.
Опирается на тестирование Go-сервиса, где testcontainers-go разобран как основа интеграционных тестов. Здесь — что делать, когда этой основы становится много.
Оба варианта — «контейнер на каждый тест» и «общий контейнер плюс шаблонная база» — собраны в воспроизводимый стенд: digital-cookbook/performance/testcontainers-template-db. Числа ниже можно снять у себя одной командой и посмотреть, как соотношение меняется на вашем железе.
Симптом и ложный след
Прогон выглядел так:
ok github.com/.../internal/storage 638s
ok github.com/.../cmd/api 1371s
Двадцать три минуты на пакет — это уже не «долго», это «никто не будет гонять локально». Тесты запускались перед пушем, в CI и больше нигде.
Флаки выглядели ещё хуже, потому что были неотличимы от настоящих падений:
--- FAIL: TestCreateCourseForm_ExistingID_Conflict (65.66s)
auth_middleware_test.go:43: Received unexpected error:
container did not start within 60s
Обратите внимание на время: 65 секунд и падение на строке подъёма контейнера, а не на утверждении. Тот же тест в одиночку проходил за 20 секунд. Отсюда и вывод «окружение», и ритуал перепрогона.
Ложный след держался на том, что вывод был правильным: это действительно окружение. Просто окружение портили сами тесты.
Арифметика, которую стоило сделать раньше
Тестовый хелпер выглядел безобидно:
func newTestStore(t *testing.T) *Store {
// поднять контейнер postgres:16-alpine
// дождаться порта
// накатить миграции
// вернуть Store
}
Вызовов этого хелпера в пакете internal/storage — 116. Соседнего newAuthTestHandler в cmd/api — около 150.
Двести шестьдесят шесть контейнеров Postgres за прогон. Каждый — образ, сеть, ожидание порта, тринадцать миграций с нуля. Никакого «медленного докера» тут не нужно: даже полторы секунды на контейнер дают шесть минут, а под WSL2 полутора секундами дело не обходится.
Хуже, что они не всегда убирались. docker ps -a показал 33 контейнера в состоянии created — застрявших между созданием и стартом.
Соблазнительно объявить их причиной падений: демон занят мусором, следующий контейнер не успевает открыть порт. Но это гипотеза, а не диагноз, и стенд её не подтверждает — он показывает другое, менее удобное. Наивный вариант падает и при работающей уборке, когда мусора не остаётся вовсе.
Зато стенд показывает, на чём именно падает. Вот кусок лога упавшего прогона:
--- PASS: TestNaive/case-05 (3.13s)
--- FAIL: TestNaive/case-06 (64.70s)
get state: Get ".../containers/1a02ab.../json": context deadline exceeded
--- PASS: TestNaive/case-07 (3.11s)
Отказ не в том, что Postgres долго стартует. Не ответил Docker API: запрос состояния контейнера через сокет не вернулся за отведённые ретраи. Соседние случаи при этом отрабатывают за 3.1 секунды — демон не лёг, он отвечает неравномерно.
Дальше начинается то, чего измерения не показывают. Почему API перестал отвечать вовремя — исчерпание какого именно ресурса, очередь внутри демона, хвосты teardown, особенности WSL2 — по этим данным сказать нельзя: ни метрик демона, ни событий Docker, сопоставленных с моментами отказов, никто не снимал. Поэтому формулировка ровно такая, какая подтверждается: повторный жизненный цикл контейнеров на этой машине оказался достаточным условием, чтобы Docker API переставал отвечать вовремя. Накопление контейнеров воспроизводится отдельно и остаётся правдоподобным усилителем — не причиной.
Мораль до всякой оптимизации: если тест падает ровно на границе таймаута и проходит в изоляции — это повод считать ресурсы, а не перезапускать. И сохранять лог упавшего прогона: без него вы будете рассуждать о причинах, вместо того чтобы прочитать, что именно не ответило.
Что заменить
Нужны два свойства, которые кажутся противоречащими:
- изоляция — тест не должен видеть данные соседа, иначе порядок выполнения станет частью условия;
- дешевизна — потому что изоляции требуется двести шестьдесят шесть раз.
Контейнер на тест даёт первое ценой второго. Общая база на всех даёт второе ценой первого — и порождает отдельный жанр отладки «почему тест зелёный в одиночку и красный в пакете». Способы готовить данные так, чтобы тесты не мешали друг другу, разбирает статья про фикстуры и тестовые данныеСкоро; здесь речь о более грубом уровне — не о содержимом базы, а о самой базе.
Развязка в том, что изоляция нужна на уровне базы данных, а не контейнера. А базу Postgres умеет копировать сам:
CREATE DATABASE testdb_9f3a1c TEMPLATE lab_template;
Это копирование файлов внутри одного сервера — десятки миллисекунд. Не «почти изоляция», а полноценная отдельная база: свои таблицы, своё содержимое, свой DROP в конце.
Приём не про Go: он про Postgres и про то, как устроен тестовый хелпер, — те же рассуждения переносятся на любую экосистему, где интеграционные тесты поднимают базу, и сравнение подходов к тестированию в разных языках показывает, насколько похоже эта задача выглядит вне Go.
Схема получается такая:
один контейнер на тестовый бинарь
└── база-шаблон (миграции накатаны один раз)
├── testdb_9f3a1c тест A
├── testdb_2b77e0 тест B
└── … по одной на тест, живут миллисекунды
Реализация
Пакет целиком — около полутора сотен строк. Ключевые места ниже.
Подъём один раз за процесс. go test компилирует и запускает каждый пакет отдельным бинарём, поэтому «один раз за процесс» и означает «один контейнер на пакет»:
var (
once sync.Once
setupErr error
pool *pgxpool.Pool // соединение к служебной базе: CREATE/DROP DATABASE
endpoint string // host:port контейнера
)
func New(t *testing.T) string {
t.Helper()
if testing.Short() {
t.Skip("интеграционный тест: нужен Docker; пропущен в -short")
}
// Подъём получает свой бюджет, а не бюджет теста, которому не повезло
// оказаться первым: контейнер живёт дольше любого теста.
once.Do(func() {
setupCtx, cancel := context.WithTimeout(context.Background(), startupTimeout)
defer cancel()
setupErr = setup(setupCtx)
})
require.NoError(t, setupErr, "общий контейнер-шаблон не поднялся")
// Контекст теста создаётся ПОСЛЕ подъёма. Создай его раньше — первый тест
// пакета отдал бы свои секунды на ожидание контейнера и пришёл бы к
// CREATE DATABASE с истёкшим дедлайном.
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
t.Cleanup(cancel)
name := newDBName(t)
_, err := pool.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", name, templateDB))
require.NoError(t, err)
t.Cleanup(func() {
// Ошибка уборки НЕ глотается: молчаливый провал — это утечка баз,
// которая проявится через сотню тестов исчерпанным диском, и связать
// её с причиной будет уже нечем.
dropCtx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if _, err := pool.Exec(dropCtx,
fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", name)); err != nil {
t.Errorf("база %s не удалена: %v", name, err)
}
})
return dsnFor(name)
}
Три вещи в этом фрагменте выглядят перестраховкой, и каждая появилась после того, как стенд на них споткнулся: раздельные бюджеты на подъём и на операции, создание контекста теста после once.Do, и проверка ошибки удаления базы. Подробности — в разделе про границы ниже.
Почему sync.Once, а не TestMain. TestMain — очевидный ответ на «инициализировать один раз». Но он объявляется в каждом пакете-потребителе: пришлось бы добавить одинаковую заглушку в два пакета, держать их в синхронности и не забыть про третий, когда он появится. sync.Once с ленивой инициализацией даёт тот же эффект при одном источнике правды — и, что важнее, позволил не трогать ни один из 266 вызовов. Сигнатуры newTestStore и newAuthTestHandler остались прежними, изменилось только их нутро.
Но Once решает только подъём. Уборку он не решает: срабатывает на первом тесте, а не после последнего, и явный Terminate звать некому.
Соблазн — оставить это реаперу ryuk: он привязан к сессии тестового процесса и уберёт контейнер сам. Соблазну лучше не поддаваться. Реапер — аварийный запасной путь, а не способ уборки: он работает с задержкой, сам требует контейнера и отключается переменной TESTCONTAINERS_RYUK_DISABLED, которую в CI ставят чаще, чем кажется — в окружениях без прав на монтирование сокета Docker или с политикой «никаких привилегированных контейнеров» он просто не стартует.
Правильная комбинация — оба механизма, каждый на своей половине:
func TestMain(m *testing.M) {
code := m.Run()
if pool != nil {
pool.Close()
}
if container != nil {
ctx, cancel := context.WithTimeout(context.Background(), startupTimeout)
// Ошибка уборки повышает код выхода: контейнер, который не убрался,
// достаётся следующему прогону, и лучше узнать об этом сразу.
if err := testcontainers.TerminateContainer(container,
testcontainers.StopContext(ctx)); err != nil {
fmt.Fprintf(os.Stderr, "контейнер не убран: %v\n", err)
if code == 0 {
code = 1
}
}
cancel()
}
os.Exit(code)
}
И одно место в setup, которое легко пропустить: контейнер надо запомнить до проверки ошибки его создания.
pg, err := tcpg.Run(ctx, image, /* ... */)
container = pg // присваиваем до проверки: неудачный Run может вернуть
if err != nil { // частично созданный контейнер, и без этого TestMain
return err // о нём не узнает
}
В варианте с контейнером на каждый тест то же самое делается через testcontainers.CleanupContainer(t, pg) — его тоже регистрируют до if err != nil, он корректно обрабатывает nil.
sync.Once — для подъёма, чтобы не трогать вызовы. TestMain — для завершения, чтобы не зависеть от реапера. Один TestMain на пакет писать всё же придётся, но это заглушка в пять строк, а не правка 266 вызовов, и она не растекается по коду.
Миграции — боевым путём. В шаблон они катятся той же функцией, что накатывает их на стендах:
if _, err := pool.Exec(ctx, "CREATE DATABASE "+templateDB); err != nil {
return fmt.Errorf("create template db: %w", err)
}
if err := migrate.Run(dsnFor(templateDB)); err != nil {
return fmt.Errorf("migrate template: %w", err)
}
Это не косметика. До перехода тесты катили миграции своим исполнителем: ручной список из тринадцати файлов и наивный разбор -- +goose Down строками. То есть проверяли не ту последовательность, которая едет на стенды. Такое расхождение не выстреливает годами, а потом выстреливает миграцией, которую тесты никогда не видели.
Две ловушки Postgres
К шаблону не должно быть соединений. CREATE DATABASE ... TEMPLATE требует, чтобы к базе-источнику никто не был подключён, иначе:
ERROR: source database "lab_template" is being accessed by other users
Поэтому служебное соединение (pool выше) держится к другой базе — postgres, — а migrate.Run открывает и закрывает своё соединение к шаблону сам. После возврата из миграций к шаблону не подключён никто, и это именно то, что нужно.
DROP DATABASE без WITH (FORCE) падает. Если тест или его хелперы не успели закрыть соединения к моменту t.Cleanup, удаление базы отобьётся. WITH (FORCE) (Postgres 13+) обрывает чужие соединения сам:
_, _ = pool.Exec(context.Background(),
fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", name))
Без него уборка становится вероятностной, и мусор возвращается — ровно тот, с которого всё началось.
Имя базы — не t.Name(). Подтесты содержат /, а в общем случае произвольные символы, недопустимые в непокавыченном идентификаторе. Восемь случайных байт в hex решают и это, и уникальность при повторном запуске той же функции.
Замеры
Один и тот же коммит, одна машина, чистый Docker до каждого прогона:
| Пакет | Было | Стало | Раз |
|---|---|---|---|
internal/storage |
638s | 17.4s | ×36.7 |
cmd/api |
1371s | 135.9s | ×10.1 |
Полный прогон go test ./... -p 1 |
— | 3m14s | — |
Контейнеров в Docker после прогона (плюс пятнадцать секунд на реап): ноль своих.
Разница множителей объяснима: в cmd/api часть времени уходит не на базу, поэтому там выигрыш меньше. Чем чище пакет от прочей работы, тем ближе ускорение к отношению «контейнер против CREATE DATABASE».
То же самое на изолированном стенде, без остальной работы проекта. Двадцать случаев, каждому нужна своя база; варианты гоняются по одному, с уборкой и паузой между повторами, медиана из четырёх:
| Вариант | Медиана | Разброс | Упало | Первым / вторым |
|---|---|---|---|---|
общий контейнер + TEMPLATE |
5.9s | 5.7–5.9 | 0 из 4 | 2 / 2 |
| контейнер на случай | 65.8s | 65.8–66.6 | 1 из 4 | 2 / 2 |
Отношение — ×11.2. Повторов четыре, а не три, и это не педантизм: порядок вариантов чередуется, при нечётном числе один из них шёл бы первым чаще другого. Колонка справа показывает назначенные позиции, а не успешные — считай она успешные, упавший прогон исчезал бы из баланса и прятал бы связь между позицией и падением. Связь тут есть: единственное падение наивного варианта пришлось на позицию «первым».
И снята она на отдохнувшем демоне, — вот как она такой стала.
Сначала порядок вариантов был постоянным: сперва шаблонный, потом наивный. Шаблонный выглядел безупречно — ноль падений, — а наивный терял один-два прогона из трёх. Вывод напрашивался: наивный нестабилен по природе.
Потом порядок стали чередовать, и падать начали оба, по два прогона из трёх. У шаблонного отказ был свой: reaper: ... could not start container. То есть его безупречность была артефактом — он всегда шёл первым, по спокойному демону.
После этого между прогонами появилось ожидание восстановления: убрать свои контейнеры, дождаться, пока догорят реаперы предыдущего прогона, и пока docker info начнёт отвечать быстрее полутора секунд. Тогда оба варианта стали доходить до конца, и осталась чистая разница в скорости — та, что в таблице.
Отсюда главный вывод, которого я не ожидал: падения по таймауту — функция состояния демона, а не свойство варианта. Наивный вариант на отдохнувшем демоне медленный, но доходит; он же сразу после чужой контейнерной молотьбы разваливается и разваливает следующего за собой. В настоящем наборе тестов пакеты идут друг за другом и параллельно — то есть живут именно во втором режиме, и никакой паузы им никто не даёт.
На пяти случаях видно устройство: у шаблонного варианта время между пятью и двадцатью случаями почти не изменилось (5.3 → 6.0), у наивного выросло вчетверо (18.4 → 65.8). Фиксированная часть у первого — подъём одного контейнера, дальше случай стоит десятки миллисекунд.
Проверить, не веря на слово: STAND_CASES=50 ./run.sh покажет ваше соотношение и вашу долю упавших прогонов. Скрипт сначала убедится, что go, docker и демон на месте, и откажется печатать таблицу, если чего-то нет: результат, полученный из сломанного окружения, хуже отсутствия результата.
Границы приёма
Он не бесплатный и не универсальный. Пять вещей, которые стоит проверить до внедрения.
Права. Роли нужен CREATEDB. Владельцем созданной базы становится тот, кто её создал: если тесты подключаются другой ролью, права придётся выдать явно. Для DROP DATABASE ... WITH (FORCE) нужно право завершать чужие сеансы — суперпользователь либо членство в pg_signal_backend.
FORCE не всесилен. Он обрывает обычные сеансы, но база не удалится, если на ней остались подготовленные транзакции (PREPARE TRANSACTION), слоты логической репликации или подписки. В тестовом контуре это редкость — но если тесты трогают логическую репликацию, уборка начнёт падать, и падать будет не там, где причина.
Шаблон копирует не всё. CREATE DATABASE ... TEMPLATE копирует содержимое базы, но не настройки уровня базы (ALTER DATABASE ... SET) и не права доступа к ней. Если тесты зависят от таких настроек, применяйте их к каждой копии отдельно.
Цена растёт с размером шаблона. Копирование файлов дёшево ровно до тех пор, пока файлов немного. Схема в шаблоне — копейки; гигабайт фикстур в шаблоне — уже нет. Плюс диск: одновременно живут все базы, которые не успели удалиться.
Контейнер получается один на пакет, а не на прогон. go test компилирует каждый пакет отдельным бинарём и запускает пакеты параллельно. Десять пакетов с интеграционными тестами — десять контейнеров одновременно, и «мы же свели всё к одному контейнеру» перестаёт быть правдой. Если это много, ограничивайте -p, а не рассчитывайте на арифметику из этой статьи.
Мелочи, которые видно только на своём коде. Образ стоит закреплять по digest, а не плавающим тегом: postgres:16-alpine молча переезжает на новый патч-релиз, и замеры разных дней начинают сравнивать разные образы. Контекст на подъём контейнера должен быть заведомо больше таймаута стратегии ожидания — иначе отказ приходит как безликое context deadline exceeded из библиотеки вместо «порт не открылся за 60 секунд», и лог перестаёт быть уликой. И уборку контейнера регистрируйте до проверки ошибки его создания: неудачный запуск может оставить частично созданный контейнер, а t.Fatalf строкой выше не даст его убрать — тот самый мусор, ради которого всё затевалось.
Сколько из этого — заслуга приёма
Три оговорки, без которых цифры выше вводят в заблуждение.
Замеры сняты на WSL2 поверх Windows. Старт контейнера там дорог как нигде: файловая система, сеть, прослойка виртуализации. На линуксовом раннере база была бы заметно лучше, а значит и множитель скромнее. Ждать ×36 у себя не стоит — ждать стоит того же порядка изменения, но с другим коэффициентом.
Замер, который переживает такие оговорки, строится иначе — с повторами, ротацией порядка и доверительными интервалами; как это выглядит на практике, показывает разбор регрессии crypto/rsa. Здесь такой строгости нет и не требуется: разница между секундами и минутами не нуждается в benchstat, чтобы быть заметной.
Эффектов на самом деле два, и они разного размера. Первый — перестать создавать двести шестьдесят шесть контейнеров. Второй — клонировать шаблон вместо повторного наката миграций. Основная часть выигрыша от первого; второй нужен для того, чтобы первый не пришлось оплачивать потерей изоляции. Если у вас контейнер поднимается один раз, а базы вы чистите TRUNCATE между тестами — вы уже забрали большую часть выигрыша, и TEMPLATE даст скорее аккуратность, чем секунды.
Приём не новый. Шаблонные базы Postgres — известный трюк, переиспользование контейнеров задокументировано в testcontainers. Здесь ценность не в изобретении, а в измеренном до/после и в том, что переход обошёлся без правки 266 вызовов.
Побочная находка: дефолты клиента и дефолты сети
В том же разборе всплыло отдельное: два теста проверяли поведение при недоступном Redis и подключались к 127.0.0.1:1. Ожидание — мгновенный отказ. Под WSL2 соединение висело до исчерпания дефолтов go-redis — около 108 секунд на тест.
Лечится в тесте, а не в продакшн-коде:
func brokenSessions() *auth.Sessions {
return auth.NewSessions(redis.NewClient(&redis.Options{
Addr: "127.0.0.1:1",
DialTimeout: 50 * time.Millisecond,
MaxRetries: -1, // без ретраев
}), time.Hour)
}
Мораль шире случая: тест, проверяющий отказ, обязан задавать таймаут явно. Дефолты клиента рассчитаны на продакшн, где терпение — достоинство. В тесте оно превращается в две минуты ожидания того, что и так известно.
Что забрать с собой
- Посчитайте вызовы тестового хелпера. Не «тесты медленные», а «сколько раз мы поднимаем контейнер». Число обычно объясняет всё.
- Падение ровно на границе таймаута — повод считать ресурсы, а не перезапускать. Посмотрите
docker ps -aпосле прогона и чем занят демон в момент отказа. Пока не посмотрели — это гипотеза, а не причина. - Изоляция нужна на уровне базы, а не контейнера.
CREATE DATABASE ... TEMPLATEдаёт полную изоляцию за десятки миллисекунд — в пределах, перечисленных выше. sync.Onceдля подъёма,TestMainдля уборки. Первый позволяет не трогать вызовы, второй — не зависеть от реапера, который в CI может быть отключён.- Один контейнер получается на пакет, а не на прогон.
go testгоняет пакеты параллельно; считайте по числу пакетов. - Миграции в тестах — боевым путём. Свой исполнитель означает, что тесты проверяют не то, что поедет на стенд.
WITH (FORCE)вDROP DATABASE— иначе уборка вероятностная.- В тестах на отказ задавайте таймауты явно. Иначе меряете терпение клиентской библиотеки.
Комментарии