AxumMiddleTechnical
Как писать интеграционные тесты для приложения на Axum?
Используйте tower::ServiceExt::oneshot() для in-process тестов без TCP-соединения или TcpListener::bind("0.0.0.0:0") с axum::serve для тестов с реальным сервером; изолируйте состояние, создавая Router через фабричную функцию в каждом тесте.
Интеграционные тесты для Axum-приложения
Axum отлично подходит для интеграционного тестирования: тип Router реализует tower::Service, поэтому можно создавать тестовый сервер прямо в процессе без реального TCP-соединения с помощью tower::ServiceExt и крейта axum-test или стандартного hyper. Начиная с Axum 0.7 рекомендуется использовать axum::serve с реальным портом или крейт axum-test для in-process тестов.
Зависимости
# Cargo.toml
[dev-dependencies]
tower = { version = "0.4", features = ["util"] }
http-body-util = "0.1"
hyper = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
In-process тест без реального сокета
use axum::{
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use http_body_util::BodyExt;
use tower::ServiceExt; // для метода .oneshot()
fn app() -> Router {
Router::new().route("/health", get(|| async { "OK" }))
}
#[tokio::test]
async fn test_health_endpoint() {
let app = app();
let response = app
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"OK");
}
Тест с JSON-телом и POST-запросом
use axum::{Json, routing::post};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
struct CreateUser {
name: String,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
Json(User { id: 1, name: payload.name })
}
fn app() -> Router {
Router::new().route("/users", post(create_user))
}
#[tokio::test]
async fn test_create_user() {
let app = app();
let body = serde_json::json!({ "name": "Alice" });
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/users")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let user: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(user["name"], "Alice");
}
Тест с реальным TCP-сервером (для клиентов типа reqwest)
use tokio::net::TcpListener;
#[tokio::test]
async fn test_with_real_server() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app()).await.unwrap();
});
let client = reqwest::Client::new();
let res = client
.get(format!("http://{}/health", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
Изоляция состояния между тестами
Для тестов с общим состоянием (например, базой данных) создавайте свежее состояние в каждом тесте:
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
type Db = Arc<Mutex<HashMap<u64, String>>>;
fn app_with_state(db: Db) -> Router {
Router::new()
.route("/items", get(list_items))
.with_state(db)
}
#[tokio::test]
async fn test_empty_list() {
let db: Db = Arc::new(Mutex::new(HashMap::new()));
let app = app_with_state(db);
// ...
}
Подводные камни
- Метод
.oneshot()потребляет Router — для нескольких запросов в одном тесте используйте.clone()перед каждым вызовом илиServiceExt::ready. - Без заголовка
content-type: application/jsonэкстракторJsonвернёт 415 Unsupported Media Type — частая причина «непонятных» 400/415 в тестах. - Если Router использует глобальное состояние (статик, синглтон), тесты могут интерферировать — изолируйте состояние через параметры фабричной функции.
- При привязке к порту 0 (рандомный свободный порт) не забывайте вытащить реальный адрес через
listener.local_addr()до передачи listener в serve. - Тест-сервер не завершается автоматически — используйте
tokio::spawnвнутри теста (tokio завершит задачу при выходе из теста). - Слои (TraceLayer, CorsLayer) добавляют накладные расходы; для unit-тестов конкретной логики тестируйте функции-обработчики напрямую без роутера.
- Сравнение тел как
&[u8]хрупко при JSON с нестабильным порядком ключей — десериализуйте в структуру илиserde_json::Valueдля сравнения.
Common mistakes
- Отвечать определением без production-сценария.
- Не называть runtime boundary, security boundary или failure mode.
- Игнорировать версию API, observability и тестовую проверку.
What the interviewer is testing
- Объясняет механизм своими словами и без выдуманных API.
- Называет реальные риски, диагностику и критерий корректности.
- Связывает ответ с текущей документацией и миграционными ограничениями.