Rust Web Server with Axum 0.8: Testing with Unit Tests and Router Tests
November 3, 2026
Episode 35 of the Rust Backend series, where we build the backend of a blog step by step. After episode 34 our API documents itself. But every episode we tested by hand with curl. Thirty-five episodes later, nobody can click through all of that after each change. Today: automatic tests. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 34. New today: tower as a dev-dependency. No server runs in this episode: everything happens in cargo test.
1. Two kinds of tests
- Unit tests check one small function.
- Router tests send real HTTP requests to our whole app, in memory, without a network.
2. Unit tests next to the code
Unit tests live next to the code, in a tests module. #[cfg(test)] means: only compiled for cargo test. At the end of models.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_from_title() {
assert_eq!(slugify("Hello, World"), "hello-world");
}
#[test]
fn slug_never_empty() {
assert!(!slugify("!!!").is_empty());
}
}The second test asks a question: what should a title of three exclamation marks give? At least not an empty slug.
cargo testtest models::tests::slug_from_title ... ok
test models::tests::slug_never_empty ... FAILEDThe slug is empty. A post titled "!!!" gets the slug "", and the next post titled "???" gets "" too: 409 Conflict! A real bug, found by asking one question.
3. The fix
If the slug is empty, we use post- and a short random part:
let slug = slug.trim_end_matches('-').to_string();
if slug.is_empty() {
return format!("post-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
}
slugcargo testBoth tests pass.
4. A test app
Now router tests. A new module, only for cargo test, in main.rs:
#[cfg(test)]
mod api_tests;and tower as a dev-dependency, for oneshot (send one request to a service):
cargo add --dev tower --features utilThe test app in src/api_tests.rs gets its database from the test, an in-memory file store (episode 25), and a Redis that does not exist: a lazy connection to port 1, with no retries. Our cache already survives a dead Redis (episode 28). Metrics get a recorder that is not installed globally:
use super::*;
fn test_app(pool: SqlitePool) -> Router {
let (jobs, _rx) = mpsc::channel(10);
let (live, _) = broadcast::channel(10);
let client = redis::Client::open("redis://127.0.0.1:1").unwrap();
let config = ConnectionManagerConfig::new().set_number_of_retries(0).set_connection_timeout(Some(Duration::from_millis(100)));
let cache = ConnectionManager::new_lazy_with_config(client, config).unwrap();
let handle = PrometheusBuilder::new().build_recorder().handle();
app(SqliteRepo { pool }, MemStorage::default(), jobs, cache, live, handle)
}use super::* gives the test module everything from main.rs, including app.
5. Helpers: call and login
call builds a request, sends it with oneshot, and reads the status and the JSON:
async fn call(app: &Router, method: Method, uri: &str, token: Option<&str>, body: Value) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(header::CONTENT_TYPE, "application/json");
if let Some(t) = token {
req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
}
let req = req.body(Body::from(body.to_string())).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status();
let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}login signs up and logs in a test user, and returns the token:
async fn login(app: &Router, email: &str) -> String {
let creds = json!({ "email": email, "password": "hunter2hunter2" });
call(app, Method::POST, "/signup", None, creds.clone()).await;
let (status, body) = call(app, Method::POST, "/login", None, creds).await;
assert_eq!(status, StatusCode::OK);
body["token"].as_str().unwrap().to_string()
}6. The first router test
Ana logs in, creates a post, and reads it back. #[sqlx::test] gives every test its own fresh database, with our migrations already run:
#[sqlx::test]
async fn create_and_get(pool: SqlitePool) {
let app = test_app(pool);
let token = login(&app, "ana@example.com").await;
let new = json!({ "title": "Hello", "body": "First post" });
let (status, created) = call(&app, Method::POST, "/posts", Some(&token), new).await;
assert_eq!(status, StatusCode::CREATED);
let (status, post) = call(&app, Method::GET, &format!("/posts/{}", created["id"]), None, Value::Null).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(post["title"], "Hello");
}cargo testIt fails: the assert_eq! in login says the login answered 500.
7. The test must bring a client address
The rate limiter from episode 23 needs the client's address ("Unable To Extract Key"), and a test has no network connection. So call adds one, the same ConnectInfo that into_make_service_with_connect_info would add:
use axum::extract::ConnectInfo;
let mut req = req.body(Body::from(body.to_string())).unwrap();
req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4000))));cargo testAll green.
8. Ownership and a dead database
The rule from episode 21: Ben cannot delete Ana's post, and Ana can:
#[sqlx::test]
async fn ben_cannot_delete_ana_post(pool: SqlitePool) {
let app = test_app(pool);
let ana = login(&app, "ana@example.com").await;
let ben = login(&app, "ben@example.com").await;
let new = json!({ "title": "Mine", "body": "Hands off" });
let (_, post) = call(&app, Method::POST, "/posts", Some(&ana), new).await;
let uri = format!("/posts/{}", post["id"]);
let (status, _) = call(&app, Method::DELETE, &uri, Some(&ben), Value::Null).await;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, _) = call(&app, Method::DELETE, &uri, Some(&ana), Value::Null).await;
assert_eq!(status, StatusCode::NO_CONTENT);
}And what does a user see when the database is down? We close the pool, and ask for a post: a 500 with "database error", and no details leaked:
#[sqlx::test]
async fn database_down_gives_500(pool: SqlitePool) {
let app = test_app(pool.clone());
pool.close().await;
let (status, body) = call(&app, Method::GET, "/posts/1", None, Value::Null).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["error"], "database error");
}cargo testtest result: ok.Each test has its own database, so they run in parallel. From now on, we run this before every change goes out.
The final code
src/api_tests.rs
use axum::body::{to_bytes, Body};
use axum::extract::ConnectInfo;
use axum::http::{header, Method, Request, StatusCode};
use serde_json::{json, Value};
use sqlx::SqlitePool;
use tower::ServiceExt;
use super::*;
fn test_app(pool: SqlitePool) -> Router {
let (jobs, _rx) = mpsc::channel(10);
let (live, _) = broadcast::channel(10);
let client = redis::Client::open("redis://127.0.0.1:1").unwrap();
let config = ConnectionManagerConfig::new().set_number_of_retries(0).set_connection_timeout(Some(Duration::from_millis(100)));
let cache = ConnectionManager::new_lazy_with_config(client, config).unwrap();
let handle = PrometheusBuilder::new().build_recorder().handle();
app(SqliteRepo { pool }, MemStorage::default(), jobs, cache, live, handle)
}
async fn call(app: &Router, method: Method, uri: &str, token: Option<&str>, body: Value) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(header::CONTENT_TYPE, "application/json");
if let Some(t) = token {
req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
}
let mut req = req.body(Body::from(body.to_string())).unwrap();
req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4000))));
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status();
let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn login(app: &Router, email: &str) -> String {
let creds = json!({ "email": email, "password": "hunter2hunter2" });
call(app, Method::POST, "/signup", None, creds.clone()).await;
let (status, body) = call(app, Method::POST, "/login", None, creds).await;
assert_eq!(status, StatusCode::OK);
body["token"].as_str().unwrap().to_string()
}
#[sqlx::test]
async fn create_and_get(pool: SqlitePool) {
let app = test_app(pool);
let token = login(&app, "ana@example.com").await;
let new = json!({ "title": "Hello", "body": "First post" });
let (status, created) = call(&app, Method::POST, "/posts", Some(&token), new).await;
assert_eq!(status, StatusCode::CREATED);
let (status, post) = call(&app, Method::GET, &format!("/posts/{}", created["id"]), None, Value::Null).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(post["title"], "Hello");
}
#[sqlx::test]
async fn ben_cannot_delete_ana_post(pool: SqlitePool) {
let app = test_app(pool);
let ana = login(&app, "ana@example.com").await;
let ben = login(&app, "ben@example.com").await;
let new = json!({ "title": "Mine", "body": "Hands off" });
let (_, post) = call(&app, Method::POST, "/posts", Some(&ana), new).await;
let uri = format!("/posts/{}", post["id"]);
let (status, _) = call(&app, Method::DELETE, &uri, Some(&ben), Value::Null).await;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, _) = call(&app, Method::DELETE, &uri, Some(&ana), Value::Null).await;
assert_eq!(status, StatusCode::NO_CONTENT);
}
#[sqlx::test]
async fn database_down_gives_500(pool: SqlitePool) {
let app = test_app(pool.clone());
pool.close().await;
let (status, body) = call(&app, Method::GET, "/posts/1", None, Value::Null).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["error"], "database error");
}src/main.rs
mod auth;
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;
mod storage;
mod mail;
mod jobs;
mod cache;
#[cfg(test)]
mod api_tests;
use axum::{routing::{get, post, put}, Extension, Router};
use repo::{Repo, SqliteRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use axum::extract::DefaultBodyLimit;
use axum::http::{header, HeaderName, HeaderValue, Method, StatusCode};
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::timeout::TimeoutLayer;
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use std::net::SocketAddr;
use storage::{LocalStorage, MemStorage, Storage};
use mail::Mailer;
use jobs::Job;
use tokio::sync::mpsc;
use tokio::time::Duration;
use redis::aio::{ConnectionManager, ConnectionManagerConfig};
use tokio::sync::broadcast;
use models::Comment;
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use axum::extract::{MatchedPath, Request};
use axum::middleware::{self, Next};
use axum::response::Response;
use utoipa::{Modify, OpenApi};
use utoipa_swagger_ui::SwaggerUi;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
pub fn app<R: Repo, S: Storage>(repo: R, store: S, jobs: mpsc::Sender<Job>, cache: ConnectionManager, live: broadcast::Sender<Comment>, handle: PrometheusHandle) -> Router {
let cors = CorsLayer::new()
.allow_origin(HeaderValue::from_static("http://localhost:5500"))
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true);
let governor = Arc::new(GovernorConfigBuilder::default().per_second(2).burst_size(5).finish().unwrap());
let auth_routes = Router::new()
.route("/signup", post(handlers::signup::<R>))
.route("/login", post(handlers::login::<R>))
.route("/auth/refresh", post(handlers::refresh::<R>))
.layer(GovernorLayer::new(governor));
let streaming = Router::new()
.route("/posts/{id}/live", get(handlers::live))
.route("/posts/{id}/events", get(handlers::events));
Router::new()
.nest("/api/v1", posts_v1())
.nest("/api/v2", posts_v2())
.merge(posts_v1())
.route("/posts/{id}/restore", post(handlers::restore_post::<R>))
.route("/me", get(handlers::me::<R>))
.route("/admin/users", get(handlers::list_users::<R>))
.route("/auth/logout", post(handlers::logout::<R>))
.route(
"/posts/{id}/images",
post(handlers::upload_image::<R, S>).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
)
.route("/files/{name}", get(handlers::get_file::<S>))
.route("/debug/slow", get(handlers::slow))
.route("/health/live", get(handlers::health_live))
.route("/health/ready", get(handlers::health_ready::<R>))
.route("/metrics", get(handlers::metrics_text))
.merge(auth_routes)
.layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10)))
.merge(streaming)
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
.route_layer(middleware::from_fn(track))
.with_state(repo)
.layer(Extension(Arc::new(store)))
.layer(Extension(jobs))
.layer(Extension(cache))
.layer(Extension(live))
.layer(Extension(handle))
.layer(cors)
.layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")))
.layer(SetResponseHeaderLayer::if_not_present(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")))
.layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")))
.layer(TraceLayer::new_for_http())
}
#[tokio::main]
async fn main() {
let cfg = config::config();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let addr = format!("{}:{}", cfg.host, cfg.port);
let options = SqliteConnectOptions::from_str(&cfg.database_url)
.unwrap()
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let repo = SqliteRepo { pool };
let (tx, rx) = mpsc::channel::<Job>(100);
let mailer = Mailer::new(cfg);
tokio::spawn(jobs::worker(rx, mailer));
tokio::spawn(jobs::cleanup(repo.clone(), Duration::from_secs(cfg.cleanup_secs)));
let client = redis::Client::open(cfg.redis_url.as_str()).unwrap();
let redis_config = ConnectionManagerConfig::new()
.set_number_of_retries(1)
.set_connection_timeout(Some(Duration::from_millis(300)))
.set_response_timeout(Some(Duration::from_millis(300)));
let cache = ConnectionManager::new_with_config(client, redis_config).await.unwrap();
let (live, _) = broadcast::channel::<Comment>(100);
let handle = PrometheusBuilder::new().set_buckets(&[0.005, 0.01, 0.05, 0.1, 0.5, 1.0]).unwrap().install_recorder().unwrap();
match cfg.storage.as_str() {
"memory" => serve(app(repo, MemStorage::default(), tx, cache, live, handle), &addr).await,
_ => serve(app(repo, LocalStorage::new("uploads"), tx, cache, live, handle), &addr).await,
}
}
async fn serve(app: Router, addr: &str) {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
tracing::info!(%addr, "listening");
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
fn posts_common<R: Repo>() -> Router<R> {
Router::new()
.route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
.route(
"/posts/{id}",
put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
)
.route("/posts/{id}/comments", get(handlers::list_comments::<R>).post(handlers::add_comment::<R>))
.route("/search", get(handlers::search_posts::<R>))
}
fn posts_v1<R: Repo>() -> Router<R> {
posts_common()
.route("/posts/{id}", get(handlers::get_post::<R>))
.layer(SetResponseHeaderLayer::overriding(HeaderName::from_static("deprecation"), HeaderValue::from_static("true")))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("sunset"),
HeaderValue::from_static("Wed, 30 Jun 2027 00:00:00 GMT"),
))
.layer(SetResponseHeaderLayer::overriding(header::LINK, HeaderValue::from_static(r#"</api/v2>; rel="successor-version""#)))
}
fn posts_v2<R: Repo>() -> Router<R> {
posts_common().route("/posts/{id}", get(handlers::get_post_v2::<R>))
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.expect("Ctrl+C handler");
};
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler")
.recv()
.await;
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutting down");
}
async fn track(req: Request, next: Next) -> Response {
let start = std::time::Instant::now();
let method = req.method().to_string();
let path = req
.extensions()
.get::<MatchedPath>()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| "unmatched".to_string());
let response = next.run(req).await;
let status = response.status().as_u16().to_string();
let labels = [("method", method), ("path", path), ("status", status)];
metrics::counter!("http_requests_total", &labels).increment(1);
metrics::histogram!("http_request_duration_seconds", &labels).record(start.elapsed().as_secs_f64());
response
}
#[derive(OpenApi)]
#[openapi(
paths(handlers::get_post_v2, handlers::create_post),
components(schemas(models::PostV2, models::NewPost, models::PostWithTags, models::Post, error::ErrorBody)),
modifiers(&SecurityAddon)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer",
SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()),
);
}
}src/models.rs
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema)]
pub struct Post {
pub id: i64,
pub title: String,
pub body: String,
pub slug: String,
pub author_id: Option<i64>,
pub created_at: String,
}
#[derive(Deserialize, Validate, ToSchema)]
pub struct NewPost {
#[validate(length(min = 1, max = 200, message = "1 to 200 characters"), custom(function = "not_blank"))]
pub title: String,
#[validate(length(min = 1, max = 20000, message = "must not be empty"))]
pub body: String,
#[serde(default)]
#[validate(length(max = 5, message = "at most 5 tags"))]
pub tags: Vec<String>,
}
#[derive(Serialize, Clone)]
pub struct Comment {
pub id: i64,
pub post_id: i64,
pub body: String,
pub created_at: String,
}
#[derive(Deserialize, Validate)]
pub struct NewComment {
#[validate(length(min = 1, max = 2000, message = "1 to 2000 characters"), custom(function = "not_blank"))]
pub body: String,
}
pub fn slugify(title: &str) -> String {
let mut slug = String::new();
for c in title.trim().to_lowercase().chars() {
if c.is_alphanumeric() {
slug.push(c);
} else if !slug.ends_with('-') {
slug.push('-');
}
}
let slug = slug.trim_end_matches('-').to_string();
if slug.is_empty() {
return format!("post-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
}
slug
}
#[derive(Serialize, ToSchema)]
pub struct PostWithTags {
#[serde(flatten)]
pub post: Post,
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct ListParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub tag: Option<String>,
#[serde(default)]
pub sort: Sort,
}
#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
#[default]
Newest,
Oldest,
Title,
}
#[derive(Serialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub page: u32,
pub per_page: u32,
pub total: i64,
}
pub struct ListFilter {
pub page: u32,
pub per_page: u32,
pub tag: Option<String>,
pub sort: Sort,
}
#[derive(Serialize)]
pub struct Hit {
pub id: i64,
pub title: String,
pub snippet: String,
}
#[derive(Deserialize)]
pub struct SearchParams {
pub q: String,
}
pub fn not_blank(value: &str) -> Result<(), ValidationError> {
if value.trim().is_empty() {
return Err(ValidationError::new("blank").with_message("must not be blank".into()));
}
Ok(())
}
#[derive(Deserialize, Validate)]
pub struct NewUser {
#[validate(email(message = "must be a valid email"))]
pub email: String,
#[validate(length(min = 10, max = 200, message = "10 to 200 characters"))]
pub password: String,
}
#[derive(Serialize, Clone)]
pub struct User {
pub id: i64,
pub email: String,
pub role: String,
pub created_at: String,
}
pub struct UserWithHash {
pub id: i64,
pub email: String,
pub password_hash: String,
pub role: String,
}
#[derive(Serialize)]
pub struct Image {
pub id: i64,
pub post_id: i64,
pub url: String,
}
#[derive(Serialize, ToSchema)]
pub struct PostV2 {
pub id: i64,
#[schema(example = "Hello")]
pub title: String,
/// The post's text in Markdown.
pub content: String,
pub slug: String,
pub author_id: Option<i64>,
pub created_at: String,
}
impl From<Post> for PostV2 {
fn from(p: Post) -> PostV2 {
PostV2 { id: p.id, title: p.title, content: p.body, slug: p.slug, author_id: p.author_id, created_at: p.created_at }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_from_title() {
assert_eq!(slugify("Hello, World"), "hello-world");
}
#[test]
fn slug_never_empty() {
assert!(!slugify("!!!").is_empty());
}
}Recap
- Unit tests sit in
#[cfg(test)] mod testsnext to the code; one good question found a real bug - Router tests send requests to the whole app with
tower::ServiceExt::oneshot, no network needed #[sqlx::test]gives each test a fresh database with migrations applied- Helpers (
call,login) keep tests short; fake what tests can't have (Redis, file storage) - Middleware that needs the client address needs
ConnectInfoinserted by the test
Next episode: Docker. One image that runs our server anywhere. Watch the series on YouTube.