9.2 Proof at the Port
A craftsman who knows how to check a piece by hand checks it the way a craftsman does. A visitor checks differently: they arrive with business, knock on the door, and wait for an answer - the way everyone knocks. Between those two kinds of checking lies an entire class of errors that the craftsman misses precisely because he is the craftsman.
What You Need
To test HTTP routes, you need to send a request and check the response. And for a test to
call router(), it must be importable. The solution is standard: add
crates/api/src/lib.rs. When a package has both lib.rs and main.rs, Cargo builds both
- the router becomes part of the library and
mainuses it as a dependency.
The goal is to test the router and handlers, not the network stack. Axum provides oneshot
from tower::ServiceExt for this - send a request directly into the router and receive a
response, no server, no port, no network. The response is the same one a real client would
get: the router processes the request identically regardless of how it arrived.
This is the approach recommended by the axum documentation for testing handlers.
The Build
Create crates/api/src/lib.rs:
pub mod routes;
In crates/api/src/main.rs, remove the mod routes; and use routes::{...} lines - the
module now belongs to the library crate. Replace them with one import from 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;
In tests/Cargo.toml, add a new target and dependencies. Note [dev-dependencies] rather
than [dependencies] - these packages compile only for tests and do not end up in the
release binary:
[[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"
To pick the latest versions at the time of reading rather than editing by hand, use
cargo add with the --dev flag. tower must be added separately - because of the util
feature:
cargo add --dev axum tempfile tokio serde_json -p tq-tests
cargo add --dev tower --features util -p tq-tests
The versions in the example above are tested - they work together.
Now tests/tests/api.rs. Two helper functions reduce repetition - they carry no logic, only
cut down noise:
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() creates a fresh store for each test. state.clone() inside a test is a
cheap Arc clone: all clones look at the same store, so multiple oneshot calls within one
test share the same data.
#[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);
}
The Result
$ 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
The port now proves itself.
The complete
tqcode for this chapter is in9-shippable/02-proof-at-the-port/.
Lore: oneshot and Tower
Axum did not invent handler testing - it inherited it from Tower. In Tower, any service can
be tested through ServiceExt::oneshot: one request, one response, no network stack. This
works because Router implements the Service<Request> trait - the same interface a real
HTTP server uses. The test reaches the same code the production client does, just without the
TCP layer in between.
When a test is more complex - for example, you need to keep a connection alive or check
behavior under load - a real server on port 0 is spun up. For verifying that routes and
handlers are correct, oneshot is sufficient and faster.