wiremock-rs для Rust: полное руководство с примерами

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

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

Начать курс

wiremock-rs: HTTP Mock-сервер для Rust

В современной разработке программного обеспечения тестирование является неотъемлемой частью процесса. Особенно сложно бывает тестировать код, который взаимодействует с внешними HTTP-сервисами. Здесь на помощь приходит библиотека wiremock-rs — мощный инструмент для создания HTTP mock-серверов в экосистеме Rust.

wiremock-rs предоставляет возможность эмулировать поведение внешних HTTP-сервисов, позволяя разработчикам тестировать свой код в изолированной среде. Вместо того чтобы отправлять реальные запросы к внешним API (что может быть медленно, ненадежно или дорого), вы можете создать локальный mock-сервер, который будет отвечать точно так, как вы зададите. Это особенно полезно при написании интеграционных тестов, тестировании обработки ошибок и разработке клиентских библиотек.

Библиотека вдохновлена популярным Java-инструментом WireMock, но полностью переписана на Rust с учетом идиом и особенностей языка. wiremock-rs поддерживает все основные возможности: сопоставление запросов по URL, заголовкам, телу; возврат различных HTTP-статусов; задержки ответов; проверку количества вызовов и многое другое.

Установка

Для установки wiremock-rs добавьте зависимость в ваш файл Cargo.toml:

[dev-dependencies]
wiremock = "0.6"

Затем выполните команду:

cargo build

Также убедитесь, что у вас установлен Rust и Cargo. Если нет — установите через rustup.

Быстрый старт

Давайте создадим простейший mock-сервер, который отвечает на GET-запросы:

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

