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 пунктов)
- Docker доступен локально и на CI-агентах (DinD / сокет / Testcontainers Cloud).
- Версии образов запинены тегами, не latest; совпадают с продом.
- Используются wait strategies (логи/порт/HTTP/healthcheck), а не sleep.
- Подключение через
getMappedPort()/connection string, без хардкода портов. - Один контейнер на класс/сьют; между тестами — truncate/rollback, не пересоздание.
- Reusable включён локально для скорости, выключен в CI для чистоты.
- Ryuk не отключён без причины — авто-уборка работает.
- Кэш образов настроен на CI, чтобы не тянуть каждый прогон.
- Ресурсы агента посчитаны под тяжёлые образы; параллелизм сбалансирован.
- Testcontainers применяется к инфраструктуре, а не к юнит-логике и не к полному E2E.
Итог
Testcontainers убирает главный самообман интеграционных тестов — «у нас всё зелено на моках». Вы тестируете против реального Postgres с его транзакциями и реального Kafka с его порядком сообщений, при этом без общего стенда и ручной уборки. Цена — Docker на агентах и чуть больше ресурсов. Для большинства команд это честная сделка.
Источники
- Testcontainers — официальный сайт и каталог модулей
- Docs — Startup & Wait strategies
- Docs — Reusable containers
- Реализации по языкам: Java, Go, Python, Node.js, .NET.