Удобные интеграционные тесты в Rust
- title
- Удобные интеграционные тесты в Rust
- type
- summary
- summary
- Связка RAII и testcontainers-rs даёт каждому интеграционному тесту в Rust собственную изолированную инфраструктуру
- tags
- rust, testing, docker, raii
- created
- 2026-07-29
- updated
- 2026-09-13
- lang
- ru
- translation_of
- delightful-integration-tests-rust
- source_updated
- 2026-09-13
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Глава из сборника rust-magic-patterns (alexpusch). Статья начинается с претензии: в тестовом фреймворке Rust нет механизмов setup и teardown, привычных по фикстурам в jest и pytest. К тому же Rust по умолчанию запускает тесты параллельно в отдельных потоках, поэтому создавать общее глобальное состояние и делиться им неудобно. Решение в том, что недостающая функциональность оказывается не нужна: raii даёт инфраструктуру под каждый отдельный тест вместо общей инфраструктуры, а изолированная инфраструктура для каждого теста просто лучше.
Речь о crate'е testcontainers-rs. Его идея в том, что Docker-контейнер - это ресурс, а значит, Drop может его зачистить.
Сначала вручную
Прежде чем браться за crate, автор реализует паттерн напрямую через bollard. Структура RabbitMqContainer хранит дескриптор Docker и идентификатор контейнера; start() создаёт контейнер rabbitmq:3.8.22-management, пробрасывает порт 5672/tcp и запускает его. Затем:
impl Drop for RabbitMqContainer {
fn drop(&mut self) {
let docker = self.docker.clone();
let container_id = self.container_id.clone();
async_drop(async move {
docker
.remove_container(&container_id, Some(RemoveContainerOptions {
force: true, ..Default::default()
}))
.await
.expect("Failed to remove container");
});
}
}
Тело теста сводится к строке let _rabbitmq = RabbitMqContainer::start().await?; и больше ни к чему.
У этого подхода есть две проблемы, и в статье прямо названы обе. Остановка контейнера асинхронна, а AsyncDrop доступен только в nightly и всё ещё дорабатывается в upstream'е (rust-lang/rust#126482). Поэтому вспомогательная функция async_drop, взятая из testcontainers-rs, запускает очистку в отдельном потоке и блокирует drop до завершения. Вердикт автора: "неидеально, но для тестов сойдёт". Вторая проблема: Drop вообще не вызывается при SIGINT, SIGTERM, SIGKILL или OOM killer'е; часть этих сигналов можно перехватить, часть нельзя, и в testcontainers-rs входит Watchdog, который частично сглаживает проблему.
Что добавляет crate
Демонстрационное приложение намеренно сделано сложнее игрушечного: два consumer'а RabbitMQ (ToyOrder и ToyReview) плюс HTTP-сервер с эндпоинтами аналитики, поверх Postgres с кэшем в Redis. Четыре динамических элемента инфраструктуры для небольшого приложения.
Реализация трейта Image задаёт свойства контейнера - имя, тег, команду, монтирования:
impl Image for RabbitMqImage {
fn name(&self) -> &str { "rabbitmq" }
fn tag(&self) -> &str { "3.8.22-management" }
}
Эта версия падает с ошибкой. Отправка сообщений в контейнер, который уже запустился, но ещё не инициализировался, выдаёт IO error: Connection reset by peer (os error 104). Исправление - метод, который автор, как сам признаётся, сначала скрыл:
fn ready_conditions(&self) -> Vec<WaitFor> {
vec![WaitFor::message_on_stdout("Server startup complete")]
}
Именно эту деталь автор называет ключевой для удобства всей схемы. У каждого сервиса есть инициализация, завершения которой нужно дождаться. Если описать условие "ждать эту строку в stdout" прямо рядом с определением образа, ни одному тесту больше не придётся об этом думать. На практике писать реализацию Image вручную часто вообще не требуется: в testcontainers-modules уже готовы описания для RabbitMQ, Postgres, Redis и многих других сервисов.
Структура TestEnv объединяет всё: три дескриптора ContainerAsync в полях с префиксом-подчёркиванием (они удерживаются только ради вызова Drop), а также канал, пул соединений, подключение к Redis и адрес API, которые тесты используют напрямую. Три контейнера стартуют параллельно через tokio::try_join!, приложение инициализируется с api_port: 0, его реальный адрес считывается через local_address(), и всё приложение запускается в runtime.
Готовый тест отправляет пять событий ToyOrdered с интервалом в пять минут, затем запрашивает эндпоинт top_toys за интервал от TEST_TIME + 3min до TEST_TIME + 17min и проверяет наличие двух заказов G.I. Joe и одного Barbie. Временное окно специально отсекает первое и последнее события, так что проверка тестирует фильтрацию по времени в запросе, а не просто приём данных.
Пауза посередине
Между публикацией и проверкой стоит вызов wait_for_consistency().await - статическая пауза на одну секунду. Статья не пытается это приукрасить: это "худшее из возможных решений". Сообщения попадают в асинхронную очередь, и извне невозможно узнать, когда они все обработаны. Слишком длинная пауза добавляет секунды к каждому тесту; слишком короткая делает тест нестабильным или, что ещё хуже, случайно проходящим. Testcontainers решает задачу развёртывания инфраструктуры, но эту проблему оставляет как есть.
Что даёт изоляция
Каждый тест получает собственный брокер, собственную базу данных и собственную схему. Один тест не может загрязнить таблицы другого, а сообщения из одного теста не дойдут до consumer'ов другого. Именно такая изоляция делает параллельный запуск любого числа тестов безопасным - то есть возвращает преимущество, с которым у стандартного параллельного запуска тестов в Rust возникали сложности при использовании общих фикстур.
Инфраструктура под программным управлением становится тестируемой сама по себе. Сымитировать сбой Redis можно вызовом env.stop_redis().await?; логичное развитие идеи - что-то вроде env.start_postgres_with_cpu_limit(...) для проверки поведения при деградации базы данных. Ни то, ни другое не выразить через docker-compose.yaml, запускаемый вспомогательным скриптом. А работа внутри единого процесса означает, что запуск остаётся стандартным cargo test, а не самодельным фреймворком, к которому команде нужно привыкать.
Плата за это - время запуска. Каждый тест ждёт старта контейнеров, ждёт выполнения условий готовности и прогоняет полные миграции. Параллельный запуск тестов частично компенсирует задержку в пределах ресурсов машины. Статья описывает это как компромисс, который каждый проект оценивает самостоятельно, а не как готовый универсальный ответ, и напоминает, что интеграционные тесты - лишь один из уровней тестового набора, тогда как юнит-тесты за это не платят вовсе.
В заключение отмечается, что всё это специфично не только для тестирования. Тот же паттерн использует tempfile для удаления созданных файлов во время выполнения; RAII применим к любому внешнему ресурсу, чьё время жизни можно привязать к владельцу.
Рядом с этим показателен аналог в Golang. t-context-go-testing разбирает правило Джонатана Холла (Jonathan Hall) о том, что время жизни контекста должно совпадать со временем жизни его владельца. Его наглядный пример того, где T.Context() ошибочен, - контейнер Testcontainers, общий для всего бинарника с тестами. Там совместное использование считается вариантом по умолчанию, а риск заключается в том, что хук отдельного теста снесёт общую инфраструктуру. Версия на Rust переворачивает этот подход: здесь изоляция на уровне теста - дешёвый вариант по умолчанию, а общие сущности не вводятся вовсе.
anatomy-of-a-test приводит тот же довод о реальных зависимостях для одного теста на Gleam, используя базу SQLite в памяти и тестовый конструктор.