Интеграционное тестирование Rust с Docker: продвинутый уровень

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение в интеграционное тестирование Rust с Docker

Интеграционное тестирование — ключевой этап разработки надёжных серверных приложений на Rust. В отличие от юнит-тестов, интеграционные проверяют взаимодействие компонентов с реальными зависимостями: базами данных, кэшами, внешними API. Использование Docker позволяет изолировать тестовое окружение и гарантировать воспроизводимость. В этой статье мы разберём продвинутые техники: параллельный запуск контейнеров, управление состоянием, mock-сервисы и работу с асинхронностью через Tokio.

Настройка тестового окружения: Docker и testcontainers

Для интеграционных тестов в Rust удобно использовать крейт testcontainers. Он позволяет программно запускать Docker-контейнеры, дожидаться их готовности и корректно очищать ресурсы. Начнём с базовой настройки.

Установка зависимостей

Добавьте в Cargo.toml:

[dev-dependencies]
testcontainers = "0.21"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Базовый пример: контейнер с PostgreSQL

Создадим тест, который поднимает PostgreSQL, выполняет миграцию и проверяет вставку данных:

use testcontainers::{clients, images::postgres::Postgres, Container};
use tokio_postgres::{NoTls, Client};

async fn setup_db() -> (Client, Container<'static, Postgres>) {
    let docker = clients::Cli::default();
    let pg_image = Postgres::default();
    let container = docker.run(pg_image);
    let host = container.get_host();
    let port = container.get_host_port_ipv4(5432);
    let connection_string = format!(
        "host={host} port={port} user=postgres dbname=postgres",
    );
    let (client, connection) = tokio_postgres::connect(&connection_string, NoTls)
        .await
        .expect("Failed to connect");
    tokio::spawn(async move {
        if let Err(e) = connection.await {
            eprintln!("Connection error: {e}");
        }
    });
    (client, container)
}

#[tokio::test]
async fn test_insert_user() {
    let (client, _container) = setup_db().await;
    client
        .simple_query("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)")
        .await
        .unwrap();
    client
        .execute("INSERT INTO users (name) VALUES ($1)", &[&"Alice"])
        .await
        .unwrap();
    let rows = client.query("SELECT name FROM users", &[]).await.unwrap();
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].get::<_, String>(0), "Alice");
}

Контейнер автоматически удалится после завершения теста благодаря деструктору Container. Это избавляет от ручной очистки.

Параллельный запуск тестов и управление ресурсами

По умолчанию Rust выполняет тесты в параллельных потоках. Если каждый тест поднимает свой контейнер, это может привести к конфликтам портов и перегрузке Docker. Рассмотрим два подхода.

Использование разных портов через динамическое выделение

Крейт testcontainers автоматически назначает свободные порты на хосте. Это решает проблему коллизий, но увеличивает потребление ресурсов. Для тяжёлых контейнеров (например, базы данных) лучше использовать общий экземпляр:

use once_cell::sync::Lazy;
use testcontainers::clients;

static DOCKER: Lazy<clients::Cli> = Lazy::new(|| clients::Cli::default());

async fn get_shared_postgres() -> (tokio_postgres::Client, Container<'static, Postgres>) {
    let pg_image = Postgres::default();
    let container = DOCKER.run(pg_image);
    // ... подключение как в предыдущем примере
    (client, container)
}

Однако такой подход опасен: тесты могут влиять друг на друга через общее состояние. Рекомендуется использовать отдельные базы данных внутри одного контейнера или сбрасывать состояние между тестами.

Сброс состояния через транзакции

Для тестов с БД можно обернуть каждый тест в транзакцию и откатывать её после проверки:

#[tokio::test]
async fn test_with_rollback() {
    let (client, _container) = setup_db().await;
    client.simple_query("BEGIN").await.unwrap();
    // ... операции с БД
    client.simple_query("ROLLBACK").await.unwrap();
}

Это гарантирует изоляцию и не требует пересоздания контейнера.

Тестирование с внешними API: mock-сервисы

При интеграционном тестировании часто нужно имитировать внешние HTTP-сервисы. Docker позволяет поднять лёгкий mock-сервер на Rust внутри контейнера. Используем крейт wiremock для создания стабов.

Пример: mock для платёжного шлюза

use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};

#[tokio::test]
async fn test_payment_integration() {
    let mock_server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/charge"))
        .respond_with(ResponseTemplate::new(200).set_body_json(
            serde_json::json!({"status": "success"})
        ))
        .mount(&mock_server)
        .await;

    let response = reqwest::Client::new()
        .post(format!("{}/charge", mock_server.uri()))
        .json(&serde_json::json!({"amount": 100}))
        .send()
        .await
        .unwrap();
    assert_eq!(response.status(), 200);
    let body: serde_json::Value = response.json().await.unwrap();
    assert_eq!(body["status"], "success");
}

Такой mock можно запустить как отдельный Docker-контейнер или встроить прямо в тестовый процесс. Для более сложных сценариев используйте testcontainers с кастомным образом, запускающим wiremock.

Интеграция с Docker Compose

Если микросервисная архитектура сложна, проще описать инфраструктуру в docker-compose.yml и запускать её из тестов:

// В тесте используем docker-compose через командную строку
use std::process::Command;

fn start_infra() {
    Command::new("docker-compose")
        .args(&["-f", "tests/docker-compose.yml", "up", "-d"])
        .status()
        .expect("Failed to start Docker Compose");
}

fn stop_infra() {
    Command::new("docker-compose")
        .args(&["-f", "tests/docker-compose                

Блоги

Рекомендаций по книгам