Тестирование хендлеров в Axum
Тестирование хендлеров (обработчиков) — критически важный аспект разработки веб-приложений на Axum. В этом разделе рассмотрим подходы к изолированному тестированию обработчиков запросов.
Содержание
- Основы тестирования хендлеров
- Тестирование с экстракторами
- Модульное тестирование хендлеров
- Тестирование обработки ошибок
- Тестирование с состоянием
- Параметризованные тесты
- Лучшие практики
Основы тестирования хендлеров
Хендлеры в Axum — это асинхронные функции, которые могут принимать экстракторы и возвращать типы, реализующие IntoResponse
. Существует два основных подхода к тестированию хендлеров:
- Прямой вызов функции хендлера — идеально для модульного тестирования
- Тестирование через Router — для интеграционного тестирования
Пример прямого вызова хендлера:
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);
}
Тестирование с экстракторами
Для тестирования хендлеров с экстракторами необходимо имитировать входные данные:
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");
}
Модульное тестирование хендлеров
Для изолированного тестирования бизнес-логики хендлеров можно разделить код на слои:
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);
}
Тестирование обработки ошибок
Важная часть тестирования хендлеров — проверка обработки ошибок:
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"], "Ресурс не найден");
// Аналогично для других типов ошибок...
}
Тестирование с состоянием
Тестирование хендлеров, использующих общее состояние:
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);
}
Параметризованные тесты
Для тестирования хендлеров с различными входными данными можно использовать параметризованные тесты:
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. Отделяйте бизнес-логику от хендлеров
// Плохо
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. Используйте фикстуры для повторного использования кода в тестах
// Фикстуры для тестов
#[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. Тестируйте граничные случаи
#[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. Используйте моки для внешних зависимостей
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. Следуя описанным выше подходам и лучшим практикам, вы сможете создавать надежные, хорошо протестированные хендлеры, которые корректно обрабатывают все сценарии использования.