Skip to content
rusthacks.dev
Web

Axum Testing With Tower Oneshot and In Memory Requests

Test an Axum router without opening a port: build a Request, call oneshot, read the body and assert on status and JSON. Includes the setup for shared state.

8 min read
Summarize in AI
Flat illustration for the post “Axum Testing With Tower Oneshot and In Memory Requests”, composed around testing.

An Axum Router is a Tower service, a value that turns a request into a response, and a value that does that needs no socket. This is the whole testing story: build the router, hand it a hand-made request, inspect the response. There is no server to start, no port to pick and nothing to wait for, so tests run in microseconds and in parallel. This post sets up a small to-do API for testing, writes five tests that cover the success path and four different failures, shows what a failing test prints, handles shared state and layers like ConnectInfo, and says what in-memory tests cannot tell you. All tests below ran under cargo test with axum 0.8.9, tower 0.5 and http-body-util 0.1 on Rust 1.99.

Make the router constructible

The one structural requirement is that tests can get at the router. Put its construction in a function that takes whatever it depends on, and keep main as a thin wrapper that binds a socket:

src/main.rsrust
use std::sync::{Arc, Mutex};

use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
};
use serde::{Deserialize, Serialize};

#[derive(Clone, Default)]
pub struct AppState {
    todos: Arc<Mutex<Vec<Todo>>>,
}

#[derive(Clone, Serialize, Deserialize, PartialEq, Debug)]
pub struct Todo {
    pub id: u32,
    pub title: String,
}

#[derive(Deserialize)]
struct NewTodo {
    title: String,
}

async fn create(State(s): State<AppState>, Json(n): Json<NewTodo>) -> (StatusCode, Json<Todo>) {
    let mut todos = s.todos.lock().unwrap();
    let todo = Todo { id: todos.len() as u32 + 1, title: n.title };
    todos.push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}

async fn fetch(State(s): State<AppState>, Path(id): Path<u32>) -> Result<Json<Todo>, StatusCode> {
    s.todos.lock().unwrap().iter().find(|t| t.id == id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/todos", post(create))
        .route("/todos/{id}", get(fetch))
        .with_state(state)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app(AppState::default())).await.unwrap();
}

The state is a parameter, so each test constructs its own empty one and tests cannot interfere with each other. That is the payoff for avoiding globals, as sharing state between handlers argues. Add the test dependencies: tower = { version = "0.5", features = ["util"] } for oneshot and http-body-util = "0.1" for reading bodies. Axum re-exports Body and http, so you do not add those.

Five tests

