11. Testing
📋 Jump to Takeaways🎁 Axum handlers are async functions. You can call them directly in tests, or spin up the full router with a test HTTP client. Both patterns have their place.
Unit Testing Handlers
For handlers that don't touch the database, test the function directly:
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
#[tokio::test]
async fn health_returns_ok() {
let response = health().await;
assert_eq!(response, "ok");
}
}#[tokio::test] is the async equivalent of #[test] — it starts a tokio runtime for the test.
Integration Testing with tower
For testing the full router, use tower::ServiceExt to send requests without a real TCP connection:
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
axum = "0.8"The tests reference two helpers you need to wire up in your project. Add them to src/lib.rs (or wherever your router is assembled):
// src/lib.rs
use axum::Router;
use crate::state::AppState;
pub fn app(state: AppState) -> Router {
Router::new()
.merge(crate::health::router(state.clone()))
.merge(crate::links::router(state.clone()))
}
#[cfg(test)]
pub async fn test_state() -> AppState {
let pool = test_pool().await;
AppState {
pool,
config: crate::config::Config {
database_url: std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:password@localhost/urlshortener_test".to_string()),
port: 3000,
log_level: "error".to_string(),
jwt_secret: "test-secret".to_string(),
},
}
}use axum::{body::Body, http::{Request, StatusCode}};
use tower::ServiceExt; // for .oneshot()
use crate::{app, test_state};
#[tokio::test]
async fn get_link_returns_404_when_not_found() {
let app = app(test_state().await);
let response = app
.oneshot(
Request::builder()
.uri("/links/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}.oneshot() sends one request through the router and returns the response. No server, no network, fast.
Test Database
Use a separate database for tests. Set DATABASE_URL in a .env.test file or use environment variables in CI.
A better approach: create a new database per test run and migrate it:
async fn test_pool() -> sqlx::PgPool {
let url = std::env::var("TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/urlshortener_test".to_string());
let pool = sqlx::PgPool::connect(&url).await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
pool
}For full isolation, use sqlx::test which creates a fresh database per test:
[dev-dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "testing"] }#[sqlx::test]
async fn create_link_stores_in_database(pool: sqlx::PgPool) {
let link = create(&pool, "https://example.com").await.unwrap();
assert_eq!(link.url, "https://example.com");
assert_eq!(link.clicks, 0);
assert!(!link.code.is_empty());
}#[sqlx::test] creates an isolated database, runs migrations, injects the pool, and cleans up afterward.
Testing JSON Responses
Read and parse the response body:
use axum::body::to_bytes;
use serde_json::Value;
#[tokio::test]
async fn create_link_returns_json() {
let app = app(test_state().await);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/links")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"url":"https://example.com"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["url"], "https://example.com");
assert!(json["code"].is_string());
}Organizing Tests
Put unit tests in the same file as the code under test:
// src/links.rs
#[cfg(test)]
mod tests {
use super::*;
// ...
}Put integration tests in tests/:
tests/
├── links_api.rs ← tests for the full /links endpoints
└── health_api.rs ← tests for /healthKey Takeaways
#[tokio::test]runs async test functions with a tokio runtime.oneshot()fromtower::ServiceExtsends a request through the router without a real server#[sqlx::test]creates an isolated database per test — ideal for database integration tests- Test the router, not the handler functions directly — it exercises middleware and extractors too
- Unit tests live next to source files; integration tests live in
tests/
🎁 Your API is open to anyone. Next up: adding JWT authentication so only authorized clients can create and delete links.