Recipe
Test an axum route without a network
Use when: you have an axum Router and want a fast test of a route's status, headers or JSON body, with no port and no server
Goal: a #[tokio::test] that builds a request, sends it through the router with oneshot, and checks the status and the body
Taught in Testing a service
Quick recipe
-
1
Write a
pub fn app() -> Routerthat builds the routes, the layers and fresh state. Addtower(with itsutilfeature) andhttp-body-utilunder[dev-dependencies].Example
Each call to
app()builds/tools,/reports/yearly, the 10-second time limit, and a new, emptyToolsmap.Why: each test gets its own state, so tests can run in parallel without seeing each other's data.
-
2
Start with
Request::get(uri)orRequest::post(uri). Add every header a real client sends, then a body.Example
Request::post("/tools"), then theCONTENT_TYPEheader, then.body(Body::from(r#"{"name":"jigsaw"}"#))and.unwrap().- no body —
.body(Body::empty()). - a JSON body —
.header(header::CONTENT_TYPE, "application/json"), then.body(Body::from(...))with the JSON text.
Why: extractors check a test request like any other, so a JSON body without its header gets
415. - no body —
-
3
Write
app().oneshot(request).await.unwrap(), withuse tower::ServiceExt;at the top.Example
A POST and then a GET each go to
app.clone(). The GET sees the new tool, because the clones share oneArc.- one request — call
app()right there. - several requests to the same state — write
let app = app();once, thenapp.clone().oneshot(...)for each request.
Why:
oneshottakes the router by value, and a secondapp()would build a separate, empty library. - one request — call
-
4
Assert on
response.status()before you read the body.Example
assert_eq!(response.status(), StatusCode::CREATED);. The first run failed here with415against201, which pointed straight at the missing header.Why: a wrong status explains most wrong bodies, and its message is the clearest.
-
5
Gather the body with
response.into_body().collect().await.unwrap().to_bytes(). Then compare it.Example
let tool: Tool = serde_json::from_slice(&bytes).unwrap();, thenassert_eq!(tool, Tool { id: 1, name: "jigsaw".to_string() });.- JSON — turn it into your type or a
serde_json::Valuewithserde_json::from_slice, and compare the values. - plain text — compare the bytes.
Why: comparing values ignores spacing and key order, so pretty-printed output doesn't break the test.
- JSON — turn it into your type or a
-
6
If the route has a time limit, mark the test
#[tokio::test(start_paused = true)], and measure withtokio::time::Instant.Example
The yearly report takes 60 seconds and the limit is 10. The test gets
408, andstart.elapsed()is 10 seconds, in no real time.Why:
TimeoutLayerwaits on Tokio's timer, and the paused clock jumps over it.
Common mistakes
- Posting JSON without its
Content-Typeheader, then blaming the service for the415. - Calling
app()twice in one test and expecting the second router to see the first one's data. - Comparing a JSON body as text, so a change in spacing breaks the test.
- Using
oneshotto test the listener or the port. That takes an end-to-end test.