Skip to content

Моки и стабы в тестировании Axum

При тестировании веб-приложений на Axum часто требуется изолировать компоненты от их внешних зависимостей. Для этого используются моки (mocks) и стабы (stubs). В этом разделе рассмотрим, как эффективно использовать моки и стабы в Axum-приложениях.

Содержание

Что такое моки и стабы

Моки (Mocks) — это объекты, которые имитируют поведение реальных компонентов в контролируемых условиях. Они позволяют проверять, как тестируемый компонент взаимодействует с зависимостями.

Стабы (Stubs) — более простые имитаторы, которые возвращают заранее заданные значения на входящие запросы.

Основные отличия:

  • Моки отслеживают вызовы методов и могут проверять, что они вызывались ожидаемым образом
  • Стабы просто возвращают предопределенные ответы без отслеживания вызовов

Библиотеки для создания моков в Rust

В экосистеме Rust есть несколько библиотек для создания моков:

1. Mockall

Mockall — одна из самых популярных библиотек для мокирования в Rust.

toml
[dependencies]
mockall = "0.12.0"

Пример использования Mockall:

rust
use mockall::{automock, predicate::*};

// Определение трейта для мокирования
#[automock]
trait Database {
    async fn get_user(&self, id: u64) -> Result<User, DbError>;
    async fn save_user(&self, user: &User) -> Result<(), DbError>;
}

// Тест с использованием мока
#[tokio::test]
async fn test_user_service_with_mock_db() {
    // Создание мока базы данных
    let mut mock_db = MockDatabase::new();
    
    // Настройка ожидаемого поведения
    mock_db.expect_get_user()
        .with(eq(42))
        .times(1)
        .returning(|_| Ok(User {
            id: 42,
            name: "Тестовый пользователь".to_string(),
        }));
    
    // Создание тестируемого сервиса с моком
    let service = UserService::new(mock_db);
    
    // Вызов тестируемого метода
    let user = service.get_user_by_id(42).await.unwrap();
    
    // Проверка результата
    assert_eq!(user.id, 42);
    assert_eq!(user.name, "Тестовый пользователь");
}

2. Mock_it

Mock_it — более легковесная альтернатива с простым API:

toml
[dependencies]
mock_it = "0.4.1"

Пример использования:

rust
use mock_it::{Mock, Matcher};

// Определение трейта
trait Logger {
    fn log(&self, message: &str);
}

// Создание мока
let mut mock_logger = Mock::<dyn Logger>::new();

// Настройка ожидаемого поведения
mock_logger
    .expect_log()
    .with(Matcher::any())
    .times(1)
    .returns(());

// Использование мока
let logger: &dyn Logger = mock_logger.make_ref();
logger.log("Test message");

// Проверка, что метод был вызван
mock_logger.verify();

Мокирование внешних сервисов

При тестировании Axum-приложений часто требуется мокировать внешние HTTP-сервисы:

rust
use axum::{
    extract::{Path, State},
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use async_trait::async_trait;
use mockall::automock;

// Модели данных
#[derive(Deserialize, Serialize, Debug, PartialEq)]
struct Product {
    id: u64,
    name: String,
    price: f64,
}

// Трейт для внешнего сервиса
#[automock]
#[async_trait]
trait ProductService {
    async fn get_product(&self, product_id: u64) -> Result<Product, String>;
}

// Настоящая реализация, которая делает HTTP-запросы
struct ExternalProductService {
    client: reqwest::Client,
    base_url: String,
}

#[async_trait]
impl ProductService for ExternalProductService {
    async fn get_product(&self, product_id: u64) -> Result<Product, String> {
        self.client
            .get(&format!("{}/products/{}", self.base_url, product_id))
            .send()
            .await
            .map_err(|e| e.to_string())?
            .json::<Product>()
            .await
            .map_err(|e| e.to_string())
    }
}

// Axum хендлер, использующий сервис
async fn get_product_handler<T: ProductService>(
    State(service): State<Arc<T>>,
    Path(product_id): Path<u64>,
) -> Result<Json<Product>, String> {
    let product = service.get_product(product_id).await?;
    Ok(Json(product))
}

// Тест хендлера с моком
#[tokio::test]
async fn test_get_product_handler() {
    // Настройка мока
    let mut mock_service = MockProductService::new();
    
    mock_service
        .expect_get_product()
        .with(mockall::predicate::eq(123))
        .times(1)
        .returning(|_| {
            Ok(Product {
                id: 123,
                name: "Тестовый продукт".to_string(),
                price: 99.99,
            })
        });
    
    // Настройка маршрутизатора с моком
    let app = Router::new()
        .route("/products/:id", get(get_product_handler::<MockProductService>))
        .with_state(Arc::new(mock_service));
    
    // Выполнение запроса
    let response = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/products/123")
                .body(axum::body::Body::empty())
                .unwrap()
        )
        .await
        .unwrap();
    
    // Проверка результата
    assert_eq!(response.status(), axum::http::StatusCode::OK);
    
    let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
    let product: Product = serde_json::from_slice(&body).unwrap();
    
    assert_eq!(product.id, 123);
    assert_eq!(product.name, "Тестовый продукт");
    assert_eq!(product.price, 99.99);
}