#[tokio::main]
async fn main() {
    // Запускаем mock-сервер на случайном порту
    let mock_server = MockServer::start().await;
    
    // Создаем правило: на GET /hello отвечаем статусом 200 и телом "Hello, World!"
    let mock = Mock::given(method("GET"))
        .and(path("/hello"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_string("Hello, World!"));
    
    // Регистрируем правило на сервере
    mock_server.register(mock).await;
    
    // Теперь можно делать запросы к серверу
    let client = reqwest::Client::new();
    let response = client.get(&format!("{}/hello", &mock_server.uri()))
        .send()
        .await
        .unwrap();
    
    println!("Статус: {}", response.status());
    println!("Тело: {}", response.text().await.unwrap());
    
    // Останавливаем сервер (произойдет автоматически при завершении main)
}

Этот минимальный пример показывает основную идею: вы запускаете сервер, определяете правила (matchers) и ответы (response templates), а затем регистрируете их. После этого ваш клиентский код может делать запросы к mock-серверу.

Основные методы и функции

1. MockServer::start()

Сигнатура: pub async fn start() -> MockServer

Описание: Запускает новый mock-сервер на случайном доступном порту. Сервер будет работать до тех пор, пока объект MockServer не будет удален.

let server = MockServer::start().await;
println!("Сервер запущен на {}", server.uri());

2. MockServer::start_on_port(port: u16)

Сигнатура: pub async fn start_on_port(port: u16) -> MockServer

Описание: Запускает mock-сервер на указанном порту. Полезно, когда нужен фиксированный порт для конфигурации.

let server = MockServer::start_on_port(8080).await;
assert_eq!(server.uri(), "http://127.0.0.1:8080");

3. Mock::given(matcher: impl Matcher)

Сигнатура: pub fn given(matcher: impl Matcher) -> MockBuilder

Описание: Создает новое правило для mock-сервера. Принимает первый matcher, который определяет условие для запроса.

let mock = Mock::given(method("POST"))
    .and(path("/api/users"))
    .respond_with(ResponseTemplate::new(201));

4. Mock::and(matcher: impl Matcher)

Сигнатура: pub fn and(self, matcher: impl Matcher) -> MockBuilder

Описание: Добавляет дополнительное условие к правилу. Все условия должны быть выполнены для срабатывания правила.

Mock::given(method("GET"))
    .and(path("/api/data"))
    .and(header("Authorization", "Bearer token123"))
    .respond_with(ResponseTemplate::new(200));

5. ResponseTemplate::new(status: u16)

Сигнатура: pub fn new(status: u16) -> ResponseTemplate

Описание: Создает шаблон ответа с указанным HTTP-статусом. Это базовый строительный блок для определения ответов.

let response = ResponseTemplate::new(404)
    .set_body_string("Not Found")
    .set_delay(Duration::from_millis(100));

6. ResponseTemplate::set_body_string(body: impl Into<String>)

Сигнатура: pub fn set_body_string(self, body: impl Into<String>) -> ResponseTemplate

Описание: Устанавливает тело ответа в виде строки. Можно использовать как обычные строки, так и сериализованные JSON-данные.

let json_body = serde_json::json!({
    "id": 1,
    "name": "Alice"
}).to_string();

ResponseTemplate::new(200)
    .set_body_string(json_body);

7. MockServer::register(mock: Mock)

Сигнатура: pub async fn register(&self, mock: Mock)

Описание: Регистрирует правило на сервере. После этого сервер начнет обрабатывать запросы, соответствующие этому правилу.

let mock = Mock::given(method("DELETE"))
    .and(path("/api/items/1"))
    .respond_with(ResponseTemplate::new(204));

server.register(mock).await;

8. MockServer::verify(&self, mock: &MockAssertion)

Сигнатура: pub async fn verify(&self, mock: &MockAssertion) -> Result<(), Vec<VerificationError>>

Описание: Проверяет, что определенное правило было вызвано указанное количество раз. Полезно для верификации поведения в тестах.

// Создаем правило
let mock = Mock::given(method("GET"))
    .and(path("/api/status"))
    .respond_with(ResponseTemplate::new(200))
    .expect(2); // Ожидаем 2 вызова

server.register(mock).await;

// ... выполняем запросы ...

// Проверяем, что правило было вызвано ровно 2 раза
server.verify(&mock).await.unwrap();

9. MockBuilder::with_header(name: &str, value: &str)

Сигнатура: pub fn with_header(self, name: &str, value: &str) -> MockBuilder

Описание: Добавляет заголовок к ответу. Можно добавлять несколько заголовков.

Mock::given(method("GET"))
    .and(path("/api/data"))
    .respond_with(
        ResponseTemplate::new(200)
            .set_body_string("{}")
            .with_header("Content-Type", "application/json")
            .with_header("X-Custom-Header", "value")
    );

10. ResponseTemplate::set_delay(delay: Duration)

Сигнатура: pub fn set_delay(self, delay: Duration) -> ResponseTemplate

Описание: Добавляет задержку перед отправкой ответа. Полезно для тестирования таймаутов и поведения при медленных ответах.

use std::time::Duration;

ResponseTemplate::new(200)
    .set_body_string("Slow response")
    .set_delay(Duration::from_secs(5)); // Задержка 5 секунд

Реальный практический пример

Рассмотрим более сложный пример: тестирование клиента для работы с API погоды. Мы создадим mock-сервер, который эмулирует работу реального сервиса погоды, включая обработку ошибок и различные сценарии.

use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path, query_param};
use serde::{Deserialize, Serialize};
use std::time::Duration;

// Структуры данных
#[derive(Debug, Serialize, Deserialize)]
struct WeatherResponse {
    city: String,
    temperature: f64,
    condition: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct ErrorResponse {
    error: String,
    code: u16,
}

// Клиент для работы с API погоды
struct WeatherClient {
    base_url: String,
    client: reqwest::Client,
}

impl WeatherClient {
    fn new(base_url: &str) -> Self {
        Self {
            base_url: base_url.to_string(),
            client: reqwest::Client::new(),
        }
    }

