Testing Web Services
Test Axum handlers without binding ports using tower::ServiceExt::oneshot and in-memory state fixtures.
Search across all documentation pages
Test Axum handlers without binding ports using tower::ServiceExt::oneshot and in-memory state fixtures.
Quick-reference recipe card - copy-paste ready.
use axum::{body::Body, http::{Request, StatusCode}, Router};
use tower::ServiceExt; // oneshot
#[tokio::test]
async fn health_returns_ok() {
let app = Router::new().route("/health", axum::routing::get(|| async { "ok" }));
let response = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}When to reach for this: Every HTTP handler needs fast integration tests without flaky network ports.
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use tower::ServiceExt;
#[derive(Clone, Default)]
struct AppState { counter: std::sync::Arc<tokio::sync::Mutex<u32>> }
#[derive(Serialize)]
struct Count { value: u32 }
#[derive(Deserialize)]
struct Add { n: u32 }
async fn get_count(State(s): State<AppState>) -> Json<Count> {
Json(Count { value: *s.counter.lock().await })
}
async fn add(State(s): State<AppState>, Json(body): Json<Add>) -> StatusCode {
*s.counter.lock().await += body.n;
StatusCode::NO_CONTENT
}
fn app(state: AppState) -> Router {
Router::new()
.route("/count", get(get_count).post(add))
.with_state(state)
}
#[tokio::test]
async fn increments_counter() {
let state = AppState::default();
let mut app = app(state);
let post = Request::builder()
.method("POST")
.uri("/count")
.header("content-type", "application/json")
.body(Body::from(r#"{"n":2}"#))
.unwrap();
let res = app.oneshot(post).await.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let get = Request::builder().uri("/count").body(Body::empty()).unwrap();
let res = app.oneshot(get).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
assert!(String::from_utf8_lossy(&bytes).contains(r#""value":2"#));
}What this demonstrates:
oneshot sends one request through the router service.with_state injects test fixtures without a real database.axum::body::to_bytes.Router implements Service<Request>; oneshot avoids TCP and port conflicts.AppState for isolation.axum_test or reqwest against TcpListener on port 0.| Level | Tool | Scope |
|---|---|---|
| Unit | Plain functions | Domain logic |
| Handler | oneshot | Routing + extractors |
| E2E | reqwest + test DB | Full stack |
// Helper to build JSON requests
fn json_request(method: &str, uri: &str, body: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}oneshot needs &mut self. Fix: Create router per test or clone service handle.application/json in POST tests.AppState per test.to_bytes or collect the body.| Alternative | Use When | Don't Use When |
|---|---|---|
axum-test crate | Higher-level request builders | Minimal deps preferred |
reqwest against real server | TLS or HTTP/2 edge cases | Fast unit-like handler tests |
mockall on traits | Isolating DB layer | Testing extractor wiring |
Layer middleware on router before oneshot; assert response headers.
Add Authorization header; use test JWT helper.
Use sqlx::test with ephemeral DB or testcontainers.
insta crate for JSON response snapshots - review diffs in PRs.
Each test owns ports and DB if using TCP; oneshot tests parallelize freely.
Upgrade handshake via reqwest or dedicated client crate.
cargo test with Tokio multi-thread runtime feature enabled.
Inject Clock trait in AppState for deterministic expiry tests.
proptest for generating valid/invalid JSON bodies.
drill or oha - separate from handler unit tests.
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.
Reviewed by Chris St. John·Last updated Jul 16, 2026