Skip to content

Тестирование хендлеров в Axum

Тестирование хендлеров (обработчиков) — критически важный аспект разработки веб-приложений на Axum. В этом разделе рассмотрим подходы к изолированному тестированию обработчиков запросов.

Содержание

Основы тестирования хендлеров

Хендлеры в Axum — это асинхронные функции, которые могут принимать экстракторы и возвращать типы, реализующие IntoResponse. Существует два основных подхода к тестированию хендлеров:

  1. Прямой вызов функции хендлера — идеально для модульного тестирования
  2. Тестирование через Router — для интеграционного тестирования

Пример прямого вызова хендлера:

rust
use axum::http::StatusCode;
use axum::Json;
use serde_json::json;

// Хендлер, который нужно протестировать
async fn hello_world() -> String {
    "Hello, World!".to_string()
}

#[tokio::test]
async fn test_hello_world_directly() {
    let result = hello_world().await;
    assert_eq!(result, "Hello, World!");
}

// Более сложный хендлер с JSON ответом
async fn json_handler() -> Json<serde_json::Value> {
    Json(json!({
        "message": "Success",
        "code": 200
    }))
}

#[tokio::test]
async fn test_json_handler_directly() {
    let result = json_handler().await;
    assert_eq!(result.0["message"], "Success");
    assert_eq!(result.0["code"], 200);
}

Тестирование с экстракторами

Для тестирования хендлеров с экстракторами необходимо имитировать входные данные:

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

// Структуры для запроса
#[derive(Deserialize)]
struct UserQuery {
    include_details: Option<bool>,
}

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

// Структура для ответа
#[derive(Serialize, Eq, PartialEq, Debug)]
struct User {
    id: i32,
    name: String,
    email: String,
    details: Option<String>,
}

// Хендлер с экстракторами
async fn get_user(
    Path(user_id): Path<i32>,
    Query(query): Query<UserQuery>,
) -> Json<User> {
    let details = if query.include_details == Some(true) {
        Some("Дополнительная информация о пользователе".to_string())
    } else {
        None
    };
    
    Json(User {
        id: user_id,
        name: format!("Пользователь {}", user_id),
        email: format!("user{}@example.com", user_id),
        details,
    })
}

#[tokio::test]
async fn test_get_user_with_extractors() {
    // Имитация параметров
    let user_id = 42;
    let query = UserQuery {
        include_details: Some(true),
    };
    
    // Вызов хендлера с тестовыми параметрами
    let result = get_user(Path(user_id), Query(query)).await;
    
    // Проверка результата
    assert_eq!(result.0.id, 42);
    assert_eq!(result.0.name, "Пользователь 42");
    assert_eq!(result.0.email, "user42@example.com");
    assert_eq!(result.0.details, Some("Дополнительная информация о пользователе".to_string()));
}

// Хендлер с JSON телом
async fn create_user(
    ExtractJson(payload): ExtractJson<CreateUser>,
) -> (StatusCode, Json<User>) {
    let user = User {
        id: 100, // В реальном приложении был бы генерированный ID
        name: payload.name,
        email: payload.email,
        details: None,
    };
    
    (StatusCode::CREATED, Json(user))
}

#[tokio::test]
async fn test_create_user() {
    // Имитация JSON-запроса
    let payload = CreateUser {
        name: "Иван".to_string(),
        email: "ivan@example.com".to_string(),
    };
    
    // Вызов хендлера
    let (status, json) = create_user(ExtractJson(payload)).await;
    
    // Проверка результата
    assert_eq!(status, StatusCode::CREATED);
    assert_eq!(json.0.name, "Иван");
    assert_eq!(json.0.email, "ivan@example.com");
}

Модульное тестирование хендлеров

Для изолированного тестирования бизнес-логики хендлеров можно разделить код на слои:

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

// Бизнес-логика, отделенная от хендлера
#[derive(Clone)]
struct UserService {
    // Поля для хранения зависимостей, например, соединение с БД
}

impl UserService {
    fn new() -> Self {
        Self {}
    }
    
    // Бизнес-функция, которую можно тестировать отдельно
    async fn find_user_by_id(&self, id: i32) -> Result<User, ApiError> {
        // В реальном приложении здесь был бы запрос к БД
        if id > 0 {
            Ok(User {
                id,
                name: format!("Пользователь {}", id),
                email: format!("user{}@example.com", id),
            })
        } else {
            Err(ApiError::NotFound)
        }
    }
}

// Ошибки API
enum ApiError {
    NotFound,
    // Другие ошибки...
}

// Хендлер, использующий сервис
async fn get_user_handler(
    State(service): State<Arc<UserService>>,
    Path(user_id): Path<i32>,
) -> Result<Json<User>, StatusCode> {
    match service.find_user_by_id(user_id).await {
        Ok(user) => Ok(Json(user)),
        Err(ApiError::NotFound) => Err(StatusCode::NOT_FOUND),
        // Обработка других ошибок...
    }
}

