Skip to main content
Axum applications are easy to test because Router implements tower::Service, allowing you to call handlers directly without spawning an HTTP server.

Basic testing approach

Create a function that returns your app for easy testing:
use axum::{routing::get, Router};

fn app() -> Router {
    Router::new()
        .route("/", get(|| async { "Hello, World!" }))
        .route("/api/users", get(list_users).post(create_user))
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    axum::serve(listener, app()).await;
}
Separating your app construction into a function makes it reusable in both production code and tests.

Testing handlers with oneshot

Use oneshot to call your app with a single request:
#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Request, StatusCode},
    };
    use http_body_util::BodyExt;
    use tower::ServiceExt; // for `oneshot`

    #[tokio::test]
    async fn test_hello_world() {
        let app = app();

        let response = app
            .oneshot(Request::get("/").body(Body::empty()).unwrap())
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::OK);

        let body = response.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(&body[..], b"Hello, World!");
    }
}
1

Create the app

Call your app construction function to get a Router.
2

Build a request

Use Request::builder() or convenience methods like Request::get() to create requests.
3

Call with oneshot

Use tower::ServiceExt::oneshot() to send the request and get the response.
4

Assert the response

Check status codes, headers, and body contents.

Testing JSON endpoints

Test endpoints that accept and return JSON:
use axum::{
    extract::Json,
    routing::post,
    Router,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Deserialize, Serialize)]
struct User {
    name: String,
    email: String,
}

async fn create_user(Json(user): Json<User>) -> Json<Value> {
    Json(json!({
        "id": 123,
        "name": user.name,
        "email": user.email,
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{self, Request, StatusCode},
    };
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    #[tokio::test]
    async fn test_create_user() {
        let app = Router::new().route("/users", post(create_user));

        let response = app
            .oneshot(
                Request::post("/users")
                    .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
                    .body(Body::from(
                        serde_json::to_vec(&json!({
                            "name": "Alice",
                            "email": "alice@example.com"
                        }))
                        .unwrap(),
                    ))
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::OK);

        let body = response.into_body().collect().await.unwrap().to_bytes();
        let body: Value = serde_json::from_slice(&body).unwrap();
        
        assert_eq!(body["id"], 123);
        assert_eq!(body["name"], "Alice");
        assert_eq!(body["email"], "alice@example.com");
    }
}

Testing with shared state

Test handlers that use State:
use axum::{
    extract::State,
    routing::get,
    Router,
};
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    counter: Arc<AtomicU64>,
}

async fn get_counter(State(state): State<AppState>) -> String {
    state.counter.load(Ordering::Relaxed).to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};

    fn app() -> Router {
        let state = AppState {
            counter: Arc::new(AtomicU64::new(42)),
        };
        Router::new()
            .route("/counter", get(get_counter))
            .with_state(state)
    }

    #[tokio::test]
    async fn test_counter() {
        let app = app();

        let response = app
            .oneshot(Request::get("/counter").body(Body::empty()).unwrap())
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::OK);
        
        let body = response.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(&body[..], b"42");
    }
}

Testing ConnectInfo

Some extractors like ConnectInfo need special handling in tests:
use axum::{
    extract::ConnectInfo,
    extract::connect_info::MockConnectInfo,
    routing::get,
    Router,
};
use std::net::SocketAddr;

async fn handler(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> String {
    format!("Connected from: {addr}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use tower::Service;

    #[tokio::test]
    async fn test_with_connect_info() {
        let mut app = Router::new()
            .route("/", get(handler))
            // Use MockConnectInfo for testing
            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 3000))))
            .into_service();

        let request = Request::get("/").body(Body::empty()).unwrap();
        let response = app.ready().await.unwrap().call(request).await.unwrap();

        assert_eq!(response.status(), StatusCode::OK);
        
        let body = response.into_body().collect().await.unwrap().to_bytes();
        assert!(std::str::from_utf8(&body).unwrap().contains("127.0.0.1:3000"));
    }
}

Multiple requests with ready and call

For testing multiple requests, use ready() and call():
#[tokio::test]
async fn test_multiple_requests() {
    use tower::Service;
    
    let mut app = app().into_service();

    // First request
    let request = Request::get("/").body(Body::empty()).unwrap();
    let response = app.ready().await.unwrap().call(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);

    // Second request
    let request = Request::get("/").body(Body::empty()).unwrap();
    let response = app.ready().await.unwrap().call(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);
}
Call ready() before each call() to ensure the service is ready to handle the request.