    async fn get_weather(&self, city: &str) -> Result {
        let url = format!("{}/weather?city={}", self.base_url, city);
        let response = self.client.get(&url).send().await.map_err(|e| e.to_string())?;
        
        if response.status().is_success() {
            response.json::().await.map_err(|e| e.to_string())
        } else {
            let error = response.json::().await.map_err(|e| e.to_string())?;
            Err(format!("{}: {}", error.error, error.code))
        }
    }

    async fn get_weather_with_timeout(&self, city: &str, timeout: Duration) -> Result {
        let url = format!("{}/weather?city={}", self.base_url, city);
        let response = tokio::time::timeout(timeout, self.client.get(&url).send())
            .await
            .map_err(|_| "Request timed out".to_string())?
            .map_err(|e| e.to_string())?;
        
        if response.status().is_success() {
            response.json::().await.map_err(|e| e.to_string())
        } else {
            let error = response.json::().await.map_err(|e| e.to_string())?;
            Err(format!("{}: {}", error.error, error.code))
        }
    }
}

#[tokio::main]
async fn main() {
    // Запускаем mock-сервер
    let mock_server = MockServer::start().await;
    let client = WeatherClient::new(&mock_server.uri());

    // Тест 1: Успешный ответ
    println!("=== Тест 1: Успешный ответ ===");
    let success_mock = Mock::given(method("GET"))
        .and(path("/weather"))
        .and(query_param("city", "Moscow"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_string(serde_json::to_string(&WeatherResponse {
                    city: "Moscow".to_string(),
                    temperature: 22.5,
                    condition: "Sunny".to_string(),
                }).unwrap())
                .with_header("Content-Type", "application/json")
        );
    
    mock_server.register(success_mock).await;
    
    match client.get_weather("Moscow").await {
        Ok(weather) => println!("Погода в {}: {}°C, {}", weather.city, weather.temperature, weather.condition),
        Err(e) => println!("Ошибка: {}", e),
    }

    // Тест 2: Ошибка 404 - город не найден
    println!("\n=== Тест 2: Город не найден ===");
    let not_found_mock = Mock::given(method("GET"))
        .and(path("/weather"))
        .and(query_param("city", "UnknownCity"))
        .respond_with(
            ResponseTemplate::new(404)
                .set_body_string(serde_json::to_string(&ErrorResponse {
                    error: "City not found".to_string(),
                    code: 404,
                }).unwrap())
                .with_header("Content-Type", "application/json")
        );
    
    mock_server.register(not_found_mock).await;
    
    match client.get_weather("UnknownCity").await {
        Ok(weather) => println!("Погода: {:?}", weather),
        Err(e) => println!("Ошибка: {}", e),
    }

    // Тест 3: Таймаут (медленный ответ)
    println!("\n=== Тест 3: Таймаут ===");
    let slow_mock = Mock::given(method("GET"))
        .and(path("/weather"))
        .and(query_param("city", "SlowCity"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_string("{}")
                .set_delay(Duration::from_secs(10)) // Задержка 10 секунд
        );
    
    mock_server.register(slow_mock).await;
    
    match client.get_weather_with_timeout("SlowCity", Duration::from_secs(3)).await {
        Ok(weather) => println!("Погода: {:?}", weather),
        Err(e) => println!("Ошибка: {}", e),
    }

    // Тест 4: Проверка количества вызовов
    println!("\n=== Тест 4: Верификация вызовов ===");
    let verify_mock = Mock::given(method("GET"))
        .and(path("/weather"))
        .and(query_param("city", "Verified"))
        .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
        .expect(1); // Ожидаем ровно 1 вызов
    
    mock_server.register(verify_mock).await;
    
    // Делаем запрос
    let _ = client.get_weather("Verified").await;
    
    // Проверяем, что было ровно 1 обращение
    match mock_server.verify(&verify_mock).await {
        Ok(_) => println!("Верификация пройдена: ровно 1 вызов"),
        Err(errors) => println!("Ошибка верификации: {:?}", errors),
    }

    println!("\nВсе тесты завершены!");
}

Советы

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