Skip to content
Free public beta until December 4, 2026: every course is open, no card needed. After that, the first chapter of each course stays free and full access is a one-time $39 ($19 for up to 100 founding members chosen for helpful feedback). Your progress is kept. How the beta works

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

  1. 1

    Write a pub fn app() -> Router that builds the routes, the layers and fresh state. Add tower (with its util feature) and http-body-util under [dev-dependencies].

    Example

    Each call to app() builds /tools, /reports/yearly, the 10-second time limit, and a new, empty Tools map.

    Why: each test gets its own state, so tests can run in parallel without seeing each other's data.

  2. 2

    Start with Request::get(uri) or Request::post(uri). Add every header a real client sends, then a body.

    Example

    Request::post("/tools"), then the CONTENT_TYPE header, 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.

  3. 3

    Write app().oneshot(request).await.unwrap(), with use 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 one Arc.

    • one request — call app() right there.
    • several requests to the same state — write let app = app(); once, then app.clone().oneshot(...) for each request.

    Why: oneshot takes the router by value, and a second app() would build a separate, empty library.

  4. 4

    Assert on response.status() before you read the body.

    Example

    assert_eq!(response.status(), StatusCode::CREATED);. The first run failed here with 415 against 201, which pointed straight at the missing header.

    Why: a wrong status explains most wrong bodies, and its message is the clearest.

  5. 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();, then assert_eq!(tool, Tool { id: 1, name: "jigsaw".to_string() });.

    • JSON — turn it into your type or a serde_json::Value with serde_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.

  6. 6

    If the route has a time limit, mark the test #[tokio::test(start_paused = true)], and measure with tokio::time::Instant.

    Example

    The yearly report takes 60 seconds and the limit is 10. The test gets 408, and start.elapsed() is 10 seconds, in no real time.

    Why: TimeoutLayer waits on Tokio's timer, and the paused clock jumps over it.

Common mistakes

  • Posting JSON without its Content-Type header, then blaming the service for the 415.
  • 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 oneshot to test the listener or the port. That takes an end-to-end test.