Мокирование баз данных

Подход с использованием трейтов

rust
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use async_trait::async_trait;
use mockall::automock;

// Модель пользователя
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct User {
    id: i64,
    username: String,
    email: String,
}

// Ошибки БД
#[derive(Debug, thiserror::Error)]
enum DbError {
    #[error("Пользователь не найден")]
    NotFound,
    #[error("Ошибка подключения: {0}")]
    Connection(String),
}

// Трейт репозитория для работы с пользователями
#[automock]
#[async_trait]
trait UserRepository {
    async fn find_by_id(&self, id: i64) -> Result<User, DbError>;
    async fn find_all(&self) -> Result<Vec<User>, DbError>;
    async fn create(&self, user: User) -> Result<User, DbError>;
    async fn update(&self, user: User) -> Result<User, DbError>;
    async fn delete(&self, id: i64) -> Result<(), DbError>;
}

// Хендлер для получения пользователя
async fn get_user<T: UserRepository>(
    State(repo): State<Arc<T>>,
    Path(id): Path<i64>,
) -> Result<Json<User>, StatusCode> {
    match repo.find_by_id(id).await {
        Ok(user) => Ok(Json(user)),
        Err(DbError::NotFound) => Err(StatusCode::NOT_FOUND),
        Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

// Тест хендлера с моком репозитория
#[tokio::test]
async fn test_get_user_handler() {
    // Создание мока репозитория
    let mut mock_repo = MockUserRepository::new();
    
    // Настройка ожидаемого поведения для существующего пользователя
    mock_repo
        .expect_find_by_id()
        .with(mockall::predicate::eq(1))
        .times(1)
        .returning(|_| {
            Ok(User {
                id: 1,
                username: "test_user".to_string(),
                email: "test@example.com".to_string(),
            })
        });
    
    // Настройка ожидаемого поведения для несуществующего пользователя
    mock_repo
        .expect_find_by_id()
        .with(mockall::predicate::eq(999))
        .times(1)
        .returning(|_| Err(DbError::NotFound));
    
    // Тест для существующего пользователя
    let result = get_user(State(Arc::new(mock_repo) as Arc<dyn UserRepository>), Path(1)).await;
    assert!(result.is_ok());
    let user = result.unwrap().0;
    assert_eq!(user.id, 1);
    assert_eq!(user.username, "test_user");
    
    // Тест для несуществующего пользователя
    let result = get_user(State(Arc::new(mock_repo) as Arc<dyn UserRepository>), Path(999)).await;
    assert!(result.is_err());
    assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}

Использование in-memory баз данных

Вместо мокирования, можно использовать встроенные базы данных для тестов:

rust
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};

// Создание тестовой SQLite БД в памяти
async fn setup_test_db() -> Pool<Sqlite> {
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect("sqlite::memory:")
        .await
        .expect("Failed to create in-memory SQLite database");
    
    // Миграция схемы
    sqlx::query(
        "CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            username TEXT NOT NULL,
            email TEXT NOT NULL
        )"
    )
    .execute(&pool)
    .await
    .expect("Failed to create users table");
    
    // Заполнение тестовыми данными
    sqlx::query(
        "INSERT INTO users (id, username, email) VALUES (?, ?, ?)"
    )
    .bind(1)
    .bind("test_user")
    .bind("test@example.com")
    .execute(&pool)
    .await
    .expect("Failed to insert test user");
    
    pool
}

#[tokio::test]
async fn test_user_repository() {
    // Настройка тестовой БД
    let pool = setup_test_db().await;
    let repo = SqliteUserRepository::new(pool);
    
    // Тестирование методов репозитория
    let user = repo.find_by_id(1).await.unwrap();
    assert_eq!(user.username, "test_user");
    
    // Другие тесты...
}

Мокирование методов Axum

Мокирование экстракторов

rust
use axum::extract::{Path, Query, Json as ExtractJson};
use serde::Deserialize;

#[derive(Deserialize)]
struct UserParams {
    include_details: Option<bool>,
}

// Хендлер с экстракторами
async fn get_user_details(
    Path(user_id): Path<u64>,
    Query(params): Query<UserParams>,
) -> impl axum::response::IntoResponse {
    // ...
}