Testing error cases

Test that your handlers properly reject invalid inputs:
#[tokio::test]
async fn test_not_found() {
    let app = app();

    let response = app
        .oneshot(Request::get("/does-not-exist").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_invalid_json() {
    let app = Router::new().route("/users", post(create_user));

    let response = app
        .oneshot(
            Request::post("/users")
                .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
                .body(Body::from("{invalid json}"))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn test_missing_content_type() {
    let app = Router::new().route("/users", post(create_user));

    let response = app
        .oneshot(
            Request::post("/users")
                .body(Body::from(r#"{"name":"Alice"}"#))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}

Integration tests with a real server

Sometimes you need to test with an actual HTTP server:
#[tokio::test]
async fn test_real_server() {
    use tokio::net::TcpListener;
    
    // Bind to port 0 to get a random available port
    let listener = TcpListener::bind("0.0.0.0:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    // Spawn the server in the background
    tokio::spawn(async move {
        axum::serve(listener, app()).await;
    });

    // Give the server a moment to start
    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;

    // Make a real HTTP request
    let client = hyper_util::client::legacy::Client::builder(
        hyper_util::rt::TokioExecutor::new()
    ).build_http();

    let response = client
        .request(
            Request::get(format!("http://{addr}"))
                .header("Host", "localhost")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    let body = response.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"Hello, World!");
}

Testing middleware

Test that middleware is properly applied:
use tower_http::trace::TraceLayer;

#[tokio::test]
async fn test_with_middleware() {
    let app = Router::new()
        .route("/", get(|| async { "OK" }))
        .layer(TraceLayer::new_for_http());

    let response = app
        .oneshot(Request::get("/").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
}

Helper utilities

Create test helpers to reduce boilerplate:
mod test_helpers {
    use super::*;
    use http_body_util::BodyExt;

    pub async fn get_body_string(response: Response) -> String {
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        String::from_utf8(bytes.to_vec()).unwrap()
    }

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

#[tokio::test]
async fn test_with_helpers() {
    let app = app();
    
    let response = app
        .oneshot(Request::get("/").body(Body::empty()).unwrap())
        .await
        .unwrap();
    
    let body = test_helpers::get_body_string(response).await;
    assert_eq!(body, "Hello, World!");
}

Complete test example

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

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct User {
    id: u64,
    name: String,
}

async fn list_users() -> Json<Vec<User>> {
    Json(vec![
        User { id: 1, name: "Alice".to_string() },
        User { id: 2, name: "Bob".to_string() },
    ])
}

async fn get_user(Path(id): Path<u64>) -> (StatusCode, Json<User>) {
    (StatusCode::OK, Json(User { id, name: "Alice".to_string() }))
}

fn app() -> Router {
    Router::new()
        .route("/users", get(list_users))
        .route("/users/:id", get(get_user))
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::Request,
    };
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    #[tokio::test]
    async fn test_list_users() {
        let app = app();
        
        let response = app
            .oneshot(Request::get("/users").body(Body::empty()).unwrap())
            .await
            .unwrap();
        
        assert_eq!(response.status(), StatusCode::OK);
        
        let body = response.into_body().collect().await.unwrap().to_bytes();
        let users: Vec<User> = serde_json::from_slice(&body).unwrap();
        
        assert_eq!(users.len(), 2);
        assert_eq!(users[0].name, "Alice");
    }

    #[tokio::test]
    async fn test_get_user() {
        let app = app();
        
        let response = app
            .oneshot(Request::get("/users/123").body(Body::empty()).unwrap())
            .await
            .unwrap();
        
        assert_eq!(response.status(), StatusCode::OK);
        
        let body = response.into_body().collect().await.unwrap().to_bytes();
        let user: User = serde_json::from_slice(&body).unwrap();
        
        assert_eq!(user.id, 123);
    }
}
Remember to add test dependencies in your Cargo.toml: tower = { version = "0.4", features = ["util"] } and http-body-util = "0.1".
Use cargo test to run all tests, or cargo test test_name to run a specific test.