axum-test: HTTP Handler Testing for Axum Without a Live Server

axum-test: HTTP Handler Testing for Axum Without a Live Server

axum-test is a test client for Axum that sends requests directly to your router in-process — no network, no port binding, no flakiness. It gives you a clean API for building requests, asserting on responses, and managing session state across test calls.

Key Takeaways

  • axum-test sends requests in-process — fast and port-free
  • TestServer wraps your Axum router and exposes get/post/put/delete methods
  • JSON request and response helpers reduce test boilerplate
  • Cookie jar is maintained automatically between requests
  • Custom headers and auth tokens slot in per-request or globally

Why a dedicated test client

Axum's tower::ServiceExt::oneshot works but requires manually building Request objects and parsing Response bodies. For anything beyond a single handler test, that's tedious.

axum-test wraps your router in a TestServer that speaks a higher-level language: server.get("/users").await, .json(&payload), .assert_status_ok(). You write tests that read like specs.

Setup

[dev-dependencies]
axum-test = "15"
tokio = { version = "1", features = ["full"] }
serde_json = "1"

Basic usage

use axum::{Router, routing::get, Json};
use axum_test::TestServer;
use serde_json::json;

async fn hello() -> Json<serde_json::Value> {
    Json(json!({ "message": "hello" }))
}

#[tokio::test]
async fn test_hello_endpoint() {
    let app = Router::new().route("/hello", get(hello));
    let server = TestServer::new(app).unwrap();

    let response = server.get("/hello").await;

    response.assert_status_ok();
    response.assert_json(&json!({ "message": "hello" }));
}

TestServer::new wraps your router. Every request method (get, post, put, patch, delete) returns a TestRequest builder that you configure and then .await to get a TestResponse.

POST with JSON body

use axum::{Router, routing::post, Json, http::StatusCode};
use serde::{Deserialize, Serialize};
use axum_test::TestServer;

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

#[derive(Serialize)]
struct UserCreated {
    id: u64,
    name: String,
}

async fn create_user(Json(payload): Json<CreateUser>) -> (StatusCode, Json<UserCreated>) {
    (StatusCode::CREATED, Json(UserCreated { id: 1, name: payload.name }))
}

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

    let response = server
        .post("/users")
        .json(&CreateUser {
            name: "Alice".into(),
            email: "alice@example.com".into(),
        })
        .await;

    response.assert_status(StatusCode::CREATED);
    
    let body: UserCreated = response.json();
    assert_eq!(body.name, "Alice");
}

.json() on the request sets both Content-Type: application/json and serializes the body. .json() on the response deserializes — no manual serde_json::from_str needed.

Testing authentication

For JWT or Bearer token auth, add the header per-request or set a global default:

#[tokio::test]
async fn test_authenticated_endpoint() {
    let app = build_app(); // your router with auth middleware
    let server = TestServer::new(app).unwrap();

    // Per-request auth header
    let response = server
        .get("/protected")
        .add_header("Authorization", "Bearer test-token-123")
        .await;

    response.assert_status_ok();
}

#[tokio::test]
async fn test_auth_required() {
    let app = build_app();
    let server = TestServer::new(app).unwrap();

    // No auth header
    let response = server.get("/protected").await;
    response.assert_status(StatusCode::UNAUTHORIZED);
}

For tests that all share the same auth state, configure the server with a default header:

use axum_test::TestServerConfig;

let config = TestServerConfig::builder()
    .default_content_type("application/json")
    .build();

let server = TestServer::new_with_config(app, config).unwrap();

axum-test maintains a cookie jar automatically. Log in once, make subsequent requests as the authenticated user:

#[tokio::test]
async fn test_session_flow() {
    let app = build_app_with_sessions();
    let server = TestServer::new(app).unwrap();

    // Log in — server sets a session cookie
    let login_response = server
        .post("/login")
        .json(&json!({ "username": "alice", "password": "secret" }))
        .await;
    login_response.assert_status_ok();

    // Subsequent requests automatically include the session cookie
    let profile_response = server.get("/profile").await;
    profile_response.assert_status_ok();
    profile_response.assert_json_contains(&json!({ "username": "alice" }));

    // Log out
    let logout_response = server.post("/logout").await;
    logout_response.assert_status_ok();

    // Now protected routes should reject us
    let after_logout = server.get("/profile").await;
    after_logout.assert_status(StatusCode::UNAUTHORIZED);
}