// Тест бизнес-логики отдельно от хендлера
#[tokio::test]
async fn test_user_service_find_by_id() {
    let service = UserService::new();
    
    // Тест успешного случая
    let user = service.find_user_by_id(1).await.unwrap();
    assert_eq!(user.id, 1);
    assert_eq!(user.name, "Пользователь 1");
    
    // Тест случая с ошибкой
    let result = service.find_user_by_id(-1).await;
    assert!(matches!(result, Err(ApiError::NotFound)));
}

// Тест хендлера с моком сервиса
#[tokio::test]
async fn test_get_user_handler() {
    let service = Arc::new(UserService::new());
    
    // Тест успешного запроса
    let result = get_user_handler(
        State(service.clone()),
        Path(1),
    ).await;
    
    assert!(result.is_ok());
    let user = result.unwrap().0;
    assert_eq!(user.id, 1);
    
    // Тест запроса с ошибкой
    let result = get_user_handler(
        State(service.clone()),
        Path(-1),
    ).await;
    
    assert!(result.is_err());
    assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}

Тестирование обработки ошибок

Важная часть тестирования хендлеров — проверка обработки ошибок:

rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

// Определение типа ошибки
#[derive(Error, Debug)]
enum AppError {
    #[error("Ресурс не найден")]
    NotFound,
    
    #[error("Неавторизованный доступ")]
    Unauthorized,
    
    #[error("Внутренняя ошибка сервера: {0}")]
    Internal(String),
}

// Реализация преобразования ошибки в HTTP-ответ
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
            AppError::Internal(msg) => {
                eprintln!("Internal error: {}", msg);
                (StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера".to_string())
            }
        };
        
        (status, Json(json!({ "error": error_message }))).into_response()
    }
}

// Хендлер, возвращающий ошибку
async fn fallible_handler(user_id: Path<i32>) -> Result<Json<User>, AppError> {
    match user_id.0 {
        1 => Ok(Json(User {
            id: 1,
            name: "Валидный пользователь".to_string(),
            email: "valid@example.com".to_string(),
        })),
        404 => Err(AppError::NotFound),
        401 => Err(AppError::Unauthorized),
        _ => Err(AppError::Internal("Тестовая внутренняя ошибка".to_string())),
    }
}

#[tokio::test]
async fn test_fallible_handler() {
    // Тест успешного случая
    let result = fallible_handler(Path(1)).await;
    assert!(result.is_ok());
    
    // Тест ошибки 404
    let err = fallible_handler(Path(404)).await.unwrap_err();
    let response = err.into_response();
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
    
    // Проверка тела ответа с ошибкой
    let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
    let body_str = String::from_utf8(body.to_vec()).unwrap();
    let json: serde_json::Value = serde_json::from_str(&body_str).unwrap();
    assert_eq!(json["error"], "Ресурс не найден");
    
    // Аналогично для других типов ошибок...
}

Тестирование с состоянием

Тестирование хендлеров, использующих общее состояние:

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

// Структура состояния
#[derive(Clone)]
struct AppState {
    counter: Arc<Mutex<i32>>,
}

// Хендлер, использующий состояние
async fn increment_counter(
    State(state): State<AppState>,
) -> String {
    let mut counter = state.counter.lock().unwrap();
    *counter += 1;
    format!("Текущее значение: {}", *counter)
}

#[tokio::test]
async fn test_increment_counter() {
    // Подготовка состояния для теста
    let state = AppState {
        counter: Arc::new(Mutex::new(0)),
    };
    
    // Первый вызов хендлера
    let result1 = increment_counter(State(state.clone())).await;
    assert_eq!(result1, "Текущее значение: 1");
    
    // Второй вызов хендлера с тем же состоянием
    let result2 = increment_counter(State(state.clone())).await;
    assert_eq!(result2, "Текущее значение: 2");
    
    // Проверка состояния напрямую
    assert_eq!(*state.counter.lock().unwrap(), 2);
}

Параметризованные тесты

Для тестирования хендлеров с различными входными данными можно использовать параметризованные тесты:

rust
use rstest::rstest;

// Хендлер для тестирования
async fn calculate(Path(n): Path<i32>) -> String {
    if n <= 0 {
        "Число должно быть положительным".to_string()
    } else if n % 2 == 0 {
        format!("{} - четное число", n)
    } else {
        format!("{} - нечетное число", n)
    }
}

#[rstest]
#[case(1, "1 - нечетное число")]
#[case(2, "2 - четное число")]
#[case(0, "Число должно быть положительным")]
#[case(-1, "Число должно быть положительным")]
#[tokio::test]
async fn test_calculate_with_params(#[case] input: i32, #[case] expected: &str) {
    let result = calculate(Path(input)).await;
    assert_eq!(result, expected);
}

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

1. Отделяйте бизнес-логику от хендлеров