// Прямое тестирование хендлера с моками экстракторов
#[tokio::test]
async fn test_get_user_details() {
    // Мок параметров пути
    let user_id = 42u64;
    
    // Мок query параметров
    let params = UserParams {
        include_details: Some(true),
    };
    
    // Вызов хендлера напрямую с моками параметров
    let response = get_user_details(
        Path(user_id),
        Query(params),
    ).await.into_response();
    
    // Проверка результата
    assert_eq!(response.status(), StatusCode::OK);
    // ... дополнительные проверки
}

Мокирование состояния приложения

rust
use axum::extract::State;
use std::sync::{Arc, RwLock};

// Структура состояния приложения
#[derive(Clone)]
struct AppState {
    config: AppConfig,
    metrics: Arc<RwLock<Metrics>>,
}

// Конфигурация приложения
struct AppConfig {
    api_key: String,
    timeout: u64,
}

// Метрики приложения
struct Metrics {
    request_count: u64,
}

// Хендлер, использующий состояние
async fn metrics_handler(
    State(state): State<AppState>,
) -> String {
    let metrics = state.metrics.read().unwrap();
    format!("Total requests: {}", metrics.request_count)
}

// Тест хендлера с моком состояния
#[tokio::test]
async fn test_metrics_handler() {
    // Создание мока состояния
    let app_state = AppState {
        config: AppConfig {
            api_key: "test_key".to_string(),
            timeout: 30,
        },
        metrics: Arc::new(RwLock::new(Metrics {
            request_count: 42,
        })),
    };
    
    // Вызов хендлера с моком состояния
    let result = metrics_handler(State(app_state)).await;
    
    // Проверка результата
    assert_eq!(result, "Total requests: 42");
}

Создание тестовых двойников

Иногда полезно создавать полные тестовые двойники для сложных компонентов:

rust
use async_trait::async_trait;

// Трейт для сервиса email
#[async_trait]
trait EmailService {
    async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), String>;
}

// Реальная реализация
struct SmtpEmailService {
    // ... поля для SMTP клиента
}

#[async_trait]
impl EmailService for SmtpEmailService {
    async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), String> {
        // Реальная отправка email через SMTP
        Ok(())
    }
}

// Тестовый двойник
#[derive(Default)]
struct TestEmailService {
    sent_emails: std::sync::RwLock<Vec<TestEmail>>,
}

struct TestEmail {
    to: String,
    subject: String,
    body: String,
}

#[async_trait]
impl EmailService for TestEmailService {
    async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), String> {
        // Вместо отправки запоминаем параметры
        let email = TestEmail {
            to: to.to_string(),
            subject: subject.to_string(),
            body: body.to_string(),
        };
        
        self.sent_emails.write().unwrap().push(email);
        Ok(())
    }
}

// Методы для проверки в тестах
impl TestEmailService {
    fn new() -> Self {
        Self {
            sent_emails: std::sync::RwLock::new(Vec::new()),
        }
    }
    
    fn get_sent_emails(&self) -> Vec<TestEmail> {
        self.sent_emails.read().unwrap().clone()
    }
    
    fn clear_emails(&self) {
        self.sent_emails.write().unwrap().clear();
    }
}

// Тест с использованием тестового двойника
#[tokio::test]
async fn test_notification_service() {
    // Создание тестового сервиса
    let email_service = Arc::new(TestEmailService::new());
    let notification_service = NotificationService::new(email_service.clone());
    
    // Вызов тестируемого метода
    notification_service.notify_user(
        "user@example.com",
        "Важное уведомление",
    ).await.unwrap();
    
    // Проверка, что email был "отправлен" с правильными параметрами
    let sent_emails = email_service.get_sent_emails();
    assert_eq!(sent_emails.len(), 1);
    assert_eq!(sent_emails[0].to, "user@example.com");
    assert_eq!(sent_emails[0].subject, "Важное уведомление");
    assert!(sent_emails[0].body.contains("Здравствуйте"));
}

Лучшие практики

1. Проектируйте код с учетом тестируемости

rust
// Плохо: жесткие зависимости
struct UserService {
    db_pool: PgPool,
    http_client: reqwest::Client,
}

// Хорошо: инъекция зависимостей через трейты
struct UserService<D, H>
where
    D: DatabaseAccess,
    H: HttpClient,
{
    db: D,
    http_client: H,
}

2. Используйте внедрение зависимостей

rust
// Определение трейтов для зависимостей
trait DatabaseAccess {
    async fn get_user(&self, id: u64) -> Result<User, Error>;
}

trait HttpClient {
    async fn get(&self, url: &str) -> Result<String, Error>;
}

// Класс с внедрением зависимостей
struct Service<D, H>
where
    D: DatabaseAccess,
    H: HttpClient,
{
    db: D,
    http_client: H,
}

