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 достаточен и быстрее.