axum Integration Testing with tower::ServiceExt and TestClient
Integration testing in Rust web applications built with axum is remarkably ergonomic once you understand the tools available. The tower ecosystem that underpins axum gives you direct access to handlers without needing to spin up a real HTTP server, while the axum-test crate provides a higher-level TestClient for tests that read more like API consumers. This post covers both approaches in depth, with realistic patterns you can drop into production codebases.
Why Integration Tests Matter for axum
Unit tests cover individual functions in isolation. But web applications are wired together: a route handler pulls from application state, passes through authentication middleware, validates a JSON body, queries a database, and serializes a response. Integration tests exercise that entire stack without the overhead of a running server or the brittleness of end-to-end HTTP calls over a real network.
axum's design — built on tower::Service — makes this unusually clean. A Router is itself a Service, so you can call it directly in tests without any HTTP server binding.
Setting Up the Test Environment
Start with the right dev-dependencies in Cargo.toml:
[dev-dependencies]
axum-test = "14"
tower = { version = "0.4", features = ["util"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
http-body-util = "0.1"
hyper = { version = "1", features = ["full"] }Your production code likely already depends on axum, tower, serde, and tokio, so the dev-only additions are minimal.
For a realistic example, consider a small API with a few routes and an authentication layer:
// src/app.rs
use axum::{
extract::{Path, State},
http::StatusCode,
middleware,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
#[derive(Clone)]
pub struct AppState {
pub items: Arc<RwLock<Vec<Item>>>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Item {
pub id: u32,
pub name: String,
}
#[derive(Deserialize)]
pub struct CreateItem {
pub name: String,
}
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/:id", get(get_item))
.route("/protected", get(protected_handler))
.layer(middleware::from_fn(auth_middleware))
.with_state(state)
}
async fn list_items(State(state): State<AppState>) -> Json<Vec<Item>> {
let items = state.items.read().unwrap().clone();
Json(items)
}
async fn get_item(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Item>, StatusCode> {
let items = state.items.read().unwrap();
items
.iter()
.find(|i| i.id == id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn create_item(
State(state): State<AppState>,
Json(payload): Json<CreateItem>,
) -> (StatusCode, Json<Item>) {
let mut items = state.items.write().unwrap();
let id = items.len() as u32 + 1;
let item = Item { id, name: payload.name };
items.push(item.clone());
(StatusCode::CREATED, Json(item))
}
async fn protected_handler() -> &'static str {
"secret data"
}Using tower::ServiceExt with oneshot()
The most direct way to test axum handlers is via tower::ServiceExt::oneshot(). This sends a single Request to the service and returns the Response without any network I/O:
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use serde_json::json;
use http_body_util::BodyExt;
fn test_state() -> AppState {
AppState {
items: Arc::new(RwLock::new(vec![
Item { id: 1, name: "Widget".into() },
Item { id: 2, name: "Gadget".into() },
])),
}
}
#[tokio::test]
async fn test_list_items_returns_200() {
let app = build_router(test_state());
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/items")
.header("Authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let items: Vec<Item> = serde_json::from_slice(&body).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "Widget");
}
#[tokio::test]
async fn test_get_item_not_found() {
let app = build_router(test_state());
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/items/999")
.header("Authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}The key insight: oneshot() consumes the service, which is fine for a single test. The router is cheap to rebuild from your build_router() factory function, so just call it at the top of each test.
Testing JSON Request Bodies
POST endpoints that accept JSON bodies need a slightly different request construction:
#[tokio::test]
async fn test_create_item() {
let app = build_router(test_state());
let payload = json!({ "name": "New Item" });
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/items")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer test-token")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let created: Item = serde_json::from_slice(&body).unwrap();
assert_eq!(created.name, "New Item");
assert!(created.id > 0);
}
#[tokio::test]
async fn test_create_item_invalid_json_returns_422() {
let app = build_router(test_state());
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/items")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer test-token")
.body(Body::from("not json"))
.unwrap(),
)
.await
.unwrap();
// axum returns 422 Unprocessable Entity for JSON parse failures
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}Testing Authentication Middleware
Middleware is often the trickiest part to test because it sits between the test and the handler. With oneshot(), middleware executes exactly as it does in production, so you can test both the happy path and the rejection path:
// src/middleware.rs
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
pub async fn auth_middleware(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let auth_header = request
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok());
match auth_header {
Some(header) if header.starts_with("Bearer ") => {
let token = &header["Bearer ".len()..];
if validate_token(token) {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
_ => Err(StatusCode::UNAUTHORIZED),
}
}
fn validate_token(token: &str) -> bool {
// In production: verify JWT signature, expiry, etc.
!token.is_empty() && token != "invalid"
}#[tokio::test]
async fn test_protected_route_without_token_returns_401() {
let app = build_router(test_state());
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/protected")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_protected_route_with_invalid_token_returns_401() {
let app = build_router(test_state());
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/protected")
.header("Authorization", "Bearer invalid")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_protected_route_with_valid_token_returns_200() {
let app = build_router(test_state());
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/protected")
.header("Authorization", "Bearer valid-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}Using axum-test's TestClient
For tests that need to make multiple sequential requests — simulating a session, or testing pagination — axum-test's TestClient is more ergonomic than raw oneshot():
use axum_test::TestServer;
#[tokio::test]
async fn test_create_then_retrieve_item() {
let state = AppState {
items: Arc::new(RwLock::new(vec![])),
};
let app = build_router(state);
let server = TestServer::new(app).unwrap();
// Create an item
let create_response = server
.post("/items")
.add_header("Authorization", "Bearer token".parse().unwrap())
.json(&json!({ "name": "Test Widget" }))
.await;
create_response.assert_status(StatusCode::CREATED);
let created: Item = create_response.json();
let item_id = created.id;
// Retrieve it back
let get_response = server
.get(&format!("/items/{item_id}"))
.add_header("Authorization", "Bearer token".parse().unwrap())
.await;
get_response.assert_status_ok();
let retrieved: Item = get_response.json();
assert_eq!(retrieved.name, "Test Widget");
}
#[tokio::test]
async fn test_pagination_workflow() {
let state = AppState {
items: Arc::new(RwLock::new(vec![])),
};
let app = build_router(state);
let server = TestServer::new(app).unwrap();
// Create multiple items
for i in 1..=5 {
server
.post("/items")
.add_header("Authorization", "Bearer token".parse().unwrap())
.json(&json!({ "name": format!("Item {i}") }))
.await
.assert_status(StatusCode::CREATED);
}
// List should return all
let list_response = server
.get("/items")
.add_header("Authorization", "Bearer token".parse().unwrap())
.await;
list_response.assert_status_ok();
let items: Vec<Item> = list_response.json();
assert_eq!(items.len(), 5);
}Testing Error Response Bodies
Production APIs return structured error bodies, not just status codes. Test that the error shape matches your contract:
#[derive(Deserialize)]
struct ApiError {
message: String,
code: String,
}
#[tokio::test]
async fn test_not_found_returns_structured_error() {
let app = build_router(test_state());
let server = TestServer::new(app).unwrap();
let response = server
.get("/items/9999")
.add_header("Authorization", "Bearer token".parse().unwrap())
.await;
response.assert_status(StatusCode::NOT_FOUND);
// If your handler returns a JSON error body:
// let error: ApiError = response.json();
// assert_eq!(error.code, "NOT_FOUND");
}Organizing Test Modules
For larger codebases, organize integration tests in a tests/ directory rather than inline #[cfg(test)] modules. This keeps compilation faster and makes test discovery cleaner:
src/
app.rs
middleware.rs
tests/
integration/
mod.rs
items_api.rs
auth.rs// tests/integration/mod.rs
mod items_api;
mod auth;
pub fn build_test_state() -> AppState {
AppState {
items: Arc::new(RwLock::new(vec![
Item { id: 1, name: "Fixture Item".into() },
])),
}
}Each test file imports from your crate and from the shared helper module, keeping test setup DRY.
Performance Tips for Test Suites
When you have dozens of integration tests, compile time matters. A few practices help:
Use cargo-nextest instead of cargo test. It runs tests in separate processes and parallelizes across cores more effectively. Install it with cargo install cargo-nextest and run with cargo nextest run.
Keep your build_router() factory cheap — don't connect to databases or external services in the router constructor. Pass those in via AppState, and use test doubles in state for integration tests.
If tests share read-only state (like a catalog of fixtures), use std::sync::OnceLock or tokio::sync::OnceCell to initialize it once per test binary:
static TEST_ITEMS: OnceLock<Vec<Item>> = OnceLock::new();
fn shared_items() -> Vec<Item> {
TEST_ITEMS.get_or_init(|| {
vec![
Item { id: 1, name: "Widget".into() },
Item { id: 2, name: "Gadget".into() },
]
}).clone()
}Continuous Testing with HelpMeTest
Writing solid integration tests locally is only half the story. Running them continuously — on every pull request, on a schedule against staging, and with monitoring alerts when they regress — is where the real value comes from.
HelpMeTest lets you run your Rust integration test suites as part of a continuous testing pipeline without managing your own CI infrastructure. You get test history, flakiness detection, and alerts when a test that was passing starts failing — all without modifying your existing cargo test or cargo nextest setup.
For axum APIs in particular, pairing your tower-based integration tests with HelpMeTest's monitoring means you catch regressions in authentication middleware, route behavior, and JSON contract changes before they reach production.
The patterns in this post — oneshot() for isolated handler tests, TestClient for multi-request flows, structured error assertions — form a complete integration test strategy for any axum application. Start with the happy path, add the auth rejection cases, then cover your error responses, and you'll have a test suite that gives you genuine confidence in your API's behavior.