src/main.rsrust
#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Request, StatusCode, header},
    };
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    async fn body_json<T: serde::de::DeserializeOwned>(res: axum::response::Response) -> T {
        let bytes = res.into_body().collect().await.unwrap().to_bytes();
        serde_json::from_slice(&bytes).unwrap()
    }

    fn post_json(uri: &str, body: &str) -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri(uri)
            .header(header::CONTENT_TYPE, "application/json")
            .body(Body::from(body.to_owned()))
            .unwrap()
    }

    #[tokio::test]
    async fn create_then_fetch() {
        let app = app(AppState::default());

        let res = app.clone().oneshot(post_json("/todos", r#"{"title":"write tests"}"#)).await.unwrap();
        assert_eq!(res.status(), StatusCode::CREATED);
        let created: Todo = body_json(res).await;
        assert_eq!(created, Todo { id: 1, title: "write tests".into() });

        let res = app.oneshot(Request::get("/todos/1").body(Body::empty()).unwrap()).await.unwrap();
        assert_eq!(res.status(), StatusCode::OK);
        let fetched: Todo = body_json(res).await;
        assert_eq!(fetched, created);
    }

    #[tokio::test]
    async fn missing_todo_is_404() {
        let res = app(AppState::default())
            .oneshot(Request::get("/todos/99").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn bad_json_is_422() {
        let res = app(AppState::default()).oneshot(post_json("/todos", r#"{"name":"x"}"#)).await.unwrap();
        assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
    }

    #[tokio::test]
    async fn wrong_content_type_is_415() {
        let req = Request::post("/todos").body(Body::from(r#"{"title":"x"}"#)).unwrap();
        let res = app(AppState::default()).oneshot(req).await.unwrap();
        assert_eq!(res.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
    }

    #[tokio::test]
    async fn bad_path_id_is_400() {
        let res = app(AppState::default())
            .oneshot(Request::get("/todos/abc").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
    }
}
Shell
cargo test
Text
running 5 tests
test tests::bad_path_id_is_400 ... ok
test tests::wrong_content_type_is_415 ... ok
test tests::bad_json_is_422 ... ok
test tests::missing_todo_is_404 ... ok
test tests::create_then_fetch ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Five tests in a few milliseconds, no port bound. Three pieces of the mechanism are worth naming. oneshot comes from tower::ServiceExt: it waits for the service to be ready, calls it once, and consumes it, which is why the first test writes app.clone().oneshot(...) for the call it will follow with another. Cloning a Router is cheap (it shares the routes and the state behind Arcs), and the clone shares the AppState, so the todo created by the first call is visible to the second. Next, the request builder: Request::get(uri) and Request::post(uri) are shortcuts, and for a JSON body you set the content type yourself, because nothing does it for you. The fourth test relies on exactly that, sending the same body without the header and getting the 415 that JSON requests and responses described. Finally, reading the body takes two steps: collect() gathers the stream into one buffer and to_bytes() gives you the bytes to parse. Wrap that in a helper once, as body_json does, and the tests stay readable.

Assert on the status before the body. When a test fails, the status tells you which layer rejected the request, and parsing the body of a 422 as a Todo would otherwise fail with a confusing serde_json error that hides the real problem. Notice the test choices as well: of the five, four are failures. The success path is the one you will notice if it breaks; the rejection paths (bad content type, bad shape, bad path, missing resource) are where behavior drifts silently when someone changes an extractor or a middleware, and a test that pins each status turns that drift into a failing build.

What a failure prints

Change the first test's expectation to an id of 2 and run it again; the failure is specific enough to act on:

Text
thread 'tests::create_then_fetch' panicked at src/main.rs:82:9:
assertion `left == right` failed
  left: Todo { id: 1, title: "write tests" }
 right: Todo { id: 2, title: "write tests" }
test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

That output comes from deriving Debug and PartialEq on the response type, which the example does for exactly this reason: comparing whole values shows the difference field by field. Prefer one assert_eq! on a full value to five assertions on its parts.

Layers, state and extractors in tests

Because the router under test is the production router, every layer you attach in app() runs in the test: authentication, size limits, tracing. That is the point, and it needs two accommodations. First, a handler that extracts ConnectInfo<SocketAddr> fails in a test because no server supplied the address. Axum ships a layer that fakes it:

src/main.rsrust
use axum::extract::{ConnectInfo, connect_info::MockConnectInfo};
use std::net::SocketAddr;

async fn whoami(ConnectInfo(a): ConnectInfo<SocketAddr>) -> String {
    a.to_string()
}

#[tokio::test]
async fn connect_info_mock() {
    let app = Router::new()
        .route("/ip", get(whoami))
        .layer(MockConnectInfo(SocketAddr::from(([203, 0, 113, 9], 4000))));
    let res = app.oneshot(Request::get("/ip").body(Body::empty()).unwrap()).await.unwrap();
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"203.0.113.9:4000");
}

This is how you test the rate limiter from rate limiting without a socket: mock the address, send six requests through clones of one router, and assert the sixth is a 429. Second, anything time-dependent (token refill, session expiry, a timeout layer) should take its clock from a parameter or from tokio::time::pause(), which freezes Tokio's timer so tokio::time::advance(Duration::from_secs(61)).await moves it forward instantly. Mark the test #[tokio::test(start_paused = true)] and a test about a 60-second expiry takes no real time. For authentication, do not bypass the middleware: obtain a real token by calling the login route in the test, or build one with the same helper the handler uses, and send it in the header. A test that skips the auth layer proves nothing about the route being protected.

External dependencies belong behind a trait with a fake behind it in tests, rather than a mocking library, the approach in mocks versus fakes. A database is better tested against a real instance in an integration test (the tests directory), with each test running in a transaction or its own schema. Keep the in-memory router tests for routing, extraction, status codes, headers and handler logic, and use the real database for the queries.

Streaming and real sockets

Two things oneshot is awkward for. A streaming response, such as server-sent events, never finishes, so collect() would wait forever; read frames one at a time with res.into_body().frame().await and stop after the ones you expect. And behavior that lives below the router (HTTP/2, connection reuse, graceful shutdown, TLS) needs a real listener. Bind port zero, so the operating system picks a free one, and call it with a real client:

tests/server.rsrust
#[tokio::test]
async fn over_a_real_socket() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, app(AppState::default())).await.unwrap();
    });
    // use reqwest or hyper here against http://{addr}/todos
    assert_ne!(addr.port(), 0);
}

Reserve these for the few cases that need them. They are slower, they can flake on port or timing issues and each one holds a task and a socket, while an in-memory test of the same route is deterministic.

What these tests cannot tell you

An in-memory call skips everything outside the router: the reverse proxy, TLS, real header size limits, connection handling and the behavior of a body that arrives in slow chunks. It also skips axum::serve's own handling of malformed requests, so a test cannot prove how the server reacts to a garbled request line. Cover those with a smoke test in CI that starts the real binary and curls /health, which is a few lines and catches the class of failure where the unit tests all pass and the binary does not start (a missing environment variable, a migration that did not run). The full arrangement, with the router builder, state and these tests together, is in an Axum production service, and the testing method for the rest of a Rust project is in the complete testing guide.

Also inTesting
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.