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();Cookie-based sessions
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.