Testing Web Services
Test Axum handlers without binding ports using tower::ServiceExt::oneshot and in-memory state fixtures.
Recipe
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.
Working Example
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:
oneshotsends one request through the router service.with_stateinjects test fixtures without a real database.- POST JSON body built from raw string.
- Response body read with
axum::body::to_bytes.
Deep Dive
How It Works
RouterimplementsService<Request>;oneshotavoids TCP and port conflicts.- Each test builds a fresh
AppStatefor isolation. - For full HTTP stack tests, use
axum_testorreqwestagainstTcpListeneron port 0.
Test Pyramid for Web APIs
| Level | Tool | Scope |
|---|---|---|
| Unit | Plain functions | Domain logic |
| Handler | oneshot | Routing + extractors |
| E2E | reqwest + test DB | Full stack |
Rust Notes
// 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()
}Gotchas
- Reusing mutably borrowed app -
oneshotneeds&mut self. Fix: Create router per test or clone service handle. - Missing Content-Type - JSON extractors fail without header. Fix: Always set
application/jsonin POST tests. - State leakage between tests - Shared static state causes order-dependent failures. Fix: Fresh
AppStateper test. - Not awaiting body - Dropping response without reading body can mask errors. Fix:
to_bytesorcollectthe body. - Testing only happy path - No assertions on 4xx/5xx. Fix: Table-driven tests for error cases.
Alternatives
| 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 |
FAQs
Test middleware?
Layer middleware on router before oneshot; assert response headers.
Test auth?
Add Authorization header; use test JWT helper.
Database tests?
Use sqlx::test with ephemeral DB or testcontainers.
Snapshot testing?
insta crate for JSON response snapshots - review diffs in PRs.
Parallel tests?
Each test owns ports and DB if using TCP; oneshot tests parallelize freely.
WebSocket tests?
Upgrade handshake via reqwest or dedicated client crate.
CI setup?
cargo test with Tokio multi-thread runtime feature enabled.
Override time?
Inject Clock trait in AppState for deterministic expiry tests.
Property tests?
proptest for generating valid/invalid JSON bodies.
Load tests?
drill or oha - separate from handler unit tests.
Related
- Axum Routing & Handlers - Routes under test
- Error Handling in Handlers - Assert error JSON
- Extractors & State - State fixtures
- Request Validation - 422 test cases
- sqlx - Database test macros
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+.