rust
// Плохо
async fn create_user_bad(Json(payload): Json<CreateUser>) -> Result<Json<User>, StatusCode> {
    // Вся бизнес-логика внутри хендлера
    if payload.name.is_empty() || payload.email.is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }
    
    // Валидация email
    if !payload.email.contains('@') {
        return Err(StatusCode::BAD_REQUEST);
    }
    
    // Создание и возврат пользователя
    Ok(Json(User {
        id: 1,
        name: payload.name,
        email: payload.email,
        details: None,
    }))
}

// Хорошо
async fn create_user_good(
    State(service): State<Arc<UserService>>,
    Json(payload): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
    match service.create_user(payload).await {
        Ok(user) => Ok(Json(user)),
        Err(ServiceError::Validation(_)) => Err(StatusCode::BAD_REQUEST),
        Err(ServiceError::Database(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

// Тесты сосредоточены на бизнес-логике
#[tokio::test]
async fn test_user_service_create() {
    let service = UserService::new();
    
    // Валидный пользователь
    let valid_user = CreateUser {
        name: "Иван".to_string(),
        email: "ivan@example.com".to_string(),
    };
    let result = service.create_user(valid_user).await;
    assert!(result.is_ok());
    
    // Невалидный email
    let invalid_user = CreateUser {
        name: "Иван".to_string(),
        email: "invalid-email".to_string(),
    };
    let result = service.create_user(invalid_user).await;
    assert!(matches!(result, Err(ServiceError::Validation(_))));
}

2. Используйте фикстуры для повторного использования кода в тестах

rust
// Фикстуры для тестов
#[cfg(test)]
mod test_utils {
    use super::*;
    
    // Функция для создания тестового сервиса
    pub fn create_test_service() -> UserService {
        UserService::new()
    }
    
    // Функция для создания тестового пользователя
    pub fn create_test_user() -> User {
        User {
            id: 1,
            name: "Тестовый пользователь".to_string(),
            email: "test@example.com".to_string(),
            details: None,
        }
    }
}

#[tokio::test]
async fn test_with_fixtures() {
    use test_utils::{create_test_service, create_test_user};
    
    let service = create_test_service();
    let test_user = create_test_user();
    
    // Использование фикстур в тестах...
}

3. Тестируйте граничные случаи

rust
#[tokio::test]
async fn test_boundary_cases() {
    // Тестирование пустых входных данных
    let result = validate_input("").await;
    assert_eq!(result, Err(ValidationError::EmptyInput));
    
    // Тестирование максимальной длины
    let long_input = "a".repeat(1001);
    let result = validate_input(&long_input).await;
    assert_eq!(result, Err(ValidationError::TooLong));
    
    // Тестирование точно на границе
    let max_input = "a".repeat(1000);
    let result = validate_input(&max_input).await;
    assert!(result.is_ok());
}

4. Используйте моки для внешних зависимостей

rust
use mockall::predicate::*;
use mockall::*;

// Трейт для сервиса пользователей
#[async_trait]
trait UserRepository {
    async fn find_by_id(&self, id: i32) -> Result<User, RepositoryError>;
    async fn save(&self, user: CreateUser) -> Result<User, RepositoryError>;
}

// Автоматическое создание мока
mock! {
    UserRepo {}
    
    #[async_trait]
    impl UserRepository for UserRepo {
        async fn find_by_id(&self, id: i32) -> Result<User, RepositoryError>;
        async fn save(&self, user: CreateUser) -> Result<User, RepositoryError>;
    }
}

// Хендлер, использующий репозиторий через трейт
async fn get_user_by_id<T: UserRepository>(
    State(repo): State<Arc<T>>,
    Path(id): Path<i32>,
) -> Result<Json<User>, StatusCode> {
    match repo.find_by_id(id).await {
        Ok(user) => Ok(Json(user)),
        Err(RepositoryError::NotFound) => Err(StatusCode::NOT_FOUND),
        Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

#[tokio::test]
async fn test_get_user_with_mock() {
    // Создание мока
    let mut mock_repo = MockUserRepo::new();
    
    // Настройка ожидаемого вызова для id=1
    mock_repo.expect_find_by_id()
        .with(eq(1))
        .times(1)
        .returning(|_| Ok(User {
            id: 1,
            name: "Тестовый пользователь".to_string(),
            email: "test@example.com".to_string(),
            details: None,
        }));
    
    // Настройка ожидаемого вызова для id=404
    mock_repo.expect_find_by_id()
        .with(eq(404))
        .times(1)
        .returning(|_| Err(RepositoryError::NotFound));
    
    let repo = Arc::new(mock_repo);
    
    // Тест успешного случая
    let result = get_user_by_id(State(repo.clone()), Path(1)).await;
    assert!(result.is_ok());
    
    // Тест случая Not Found
    let result = get_user_by_id(State(repo.clone()), Path(404)).await;
    assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
}

Тестирование хендлеров является ключевым аспектом разработки качественных веб-приложений на Axum. Следуя описанным выше подходам и лучшим практикам, вы сможете создавать надежные, хорошо протестированные хендлеры, которые корректно обрабатывают все сценарии использования.