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)
}
Source: axum example: testing and async-trait
-
Arc<dyn BookRepo>keeps the state cheap to clone and lets a test substitute any implementation. A trait withasync fnis not dyn-compatible on its own, so the trait uses#[async_trait](which boxes the futures); returningPin<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()insrc/lib.rswith a thinsrc/main.rs: integration tests intests/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
-
oneshotconsumes the router; build a fresh one per request (it is cheap), or callServiceExt::<Request<Body>>::ready(&mut app).await?.call(request)repeatedly on one router for multi-step tests. -
ConnectInfois inserted byinto_make_service_with_connect_infofor real connections. In-process requests have none, so a handler extracting it fails with500; theMockConnectInfolayer supplies a fixed address. -
The fake repository is plain Rust: a
Vecand aniter().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;
}
Source: axum-test 21.1.0
-
assert_json_containschecks a subset of the body, so a test does not break when a field is added. -
save_cookies()carriesSet-Cookievalues into later requests — the login, then/meflow above. Sessions and CSRF tokens (see Sessions & Cookies) are tested the same way. -
WebSocket tests need
http_transport()and thewsfeature: the upgrade needs a real connection. A plaintokio-tungsteniteclient againstaxum::serveon127.0.0.1:0works too, which is how the axumtesting-websocketsexample does it (see WebSockets). -
TestServer::newpanics when the server cannot be built;try_newreturns 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(())
}
Source: sqlx: the test attribute
-- 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);
Source: sqlx: the test attribute
$ DATABASE_URL="postgres://bookshelf:$DB_PASSWORD@localhost/bookshelf" cargo test --test db
running 1 test
test finds_a_seeded_book ... ok
Source: sqlx: the test attribute
-
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-moduleswith thepostgresfeature) 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 aPgBookRepoon 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
}
Source: insta documentation
-
A new or changed snapshot fails the test and writes a
.snap.newfile;cargo insta reviewaccepts or rejects each one, and the accepted.snapfiles are committed. In CI,CI=truemakes 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 runruns 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.