← Оглавление

9.2 Проверка порта

Мастер, умеющий проверить изделие руками, проверяет его так, как проверяет мастер. Посетитель проверяет иначе: он приходит с делом, стучит в дверь и ждёт ответа - так, как стучат все. Между этими двумя проверками умещается целый класс ошибок, которые мастер не замечает именно потому, что он мастер.

Что нужно

Чтобы проверить HTTP-маршруты, нужно отправить запрос и проверить ответ. А чтобы тест мог вызвать router(), нужно сделать его импортируемым. Решение стандартное: добавить crates/api/src/lib.rs. Когда у пакета есть и lib.rs, и main.rs, Cargo собирает их вместе - роутер становится частью библиотеки, и main использует её как зависимость.

Задача - проверить роутер и хендлеры, а не сетевой стек. Axum предоставляет для этого oneshot из tower::ServiceExt - отправить запрос напрямую в роутер и получить ответ, без сервера, без порта, без сети. Ответ приходит тот же, что получил бы настоящий клиент: роутер обрабатывает запрос одинаково вне зависимости от того, как он пришёл.

Это подход, рекомендованный документацией axum для тестирования хендлеров.

Постройка

Создайте crates/api/src/lib.rs:

pub mod routes;

В crates/api/src/main.rs удалите строки mod routes; и use routes::{...} - модуль теперь принадлежит библиотечному крейту. Вместо них один импорт из tq_api:

use axum::{Router, routing::get};
use std::path::Path;
use std::sync::{Arc, Mutex};
use tq_api::routes::{AppState, SharedStore, router};
use tq_core::config::Config;
use tq_core::persistence;
use tq_core::store::TaskStore;

В tests/Cargo.toml добавьте новую цель и зависимости. Обратите внимание на [dev-dependencies] вместо обычного [dependencies] - эти пакеты компилируются только для тестов и не попадают в релизный бинарник:

[[test]]
name = "api"
path = "tests/api.rs"

[dev-dependencies]
tq-core    = { path = "../crates/core" }
tq-api     = { path = "../crates/api" }
axum       = "0.8"
tower      = { version = "0.5", features = ["util"] }
tempfile   = "3"
tokio      = { version = "1", features = ["full"] }
serde_json = "1"

Если хотите взять самые свежие версии на момент чтения, вместо ручного редактирования можно использовать cargo add с флагом --dev. tower нужно добавлять отдельно - из-за фичи util:

cargo add --dev axum tempfile tokio serde_json -p tq-tests
cargo add --dev tower --features util -p tq-tests

Версии в примере выше проверены - они работают вместе.

Теперь tests/tests/api.rs. Две вспомогательные функции сокращают повторения - они не несут логики, только снижают шум:

use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::Value;
use std::sync::{Arc, Mutex};
use tq_api::routes::{AppState, router};
use tq_core::store::TaskStore;
use tower::ServiceExt;

fn make_state() -> (AppState, tempfile::TempDir) {
    let dir = tempfile::tempdir().unwrap();
    let state = AppState {
        store: Arc::new(Mutex::new(TaskStore::new(vec![]))),
        data_path: dir.path().join("tasks.json"),
    };
    (state, dir)
}

async fn body_json(res: axum::response::Response) -> Value {
    let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
    serde_json::from_slice(&bytes).unwrap()
}

make_state() создаёт чистое хранилище для каждого теста. state.clone() внутри теста - дешёвый клон Arc: все клоны смотрят на одно хранилище, поэтому несколько вызовов oneshot в одном тесте работают с общими данными.

#[tokio::test]
async fn add_task_returns_task() {
    let (state, _dir) = make_state();

    let res = router(state)
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/tasks")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"title":"Buy coffee"}"#))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::OK);
    let body = body_json(res).await;
    assert_eq!(body["title"], "Buy coffee");
    assert_eq!(body["status"], "Todo");
}

#[tokio::test]
async fn list_returns_added_tasks() {
    let (state, _dir) = make_state();

    for json in [r#"{"title":"Buy coffee"}"#, r#"{"title":"Write tests"}"#] {
        router(state.clone())
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/tasks")
                    .header("content-type", "application/json")
                    .body(Body::from(json))
                    .unwrap(),
            )
            .await
            .unwrap();
    }

    let res = router(state)
        .oneshot(Request::builder().uri("/tasks").body(Body::empty()).unwrap())
        .await
        .unwrap();

    let tasks: Vec<Value> = serde_json::from_slice(
        &axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(),
    )
    .unwrap();
    assert_eq!(tasks.len(), 2);
}

#[tokio::test]
async fn done_marks_task_complete() {
    let (state, _dir) = make_state();

    let res = router(state.clone())
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/tasks")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"title":"Buy coffee"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let id = body_json(res).await["id"].as_u64().unwrap();

    let res = router(state)
        .oneshot(
            Request::builder()
                .method("PATCH")
                .uri(format!("/tasks/{id}/done"))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(body_json(res).await["status"], "Done");
}

#[tokio::test]
async fn get_unknown_task_returns_404() {
    let (state, _dir) = make_state();

    let res = router(state)
        .oneshot(Request::builder().uri("/tasks/999").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::NOT_FOUND);
}

Результат

$ make ci
...
     Running tests/api.rs (target/debug/deps/api-...)

running 4 tests
test add_task_returns_task ... ok
test done_marks_task_complete ... ok
test get_unknown_task_returns_404 ... ok
test list_returns_added_tasks ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Порт теперь доказывает сам себя.

Полный код этой главы находится в 9-shippable/02-proof-at-the-port/.


Lore: oneshot и Tower

Axum не изобретал тестирование хендлеров - он унаследовал его от Tower. В Tower любой сервис можно протестировать через ServiceExt::oneshot: один запрос, один ответ, никакого сетевого стека. Это работает, потому что Router реализует трейт Service<Request> - тот же интерфейс, который использует реальный HTTP-сервер. Тест обращается к тому же коду, что и клиент в продакшене, просто без прослойки в виде TCP.

Когда тест сложнее - например, нужно сохранить соединение или проверить поведение при высокой нагрузке - разворачивают реальный сервер на порту 0. Для проверки правильности маршрутов и хендлеров oneshot достаточен и быстрее.