Моки и стабы в тестировании Axum
При тестировании веб-приложений на Axum часто требуется изолировать компоненты от их внешних зависимостей. Для этого используются моки (mocks) и стабы (stubs). В этом разделе рассмотрим, как эффективно использовать моки и стабы в Axum-приложениях.
Содержание
- Что такое моки и стабы
- Библиотеки для создания моков в Rust
- Мокирование внешних сервисов
- Мокирование баз данных
- Мокирование методов Axum
- Создание тестовых двойников
- Лучшие практики
Что такое моки и стабы
Моки (Mocks) — это объекты, которые имитируют поведение реальных компонентов в контролируемых условиях. Они позволяют проверять, как тестируемый компонент взаимодействует с зависимостями.
Стабы (Stubs) — более простые имитаторы, которые возвращают заранее заданные значения на входящие запросы.
Основные отличия:
- Моки отслеживают вызовы методов и могут проверять, что они вызывались ожидаемым образом
- Стабы просто возвращают предопределенные ответы без отслеживания вызовов
Библиотеки для создания моков в Rust
В экосистеме Rust есть несколько библиотек для создания моков:
1. Mockall
Mockall — одна из самых популярных библиотек для мокирования в Rust.
[dependencies]
mockall = "0.12.0"
Пример использования Mockall:
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:
[dependencies]
mock_it = "0.4.1"
Пример использования:
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-сервисы:
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);
}
Мокирование баз данных
Подход с использованием трейтов
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 баз данных
Вместо мокирования, можно использовать встроенные базы данных для тестов:
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
Мокирование экстракторов
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);
// ... дополнительные проверки
}
Мокирование состояния приложения
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");
}
Создание тестовых двойников
Иногда полезно создавать полные тестовые двойники для сложных компонентов:
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. Проектируйте код с учетом тестируемости
// Плохо: жесткие зависимости
struct UserService {
db_pool: PgPool,
http_client: reqwest::Client,
}
// Хорошо: инъекция зависимостей через трейты
struct UserService<D, H>
where
D: DatabaseAccess,
H: HttpClient,
{
db: D,
http_client: H,
}
2. Используйте внедрение зависимостей
// Определение трейтов для зависимостей
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. Используйте фабрики для создания моков в тестах
#[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. Используйте контексты тестирования
#[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-приложениях. Используя правильные подходы и библиотеки, вы можете создавать надежные и эффективные тесты, которые обеспечат качество вашего приложения.