impl<D, H> Service<D, H>
where
    D: DatabaseAccess,
    H: HttpClient,
{
    fn new(db: D, http_client: H) -> Self {
        Self { db, http_client }
    }
    
    async fn get_user_data(&self, id: u64) -> Result<UserData, Error> {
        let user = self.db.get_user(id).await?;
        let external_data = self.http_client.get(&format!("/api/users/{}", id)).await?;
        
        // Обработка данных
        Ok(UserData {
            username: user.username,
            extra: external_data,
        })
    }
}

// Тестирование
#[tokio::test]
async fn test_service() {
    // Создание моков
    let mut mock_db = MockDatabaseAccess::new();
    let mut mock_http = MockHttpClient::new();
    
    // Настройка моков
    mock_db.expect_get_user()
        .with(eq(1))
        .returning(|_| Ok(User { id: 1, username: "test".to_string() }));
    
    mock_http.expect_get()
        .with(eq("/api/users/1"))
        .returning(|_| Ok("external data".to_string()));
    
    // Создание сервиса с моками
    let service = Service::new(mock_db, mock_http);
    
    // Вызов метода
    let result = service.get_user_data(1).await.unwrap();
    
    // Проверка результата
    assert_eq!(result.username, "test");
    assert_eq!(result.extra, "external data");
}

3. Используйте фабрики для создания моков в тестах

rust
#[cfg(test)]
mod tests {
    use super::*;
    
    // Фабрика для создания тестовых компонентов
    struct TestFactory;
    
    impl TestFactory {
        fn create_mock_db() -> MockDatabase {
            let mut db = MockDatabase::new();
            // Настройка базовых ожиданий
            db.expect_connect().returning(|| Ok(()));
            db
        }
        
        fn create_mock_user_service() -> MockUserService {
            let mut service = MockUserService::new();
            // Настройка базовых ожиданий
            service
        }
        
        fn create_test_app() -> Router {
            // Создание тестового приложения с моками
            let db = Arc::new(Self::create_mock_db());
            let user_service = Arc::new(Self::create_mock_user_service());
            
            Router::new()
                .route("/users/:id", get(get_user_handler))
                .with_state(AppState {
                    db,
                    user_service,
                })
        }
    }
    
    #[tokio::test]
    async fn test_get_user() {
        let app = TestFactory::create_test_app();
        // Дальнейшее тестирование...
    }
}

4. Используйте контексты тестирования

rust
#[cfg(test)]
mod tests {
    use super::*;
    
    // Контекст для тестов пользователей
    struct UserTestContext {
        app: Router,
        user_repo: Arc<MockUserRepository>,
        auth_service: Arc<MockAuthService>,
    }
    
    impl UserTestContext {
        fn new() -> Self {
            let user_repo = Arc::new(MockUserRepository::new());
            let auth_service = Arc::new(MockAuthService::new());
            
            let app = Router::new()
                .route("/users/:id", get(get_user_handler))
                .with_state(AppState {
                    user_repo: user_repo.clone(),
                    auth_service: auth_service.clone(),
                });
            
            Self {
                app,
                user_repo,
                auth_service,
            }
        }
        
        // Вспомогательные методы для настройки моков
        fn mock_user_exists(&self, id: u64) {
            self.user_repo
                .expect_find_by_id()
                .with(mockall::predicate::eq(id))
                .returning(move |_| {
                    Ok(User {
                        id,
                        username: format!("user_{}", id),
                        email: format!("user_{}@example.com", id),
                    })
                });
        }
        
        fn mock_user_not_found(&self, id: u64) {
            self.user_repo
                .expect_find_by_id()
                .with(mockall::predicate::eq(id))
                .returning(|_| Err(DbError::NotFound));
        }
    }
    
    #[tokio::test]
    async fn test_get_existing_user() {
        let ctx = UserTestContext::new();
        ctx.mock_user_exists(42);
        
        let response = ctx.app
            .oneshot(
                Request::builder()
                    .uri("/users/42")
                    .body(Body::empty())
                    .unwrap()
            )
            .await
            .unwrap();
        
        assert_eq!(response.status(), StatusCode::OK);
        // Дополнительные проверки...
    }
    
    #[tokio::test]
    async fn test_get_non_existent_user() {
        let ctx = UserTestContext::new();
        ctx.mock_user_not_found(999);
        
        let response = ctx.app
            .oneshot(
                Request::builder()
                    .uri("/users/999")
                    .body(Body::empty())
                    .unwrap()
            )
            .await
            .unwrap();
        
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}

Моки и стабы являются мощными инструментами для изолированного тестирования компонентов в Axum-приложениях. Используя правильные подходы и библиотеки, вы можете создавать надежные и эффективные тесты, которые обеспечат качество вашего приложения.