toolsintegration-testingdockertestcontainersci

Testcontainers: реальные БД, Kafka и Redis в тестах вместо моков и общего стенда

У интеграционных тестов есть две классические беды. Первая — моки врут: вы подменяете базу или брокер заглушкой, и тест зелёный, а на проде падает, потому что заглушка не знает про реальные транзакции, индексы, дедлоки и сериализацию сообщений. Вторая — общий стенд: одна БД на всех, тесты топчут данные друг друга, флакают и зависят от того, кто что залил вчера.

Testcontainers закрывает обе. Это библиотека, которая на время теста поднимает реальные зависимости в Docker — настоящий PostgreSQL, Kafka, Redis, Elasticsearch, что угодно — даёт тесту подключиться к ним и сама всё убирает после. Тест получает не заглушку, а ровно тот софт, что крутится в проде.

Как это выглядит

Идея простая: в коде теста вы объявляете контейнер с нужным образом, библиотека его стартует, пробрасывает порт на случайный свободный (чтобы тесты не дрались за 5432), отдаёт вам host и port — вы подключаетесь обычным драйвером. После теста контейнер убивается.

Работает из Java, Go, Python, Node.js, .NET, Rust и других — единый подход на всех языках. Под капотом нужен только запущенный Docker (или совместимый runtime — Podman, Colima, Testcontainers Cloud).

Ключевые паттерны, без которых будет больно

1. Wait strategies — не sleep, а готовность

Контейнер «запущен» и «готов принимать соединения» — это разные моменты. Постгрес поднял процесс, но ещё инициализирует БД. Главный антипаттерн новичка — sleep(5). Правильно — wait strategy: ждать строки в логах, открытого порта, успешного HTTP-эндпоинта или healthcheck. Подробно — в документации про startup & wait strategies.

2. Reusable / один контейнер на класс

Поднимать свежий контейнер на каждый тест-метод — медленно. Два рецепта: держать один контейнер на класс тестов (стартует раз, между тестами чистим данные), или включить reusable containers — контейнер переживает прогон и переиспользуется локально для скорости (в CI обычно выключают). Между тестами — truncate/rollback, а не пересоздание.

3. Ryuk — авто-уборка

Testcontainers запускает спутник Ryuk, который убивает оставшиеся контейнеры/сети/тома, если тест упал или процесс прибили. Это спасает CI-агентов от «протекших» контейнеров, забивающих диск и память.

4. Модули под конкретные технологии

Есть готовые модули: PostgreSQLContainer, KafkaContainer, RedisContainer / GenericContainer, MongoDB, MySQL, Elasticsearch, RabbitMQ, а также LocalStack (эмуляция AWS: S3, SQS, DynamoDB) и WireMock-модуль. Для любого образа без готового модуля — GenericContainer с указанием портов и wait-стратегии.

5. Случайные порты — это фича

Контейнер маппит внутренний порт на случайный внешний. Никогда не хардкодьте localhost:5432 — берите getMappedPort() / getConnectionString(). Иначе параллельные тесты подерутся за порт.

6. Пиньте версию образа

Образ — это часть теста. Не используйте latest: фиксируйте тег (postgres:16.3), чтобы прод, тесты и CI говорили об одной версии. Иначе «вчера было зелено» превращается в детектив.

CI: где обычно спотыкаются

  • Docker-in-Docker / socket. Агенту нужен доступ к Docker-демону (DinD или примонтированный сокет). На многих SaaS-CI это включается флагом.
  • Кэш образов. Первый прогон тянет образы из реестра — медленно. Кэшируйте слои/образы между запусками, иначе каждый PR ждёт скачивание.
  • Ресурсы. Тяжёлые образы (Elasticsearch, Kafka) едят RAM/CPU. Считайте лимиты агента, иначе OOM и флаки.
  • Параллелизм. Случайные порты позволяют гонять параллельно, но суммарная нагрузка на агента растёт — балансируйте.
  • Testcontainers Cloud. Если локального Docker нет или агент слабый — есть вариант выносить контейнеры в облако провайдера.

Когда Testcontainers НЕ нужен

  • Юнит-тесты. Чистая логика без внешних зависимостей — там контейнер только замедлит. Моки уместны именно здесь.
  • Очень тяжёлый старт. Если образ поднимается минуту, а тестов сотни — пересмотрите гранулярность (один контейнер на класс/сьют, а не на тест).
  • Контрактные тесты к чужим API. Для внешних HTTP-сервисов часто лучше mock-сервер (WireMock) или contract testing (Pact), а не поднимать чужой сервис.
  • E2E на полном окружении. Когда нужен весь стек целиком — это уже задача staging/ephemeral environments, а не одного теста.

Правило: Testcontainers — для интеграции с инфраструктурой (БД, брокеры, кэши, хранилища). Не замена юнит-тестам снизу и не замена полноценному окружению сверху.

Чек-лист внедрения (10 пунктов)

  1. Docker доступен локально и на CI-агентах (DinD / сокет / Testcontainers Cloud).
  2. Версии образов запинены тегами, не latest; совпадают с продом.
  3. Используются wait strategies (логи/порт/HTTP/healthcheck), а не sleep.
  4. Подключение через getMappedPort()/connection string, без хардкода портов.
  5. Один контейнер на класс/сьют; между тестами — truncate/rollback, не пересоздание.
  6. Reusable включён локально для скорости, выключен в CI для чистоты.
  7. Ryuk не отключён без причины — авто-уборка работает.
  8. Кэш образов настроен на CI, чтобы не тянуть каждый прогон.
  9. Ресурсы агента посчитаны под тяжёлые образы; параллелизм сбалансирован.
  10. Testcontainers применяется к инфраструктуре, а не к юнит-логике и не к полному E2E.

Итог

Testcontainers убирает главный самообман интеграционных тестов — «у нас всё зелено на моках». Вы тестируете против реального Postgres с его транзакциями и реального Kafka с его порядком сообщений, при этом без общего стенда и ручной уборки. Цена — Docker на агентах и чуть больше ресурсов. Для большинства команд это честная сделка.

Источники