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.
  • Называет реальные риски, диагностику и критерий корректности.
  • Связывает ответ с текущей документацией и миграционными ограничениями.

Sources

Related topics