The cookie jar persists within a TestServer instance for the lifetime of the test. Create a fresh TestServer to start with a clean session.

Query parameters and path variables

#[tokio::test]
async fn test_search_with_query_params() {
    let app = build_app();
    let server = TestServer::new(app).unwrap();

    let response = server
        .get("/search")
        .add_query_param("q", "rust testing")
        .add_query_param("limit", "10")
        .await;

    response.assert_status_ok();
}

#[tokio::test]
async fn test_get_user_by_id() {
    let app = build_app();
    let server = TestServer::new(app).unwrap();

    let response = server.get("/users/42").await;
    response.assert_status_ok();
}

Multipart form uploads

use axum_test::multipart::MultipartForm;

#[tokio::test]
async fn test_file_upload() {
    let app = build_app();
    let server = TestServer::new(app).unwrap();

    let form = MultipartForm::new()
        .add_text("description", "My avatar")
        .add_file_with_type("file", b"fake image bytes", "avatar.png", "image/png");

    let response = server
        .post("/upload")
        .multipart(form)
        .await;

    response.assert_status_ok();
}

Response assertions

TestResponse provides a set of assertion methods that fail with clear messages:

response.assert_status_ok();                          // 200
response.assert_status_created();                     // 201
response.assert_status(StatusCode::ACCEPTED);         // any specific code
response.assert_status_not_found();                   // 404
response.assert_status_bad_request();                 // 400

response.assert_json(&expected_value);                // exact JSON match
response.assert_json_contains(&partial_value);        // subset match
response.assert_text("exact body text");

// Get typed body for further assertions
let user: User = response.json();
assert_eq!(user.email, "alice@example.com");

assert_json_contains is particularly useful — it checks that the response JSON contains the specified keys and values, allowing extra fields.

Database state in tests

For handlers that touch a database, spin up a test database and pass it via Axum's state:

#[tokio::test]
async fn test_creates_user_in_db() {
    let pool = setup_test_db().await; // your test DB setup
    
    let app = Router::new()
        .route("/users", post(create_user_handler))
        .with_state(pool.clone());

    let server = TestServer::new(app).unwrap();

    let response = server
        .post("/users")
        .json(&json!({ "name": "Bob", "email": "bob@example.com" }))
        .await;

    response.assert_status_created();

    // Verify in DB
    let user = sqlx::query!("SELECT * FROM users WHERE email = 'bob@example.com'")
        .fetch_one(&pool)
        .await
        .unwrap();

    assert_eq!(user.name, "Bob");
}

Test organization

Group related handler tests in a module that shares setup:

#[cfg(test)]
mod user_tests {
    use super::*;
    use axum_test::TestServer;

    fn test_server() -> TestServer {
        let app = Router::new()
            .route("/users", get(list_users).post(create_user))
            .route("/users/:id", get(get_user).delete(delete_user));
        TestServer::new(app).unwrap()
    }

    #[tokio::test]
    async fn list_returns_empty_array() {
        let server = test_server();
        let response = server.get("/users").await;
        response.assert_status_ok();
        response.assert_json(&json!([]));
    }

    #[tokio::test]
    async fn create_then_get() {
        let server = test_server();
        
        server.post("/users")
            .json(&json!({ "name": "Carol" }))
            .await
            .assert_status_created();

        let response = server.get("/users/1").await;
        response.assert_json_contains(&json!({ "name": "Carol" }));
    }
}

Summary

axum-test turns Axum handler tests from low-level Request/Response wrangling into readable, declarative specs. The in-process client eliminates network latency and port conflicts, cookie jars handle session state automatically, and the assertion API gives clear failure messages. For any Axum project with more than a handful of routes, it's the right foundation for handler-level tests.

Read more