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
Useoneshot 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!");
}
}
Build a request
Use
Request::builder() or convenience methods like Request::get() to create requests.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 useState:
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 likeConnectInfo 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, useready() 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.