Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9, tower 0.5.3, http-body-util 0.1.5, axum-test 21.1.0, sqlx 0.9.0 and insta 1.49.0 on Rust 1.99 (edition 2024), checked on 2026-10-06. Because a Router is a tower Service, an axum application can be tested without a network: build the router, send it a Request, inspect the Response. That makes HTTP-level tests nearly as fast as unit tests, and they exercise the real routing, extractors, middleware and error handling. The general Rust tooling (cargo test, test organisation, doctests, criterion) is covered in the Rust Reference’s Testing & Benchmarking.

Where it runs in the request

In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and extractors run, the handler runs, the response travels back out), tests enter the lifecycle at the tower step: oneshot and axum-test call the Router as a Service directly, skipping tokio’s listener and hyper. Routing, middleware, extractors, the handler and IntoResponse all run exactly as in production; only the TCP connection and HTTP parsing are replaced. Tests that need those (TLS, keep-alive, graceful shutdown) start a real server on an ephemeral port instead.

Make the application testable

Build the whole Router in one function of the library crate and give it its dependencies as arguments. main calls it with real ones; tests call it with fakes. Dependencies that tests need to replace are reached through a trait object in the state:

use std::{net::SocketAddr, sync::Arc};

use axum::{
    Json, Router,
    extract::{
        ConnectInfo, Path, State,
        ws::{Message, WebSocket, WebSocketUpgrade},
    },
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post},
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use utoipa::{OpenApi, ToSchema};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct Book {
    pub isbn: String,
    pub title: String,
    pub year: i16,
}

/// The dependency handlers use; tests substitute a fake.
#[async_trait::async_trait]
pub trait BookRepo: Send + Sync {
    async fn find(&self, isbn: &str) -> sqlx::Result<Option<Book>>;
}

pub struct PgBookRepo(pub PgPool);

#[async_trait::async_trait]
impl BookRepo for PgBookRepo {
    async fn find(&self, isbn: &str) -> sqlx::Result<Option<Book>> {
        sqlx::query_as!(Book, "SELECT isbn, title, year FROM books WHERE isbn = $1", isbn).fetch_optional(&self.0).await
    }
}

#[derive(Clone)]
pub struct AppState {
    pub books: Arc<dyn BookRepo>,
}

#[utoipa::path(get, path = "/books/{isbn}", responses((status = 200, body = Book), (status = 404)))]
async fn get_book(State(state): State<AppState>, Path(isbn): Path<String>) -> Response {
    match state.books.find(&isbn).await {
        Ok(Some(book)) => Json(book).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

async fn whoami(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> String {
    format!("you are {}", addr.ip())
}

#[derive(Deserialize)]
struct Login {
    user: String,
}

async fn login(jar: CookieJar, Json(login): Json<Login>) -> (CookieJar, StatusCode) {
    (jar.add(Cookie::build(("user", login.user)).path("/").http_only(true)), StatusCode::NO_CONTENT)
}

async fn me(jar: CookieJar) -> Result<String, StatusCode> {
    jar.get("user").map(|c| c.value().to_owned()).ok_or(StatusCode::UNAUTHORIZED)
}

async fn shout(ws: WebSocketUpgrade) -> Response {
    ws.on_upgrade(|mut socket: WebSocket| async move {
        while let Some(Ok(Message::Text(text))) = socket.recv().await {
            if socket.send(Message::Text(text.to_uppercase().into())).await.is_err() {
                break;
            }
        }
    })
}

#[derive(OpenApi)]
#[openapi(paths(get_book), components(schemas(Book)))]
pub struct ApiDoc;

/// Builds the whole application; `main` and every test call this.
pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/books/{isbn}", get(get_book))
        .route("/whoami", get(whoami))
        .route("/login", post(login))
        .route("/me", get(me))
        .route("/ws/shout", get(shout))
        .with_state(state)
}
  • Arc<dyn BookRepo> keeps the state cheap to clone and lets a test substitute any implementation. A trait with async fn is not dyn-compatible on its own, so the trait uses #[async_trait] (which boxes the futures); returning Pin<Box<dyn Future>> by hand does the same. This is unrelated to extractors, which since axum 0.8 must not use #[async_trait] (see Custom Extractors).

  • A generic AppState<R: BookRepo> avoids the boxing at the cost of a type parameter on every handler; for a repository that does I/O the boxing is negligible.

  • Put app() in src/lib.rs with a thin src/main.rs: integration tests in tests/ can only use the library crate (see Project Structure).

In-process tests with oneshot

tower::ServiceExt::oneshot drives one request through the router; http_body_util::BodyExt::collect reads the response body:

use std::{net::SocketAddr, sync::Arc};

use axum::{
    body::Body,
    extract::connect_info::MockConnectInfo,
    http::{Request, StatusCode},
};
use bookshelf::{AppState, Book, BookRepo, app};
use http_body_util::BodyExt; // collect()
use tower::ServiceExt; // oneshot()

/// An in-memory fake: no database, deterministic data.
struct FakeRepo(Vec<Book>);

#[async_trait::async_trait]
impl BookRepo for FakeRepo {
    async fn find(&self, isbn: &str) -> sqlx::Result<Option<Book>> {
        Ok(self.0.iter().find(|b| b.isbn == isbn).cloned())
    }
}

fn test_app() -> axum::Router {
    let books = vec![Book { isbn: "9781718503106".into(), title: "The Rust Programming Language".into(), year: 2023 }];
    app(AppState { books: Arc::new(FakeRepo(books)) })
}

#[tokio::test]
async fn returns_a_book_as_json() {
    let response = test_app()
        .oneshot(Request::get("/books/9781718503106").body(Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(response.headers()["content-type"], "application/json");
    let body = response.into_body().collect().await.unwrap().to_bytes();
    let book: Book = serde_json::from_slice(&body).unwrap();
    assert_eq!(book.title, "The Rust Programming Language");
}

#[tokio::test]
async fn unknown_book_is_404() {
    let response =
        test_app().oneshot(Request::get("/books/0000000000000").body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn connect_info_is_mocked() {
    // ConnectInfo is normally inserted by into_make_service_with_connect_info; oneshot has no connection
    let app = test_app().layer(MockConnectInfo(SocketAddr::from(([203, 0, 113, 7], 50000))));
    let response = app.oneshot(Request::get("/whoami").body(Body::empty()).unwrap()).await.unwrap();
    let body = response.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&body[..], b"you are 203.0.113.7");
}
$ cargo test --test router
running 3 tests
test connect_info_is_mocked ... ok
test unknown_book_is_404 ... ok
test returns_a_book_as_json ... ok
  • oneshot consumes the router; build a fresh one per request (it is cheap), or call ServiceExt::<Request<Body>>::ready(&mut app).await?.call(request) repeatedly on one router for multi-step tests.

  • ConnectInfo is inserted by into_make_service_with_connect_info for real connections. In-process requests have none, so a handler extracting it fails with 500; the MockConnectInfo layer supplies a fixed address.

  • The fake repository is plain Rust: a Vec and an iter().find. Hand-written fakes are usually clearer than mocks for repositories; mockall's #[automock] generates mocks with call expectations when the interaction is what the test checks (an email was sent exactly once).

The axum-test crate

axum-test wraps the same router in a TestServer with a request builder and assertion helpers, keeps cookies between requests like a browser, and can run on a real port for WebSockets:

use std::sync::Arc;

use axum_test::TestServer;
use bookshelf::{AppState, Book, BookRepo, app};
use serde_json::json;

struct FakeRepo;

#[async_trait::async_trait]
impl BookRepo for FakeRepo {
    async fn find(&self, isbn: &str) -> sqlx::Result<Option<Book>> {
        Ok((isbn == "9781718503106").then(|| Book { isbn: isbn.into(), title: "The Book".into(), year: 2023 }))
    }
}

fn server() -> TestServer {
    TestServer::builder()
        .save_cookies() // behave like a browser across requests
        .http_transport() // a real TCP port: needed for WebSockets
        .build(app(AppState { books: Arc::new(FakeRepo) }))
}

#[tokio::test]
async fn json_assertions() {
    let server = server();
    server.get("/books/9781718503106").await.assert_status_ok().assert_json_contains(&json!({ "year": 2023 }));
    server.get("/books/1").await.assert_status_not_found();
}

#[tokio::test]
async fn cookies_are_kept_between_requests() {
    let server = server();
    server.get("/me").await.assert_status_unauthorized();
    server.post("/login").json(&json!({ "user": "ada" })).await.assert_status(axum::http::StatusCode::NO_CONTENT);
    server.get("/me").await.assert_text("ada");
}

#[tokio::test]
async fn websocket_round_trip() {
    let server = server();
    let mut socket = server.get_websocket("/ws/shout").await.into_websocket().await;
    socket.send_text("borrow").await;
    socket.assert_receive_text("BORROW").await;
}
  • assert_json_contains checks a subset of the body, so a test does not break when a field is added.

  • save_cookies() carries Set-Cookie values into later requests — the login, then /me flow above. Sessions and CSRF tokens (see Sessions & Cookies) are tested the same way.

  • WebSocket tests need http_transport() and the ws feature: the upgrade needs a real connection. A plain tokio-tungstenite client against axum::serve on 127.0.0.1:0 works too, which is how the axum testing-websockets example does it (see WebSockets).

  • TestServer::new panics when the server cannot be built; try_new returns the error instead.

Database tests

#[sqlx::test] gives each test a fresh database: it creates one on the server named by DATABASE_URL, applies the crate’s ./migrations, loads the named fixtures from tests/fixtures/, passes a PgPool in, and drops the database when the test passes. Tests therefore run in parallel without seeing each other’s rows:

use bookshelf::{BookRepo, PgBookRepo};
use sqlx::PgPool;

// sqlx creates a fresh database per test, applies ./migrations, and drops it afterwards
#[sqlx::test(fixtures("books"))]
async fn finds_a_seeded_book(pool: PgPool) -> sqlx::Result<()> {
    let repo = PgBookRepo(pool);
    let book = repo.find("9781718503106").await?.expect("seeded by fixtures/books.sql");
    assert_eq!(book.year, 2023);
    assert!(repo.find("9780000000000").await?.is_none());
    Ok(())
}
-- tests/fixtures/books.sql
INSERT INTO authors (id, name) VALUES (1, 'Steve Klabnik');
INSERT INTO books (isbn, title, author_id, year) VALUES ('9781718503106', 'The Rust Programming Language', 1, 2023);
$ DATABASE_URL="postgres://bookshelf:$DB_PASSWORD@localhost/bookshelf" cargo test --test db
running 1 test
test finds_a_seeded_book ... ok
  • The database user needs permission to create databases. In CI, run Postgres as a service container (see CI/CD with GitHub Actions); locally, the development database from Databases with sqlx is enough. A failing test leaves its database behind for inspection.

  • testcontainers (testcontainers-modules with the postgres feature) starts a throwaway Postgres container from the test itself when no shared server is available; it needs a Docker daemon, and each container takes seconds to start, so share one per test binary.

  • Test the repository against the real database and the handlers against fakes. A handful of end-to-end tests then combine app() with a PgBookRepo on the #[sqlx::test] pool.

Snapshot and contract tests

insta stores the expected output in a file next to the test and shows a diff when it changes. It suits large JSON bodies, rendered templates and error pages, where hand-written assertions would be long:

use std::sync::Arc;

use axum_test::TestServer;
use bookshelf::{ApiDoc, AppState, Book, BookRepo, app};
use utoipa::OpenApi;

struct FakeRepo;

#[async_trait::async_trait]
impl BookRepo for FakeRepo {
    async fn find(&self, isbn: &str) -> sqlx::Result<Option<Book>> {
        Ok(Some(Book { isbn: isbn.into(), title: "The Book".into(), year: 2023 }))
    }
}

#[tokio::test]
async fn book_response_shape() {
    let server = TestServer::new(app(AppState { books: Arc::new(FakeRepo) }));
    let body: serde_json::Value = server.get("/books/9781718503106").await.json();
    // stored in tests/snapshots/*.snap and reviewed with `cargo insta review`
    insta::assert_json_snapshot!(body, { ".isbn" => "[isbn]" });
}

/// Contract test: any change to the published OpenAPI document shows up as a snapshot diff.
#[test]
fn openapi_contract_is_unchanged() {
    let spec = serde_json::to_value(ApiDoc::openapi()).unwrap();
    insta::assert_json_snapshot!(spec, { ".info.version" => "[version]" });
}

Source: insta and utoipa 6.0.0

# tests/snapshots/snapshots__book_response_shape.snap
---
source: testdemo/tests/snapshots.rs
expression: body
---
{
  "isbn": "[isbn]",
  "title": "The Book",
  "year": 2023
}
  • A new or changed snapshot fails the test and writes a .snap.new file; cargo insta review accepts or rejects each one, and the accepted .snap files are committed. In CI, CI=true makes insta fail rather than write files.

  • Redactions ({ ".isbn" => "[isbn]" }) replace values that change between runs — ids, timestamps, versions — so the snapshot records the shape.

  • Contract test: snapshotting the generated OpenAPI document (see OpenAPI with utoipa) turns every change to the public API into a reviewed diff, which catches an accidental rename or a removed field before clients do. For consumer-driven contracts, Pact verifies the provider against the consumers' published expectations.

Running the suite

  • cargo nextest run runs each test in its own process with better parallelism and output; #[sqlx::test] and axum-test work unchanged.

  • Keep load and soak tests out of cargo test: run oha or k6 against a release build instead, as described in Performance Tuning.

Where the books are outdated

Yoon (Beginning Axum, chapter 6) tests the router with oneshot, the approach on this page, which is unchanged in axum 0.8; the book does not cover database fixtures, snapshots or WebSocket tests. Material written for axum 0.6 reads bodies with hyper::body::to_bytes, removed in hyper 1.0; use http_body_util::BodyExt::collect (or axum::body::to_bytes with a size limit). Older axum-test examples write TestServer::new(app).unwrap(); in 21.x new returns the server directly.