Lesson 11 of 30
Testing Axum Services: Handlers, Databases, and Contracts
A Rust Axum Router is a Tower service, so a test can hand it a request value and read the response value without opening a socket. This lesson tests a small Storefront orders service at four levels: in-process handler tests, integration tests against PostgreSQL, contract tests, and a real server. A payment gateway is replaced with a fake.
Project setup
#[sqlx::test] connects with DATABASE_URL, creates a new database for each test, applies the files in migrations/, and passes the test a PgPool for that database. The database user needs permission to create databases.
docker run --name pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=storefront -p 5432:5432 -d postgres:16
export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/storefrontCargo.toml
# Cargo.toml
[package]
name = "storefront"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "migrate", "macros"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1"
reqwest = { version = "0.12", features = ["json"] }
tower = { version = "0.5", features = ["util"] }Example 1: Test a handler with oneshot
tower::ServiceExt::oneshot calls a service once and returns the response future. The test builds a Request, sends it to the router, and reads the body with http_body_util::BodyExt. No port is bound, so the test cannot fail because a port is busy.
// tests/oneshot_basics.rs
use axum::{
body::Body,
extract::Json,
http::{Request, StatusCode},
routing::{get, post},
Router,
};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tower::ServiceExt;
async fn double(Json(body): Json<Value>) -> Json<Value> {
let n = body["n"].as_i64().unwrap_or(0);
Json(json!({ "n": n * 2 }))
}
fn app() -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/double", post(double))
}
fn json_request(body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/double")
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap()
}
#[tokio::test]
async fn health_returns_ok() {
let request = Request::builder().uri("/health").body(Body::empty()).unwrap();
let response = app().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"ok");
}
#[tokio::test]
async fn double_returns_json() {
let response = app().oneshot(json_request(r#"{"n": 4}"#)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["n"], 8);
}
#[tokio::test]
async fn wrong_content_type_is_415() {
let request = Request::builder()
.method("POST")
.uri("/double")
.body(Body::from(r#"{"n": 4}"#))
.unwrap();
let response = app().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn malformed_json_is_400() {
let response = app().oneshot(json_request("{not json")).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}oneshottakes the router by value. Callapp()again, or clone the router, for a second request in one test.- Axum rejects a
Jsonrequest with a wrong content type (415), malformed JSON (400), or a body that does not match the type (422). Asserting these statuses makes a change to error behavior visible. app()builds the router in a function. Example 2 applies the same pattern with real dependencies.
Example 2: The service under test
The service builds its router from a state struct of dependencies: a database pool and a payment gateway behind a trait. main passes real values and the tests pass test values. The trait exists at the one seam that reaches a remote system. The database is not hidden behind a trait, because tests run against a real one.
An order is inserted as pending, charged, and then set to paid or cancelled. The caller identity comes from an x-customer-id header here. Real authentication is in Authentication and authorization.
migrations/0001_init.sql
-- migrations/0001_init.sql
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents > 0)
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
product_id BIGINT NOT NULL REFERENCES products (id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
total_cents BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);src/error.rs
// src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub enum ApiError {
Unauthorized,
NotFound,
Invalid(&'static str),
PaymentDeclined,
Internal,
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
eprintln!("database error: {err}");
ApiError::Internal
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"),
ApiError::Invalid(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
ApiError::PaymentDeclined => (StatusCode::PAYMENT_REQUIRED, "payment declined"),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/payments.rs
// src/payments.rs
use async_trait::async_trait;
#[derive(Debug)]
pub enum PaymentError {
Declined,
}
/// The seam between the service and a payment provider.
#[async_trait]
pub trait PaymentGateway: Send + Sync {
async fn charge(&self, order_id: i64, cents: i64) -> Result<String, PaymentError>;
}
/// Stand-in for a real provider client, used by `main`.
pub struct AlwaysApprove;
#[async_trait]
impl PaymentGateway for AlwaysApprove {
async fn charge(&self, order_id: i64, _cents: i64) -> Result<String, PaymentError> {
Ok(format!("ch_{order_id}"))
}
}src/lib.rs
// src/lib.rs
pub mod error;
pub mod payments;
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use error::ApiError;
use payments::{PaymentError, PaymentGateway};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub payments: Arc<dyn PaymentGateway>,
}
/// Builds the router from its dependencies. `main` and the tests both call this.
pub fn app(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/orders", post(create_order))
.route("/orders/{id}", get(get_order))
.with_state(state)
}
#[derive(Deserialize)]
pub struct NewOrder {
product_id: i64,
quantity: i32,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct OrderDto {
id: i64,
customer_id: i64,
status: String,
total_cents: i64,
}
async fn health() -> &'static str {
"ok"
}
/// Stand-in for real authentication: the caller id comes from a header.
fn caller_id(headers: &HeaderMap) -> Result<i64, ApiError> {
headers
.get("x-customer-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.ok_or(ApiError::Unauthorized)
}
async fn set_status(pool: &PgPool, order_id: i64, status: &str) -> Result<(), ApiError> {
sqlx::query("UPDATE orders SET status = $2 WHERE id = $1")
.bind(order_id)
.bind(status)
.execute(pool)
.await?;
Ok(())
}
async fn create_order(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<NewOrder>,
) -> Result<(StatusCode, Json<OrderDto>), ApiError> {
let customer_id = caller_id(&headers)?;
if body.quantity <= 0 {
return Err(ApiError::Invalid("quantity must be positive"));
}
let price: Option<i32> = sqlx::query_scalar("SELECT price_cents FROM products WHERE id = $1")
.bind(body.product_id)
.fetch_optional(&state.pool)
.await?;
let price = price.ok_or(ApiError::Invalid("unknown product"))?;
let total_cents = i64::from(price) * i64::from(body.quantity);
let order = sqlx::query_as::<_, OrderDto>(
"INSERT INTO orders (customer_id, product_id, quantity, total_cents)
VALUES ($1, $2, $3, $4)
RETURNING id, customer_id, status, total_cents",
)
.bind(customer_id)
.bind(body.product_id)
.bind(body.quantity)
.bind(total_cents)
.fetch_one(&state.pool)
.await?;
match state.payments.charge(order.id, total_cents).await {
Ok(_) => {
set_status(&state.pool, order.id, "paid").await?;
let paid = OrderDto {
status: "paid".to_owned(),
..order
};
Ok((StatusCode::CREATED, Json(paid)))
}
Err(PaymentError::Declined) => {
set_status(&state.pool, order.id, "cancelled").await?;
Err(ApiError::PaymentDeclined)
}
}
}
async fn get_order(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Result<Json<OrderDto>, ApiError> {
let customer_id = caller_id(&headers)?;
let order = sqlx::query_as::<_, OrderDto>(
"SELECT id, customer_id, status, total_cents
FROM orders
WHERE id = $1 AND customer_id = $2",
)
.bind(id)
.bind(customer_id)
.fetch_optional(&state.pool)
.await?;
order.map(Json).ok_or(ApiError::NotFound)
}src/main.rs
// src/main.rs
use std::sync::Arc;
use sqlx::postgres::PgPoolOptions;
use storefront::{app, payments::AlwaysApprove, AppState};
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.expect("database connection");
sqlx::migrate!().run(&pool).await.expect("migrations");
let state = AppState {
pool,
payments: Arc::new(AlwaysApprove),
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(state)).await.unwrap();
}Example 3: Integration tests and a fake gateway
A fake is a small working replacement that behaves like the real dependency in a simple way. It differs from a mock, which records calls and checks them against expectations. FakeGateway records charges and can decline once, which creates the failure that is hard to trigger in a real provider. The tests assert outcomes: the stored status, the response, and the recorded charges.
Files in tests/ compile as separate crates. Each declares mod common; to share helpers. The common module is shared by all later examples.
tests/common/mod.rs
// tests/common/mod.rs
#![allow(dead_code)]
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
};
use async_trait::async_trait;
use axum::{
body::Body,
http::{Request, StatusCode},
Router,
};
use http_body_util::BodyExt;
use serde_json::Value;
use sqlx::PgPool;
use storefront::{
app,
payments::{PaymentError, PaymentGateway},
AppState,
};
use tower::ServiceExt;
/// A payment gateway that records charges and can be told to decline once.
#[derive(Default)]
pub struct FakeGateway {
charges: Mutex<Vec<(i64, i64)>>,
decline: AtomicBool,
}
impl FakeGateway {
pub fn decline_next(&self) {
self.decline.store(true, Ordering::SeqCst);
}
pub fn charges(&self) -> Vec<(i64, i64)> {
self.charges.lock().unwrap().clone()
}
}
#[async_trait]
impl PaymentGateway for FakeGateway {
async fn charge(&self, order_id: i64, cents: i64) -> Result<String, PaymentError> {
if self.decline.swap(false, Ordering::SeqCst) {
return Err(PaymentError::Declined);
}
self.charges.lock().unwrap().push((order_id, cents));
Ok(format!("ch_{order_id}"))
}
}
pub struct TestApp {
pub router: Router,
pub pool: PgPool,
pub gateway: Arc<FakeGateway>,
pub product_id: i64,
}
impl TestApp {
/// Builds the router over a fresh database and inserts one product (250 cents).
pub async fn new(pool: PgPool) -> Self {
let product_id: i64 = sqlx::query_scalar(
"INSERT INTO products (sku, name, price_cents) VALUES ('PEN-1', 'Pen', 250) RETURNING id",
)
.fetch_one(&pool)
.await
.unwrap();
let gateway = Arc::new(FakeGateway::default());
let state = AppState {
pool: pool.clone(),
payments: gateway.clone(),
};
TestApp {
router: app(state),
pool,
gateway,
product_id,
}
}
/// Sends one request in process and returns the status and the JSON body.
pub async fn send(&self, request: Request<Body>) -> (StatusCode, Value) {
let response = self.router.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, body)
}
pub async fn seed_order(&self, customer_id: i64) -> i64 {
sqlx::query_scalar(
"INSERT INTO orders (customer_id, product_id, quantity, total_cents, status)
VALUES ($1, $2, 1, 250, 'paid') RETURNING id",
)
.bind(customer_id)
.bind(self.product_id)
.fetch_one(&self.pool)
.await
.unwrap()
}
pub async fn only_order(&self) -> (i64, String) {
sqlx::query_as("SELECT id, status FROM orders")
.fetch_one(&self.pool)
.await
.unwrap()
}
pub async fn order_count(&self) -> i64 {
sqlx::query_scalar("SELECT count(*) FROM orders")
.fetch_one(&self.pool)
.await
.unwrap()
}
}
pub fn post_order(customer: Option<i64>, body: Value) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri("/orders")
.header("content-type", "application/json");
if let Some(id) = customer {
builder = builder.header("x-customer-id", id.to_string());
}
builder.body(Body::from(body.to_string())).unwrap()
}
pub fn get_order(customer: Option<i64>, order_id: i64) -> Request<Body> {
let mut builder = Request::builder().uri(format!("/orders/{order_id}"));
if let Some(id) = customer {
builder = builder.header("x-customer-id", id.to_string());
}
builder.body(Body::empty()).unwrap()
}tests/orders.rs
// tests/orders.rs
mod common;
use axum::http::StatusCode;
use common::{post_order, TestApp};
use serde_json::json;
use sqlx::PgPool;
#[sqlx::test]
async fn paid_order_is_stored_and_charged(pool: PgPool) {
let app = TestApp::new(pool).await;
let request = post_order(Some(7), json!({ "product_id": app.product_id, "quantity": 2 }));
let (status, body) = app.send(request).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(body["status"], "paid");
assert_eq!(body["total_cents"], 500);
let order_id = body["id"].as_i64().unwrap();
assert_eq!(app.only_order().await, (order_id, "paid".to_owned()));
assert_eq!(app.gateway.charges(), vec![(order_id, 500)]);
}
#[sqlx::test]
async fn declined_payment_cancels_the_order(pool: PgPool) {
let app = TestApp::new(pool).await;
app.gateway.decline_next();
let request = post_order(Some(7), json!({ "product_id": app.product_id, "quantity": 1 }));
let (status, body) = app.send(request).await;
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
assert_eq!(body["error"], "payment declined");
let (_, stored_status) = app.only_order().await;
assert_eq!(stored_status, "cancelled");
assert!(app.gateway.charges().is_empty());
}
#[sqlx::test]
async fn unknown_product_creates_nothing(pool: PgPool) {
let app = TestApp::new(pool).await;
let request = post_order(Some(7), json!({ "product_id": 999, "quantity": 1 }));
let (status, _) = app.send(request).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(app.order_count().await, 0);
}- Each test gets its own database with migrations applied, so tests run in parallel and do not depend on order.
- The tests drive HTTP and then query the database. This finds missing columns, violated constraints, and transactions that never commit.
- Use the PostgreSQL major version of production. SQLite is not a stand-in: types and locking differ.
Example 4: Authorization and error paths
Every route that needs a caller identity gets three tests: no credentials, bad credentials, and valid credentials for the wrong owner. The wrong-owner case finds authorization bugs. Returning 404 for another customer's order avoids confirming that the order exists.
tests/access.rs
// tests/access.rs
mod common;
use axum::http::StatusCode;
use common::{get_order, post_order, TestApp};
use serde_json::json;
use sqlx::PgPool;
#[sqlx::test]
async fn request_without_credentials_is_401(pool: PgPool) {
let app = TestApp::new(pool).await;
let order_id = app.seed_order(7).await;
let (status, _) = app.send(get_order(None, order_id)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let request = post_order(None, json!({ "product_id": app.product_id, "quantity": 1 }));
let (status, _) = app.send(request).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn customer_cannot_read_another_customers_order(pool: PgPool) {
let app = TestApp::new(pool).await;
let order_id = app.seed_order(7).await;
let (status, body) = app.send(get_order(Some(8), order_id)).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"], "not found");
let (status, body) = app.send(get_order(Some(7), order_id)).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], order_id);
}
#[sqlx::test]
async fn zero_quantity_is_rejected(pool: PgPool) {
let app = TestApp::new(pool).await;
let request = post_order(Some(7), json!({ "product_id": app.product_id, "quantity": 0 }));
let (status, body) = app.send(request).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body["error"], "quantity must be positive");
}Example 5: Contract tests
A contract is what an interface promises to its consumers: paths, methods, status codes, field names, types, and error shapes. A contract test fails when a change would break a consumer even if the service's own tests pass. The tests compare against serde_json::Value and not the handler's own struct. A renamed field then fails the test, because the test and the handler do not change together.
tests/contract.rs
// tests/contract.rs
mod common;
use axum::http::StatusCode;
use common::{get_order, TestApp};
use sqlx::PgPool;
#[sqlx::test]
async fn order_response_shape_is_stable(pool: PgPool) {
let app = TestApp::new(pool).await;
let order_id = app.seed_order(7).await;
let (status, body) = app.send(get_order(Some(7), order_id)).await;
assert_eq!(status, StatusCode::OK);
let mut keys: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort();
assert_eq!(keys, ["customer_id", "id", "status", "total_cents"]);
assert!(body["id"].is_i64());
assert!(body["customer_id"].is_i64());
assert!(body["status"].is_string());
assert!(body["total_cents"].is_i64());
}
#[sqlx::test]
async fn error_response_shape_is_stable(pool: PgPool) {
let app = TestApp::new(pool).await;
let (status, body) = app.send(get_order(Some(7), 12345)).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let keys: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
assert_eq!(keys, ["error"]);
assert!(body["error"].is_string());
}Other contract checks:
- A snapshot of the generated OpenAPI document shows any change to paths or schemas in a diff (REST API design).
- Consumer-driven contracts have each consumer publish the requests and responses it depends on, and the provider runs them in CI.
- A schema compatibility check for protobuf or event schemas fails the build when a field is removed or renumbered (gRPC basics).
Example 6: A real server on a free port
Some behavior exists only over a real connection: keep-alive, graceful shutdown, and TLS. Binding to port 0 lets the operating system choose a free port, and local_addr returns it. Each such test is slower and harder to debug than an in-process test, so the number stays small. Timeout tests use Tokio's paused clock (Testing async code).
tests/e2e.rs
// tests/e2e.rs
mod common;
use common::TestApp;
use sqlx::PgPool;
#[sqlx::test]
async fn real_server_answers_health(pool: PgPool) {
let app = TestApp::new(pool).await;
// Port 0 lets the operating system choose a free port.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = app.router.clone();
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let response = reqwest::get(format!("http://{addr}/health")).await.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(response.text().await.unwrap(), "ok");
}Complete example
File tree:
storefront/
Cargo.toml
migrations/0001_init.sql
src/
main.rs
lib.rs
error.rs
payments.rs
tests/
common/mod.rs
oneshot_basics.rs
orders.rs
access.rs
contract.rs
e2e.rsCargo.toml
# Cargo.toml
[package]
name = "storefront"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "migrate", "macros"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1"
reqwest = { version = "0.12", features = ["json"] }
tower = { version = "0.5", features = ["util"] }migrations/0001_init.sql
-- migrations/0001_init.sql
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents > 0)
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
product_id BIGINT NOT NULL REFERENCES products (id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
total_cents BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);src/main.rs
// src/main.rs
use std::sync::Arc;
use sqlx::postgres::PgPoolOptions;
use storefront::{app, payments::AlwaysApprove, AppState};
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.expect("database connection");
sqlx::migrate!().run(&pool).await.expect("migrations");
let state = AppState {
pool,
payments: Arc::new(AlwaysApprove),
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app(state)).await.unwrap();
}src/lib.rs
// src/lib.rs
pub mod error;
pub mod payments;
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use error::ApiError;
use payments::{PaymentError, PaymentGateway};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub payments: Arc<dyn PaymentGateway>,
}
/// Builds the router from its dependencies. `main` and the tests both call this.
pub fn app(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/orders", post(create_order))
.route("/orders/{id}", get(get_order))
.with_state(state)
}
#[derive(Deserialize)]
pub struct NewOrder {
product_id: i64,
quantity: i32,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct OrderDto {
id: i64,
customer_id: i64,
status: String,
total_cents: i64,
}
async fn health() -> &'static str {
"ok"
}
/// Stand-in for real authentication: the caller id comes from a header.
fn caller_id(headers: &HeaderMap) -> Result<i64, ApiError> {
headers
.get("x-customer-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.ok_or(ApiError::Unauthorized)
}
async fn set_status(pool: &PgPool, order_id: i64, status: &str) -> Result<(), ApiError> {
sqlx::query("UPDATE orders SET status = $2 WHERE id = $1")
.bind(order_id)
.bind(status)
.execute(pool)
.await?;
Ok(())
}
async fn create_order(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<NewOrder>,
) -> Result<(StatusCode, Json<OrderDto>), ApiError> {
let customer_id = caller_id(&headers)?;
if body.quantity <= 0 {
return Err(ApiError::Invalid("quantity must be positive"));
}
let price: Option<i32> = sqlx::query_scalar("SELECT price_cents FROM products WHERE id = $1")
.bind(body.product_id)
.fetch_optional(&state.pool)
.await?;
let price = price.ok_or(ApiError::Invalid("unknown product"))?;
let total_cents = i64::from(price) * i64::from(body.quantity);
let order = sqlx::query_as::<_, OrderDto>(
"INSERT INTO orders (customer_id, product_id, quantity, total_cents)
VALUES ($1, $2, $3, $4)
RETURNING id, customer_id, status, total_cents",
)
.bind(customer_id)
.bind(body.product_id)
.bind(body.quantity)
.bind(total_cents)
.fetch_one(&state.pool)
.await?;
match state.payments.charge(order.id, total_cents).await {
Ok(_) => {
set_status(&state.pool, order.id, "paid").await?;
let paid = OrderDto {
status: "paid".to_owned(),
..order
};
Ok((StatusCode::CREATED, Json(paid)))
}
Err(PaymentError::Declined) => {
set_status(&state.pool, order.id, "cancelled").await?;
Err(ApiError::PaymentDeclined)
}
}
}
async fn get_order(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Result<Json<OrderDto>, ApiError> {
let customer_id = caller_id(&headers)?;
let order = sqlx::query_as::<_, OrderDto>(
"SELECT id, customer_id, status, total_cents
FROM orders
WHERE id = $1 AND customer_id = $2",
)
.bind(id)
.bind(customer_id)
.fetch_optional(&state.pool)
.await?;
order.map(Json).ok_or(ApiError::NotFound)
}src/error.rs
// src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub enum ApiError {
Unauthorized,
NotFound,
Invalid(&'static str),
PaymentDeclined,
Internal,
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
eprintln!("database error: {err}");
ApiError::Internal
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"),
ApiError::Invalid(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
ApiError::PaymentDeclined => (StatusCode::PAYMENT_REQUIRED, "payment declined"),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/payments.rs
// src/payments.rs
use async_trait::async_trait;
#[derive(Debug)]
pub enum PaymentError {
Declined,
}
/// The seam between the service and a payment provider.
#[async_trait]
pub trait PaymentGateway: Send + Sync {
async fn charge(&self, order_id: i64, cents: i64) -> Result<String, PaymentError>;
}
/// Stand-in for a real provider client, used by `main`.
pub struct AlwaysApprove;
#[async_trait]
impl PaymentGateway for AlwaysApprove {
async fn charge(&self, order_id: i64, _cents: i64) -> Result<String, PaymentError> {
Ok(format!("ch_{order_id}"))
}
}tests/common/mod.rs
// tests/common/mod.rs
#![allow(dead_code)]
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
};
use async_trait::async_trait;
use axum::{
body::Body,
http::{Request, StatusCode},
Router,
};
use http_body_util::BodyExt;
use serde_json::Value;
use sqlx::PgPool;
use storefront::{
app,
payments::{PaymentError, PaymentGateway},
AppState,
};
use tower::ServiceExt;
/// A payment gateway that records charges and can be told to decline once.
#[derive(Default)]
pub struct FakeGateway {
charges: Mutex<Vec<(i64, i64)>>,
decline: AtomicBool,
}
impl FakeGateway {
pub fn decline_next(&self) {
self.decline.store(true, Ordering::SeqCst);
}
pub fn charges(&self) -> Vec<(i64, i64)> {
self.charges.lock().unwrap().clone()
}
}
#[async_trait]
impl PaymentGateway for FakeGateway {
async fn charge(&self, order_id: i64, cents: i64) -> Result<String, PaymentError> {
if self.decline.swap(false, Ordering::SeqCst) {
return Err(PaymentError::Declined);
}
self.charges.lock().unwrap().push((order_id, cents));
Ok(format!("ch_{order_id}"))
}
}
pub struct TestApp {
pub router: Router,
pub pool: PgPool,
pub gateway: Arc<FakeGateway>,
pub product_id: i64,
}
impl TestApp {
/// Builds the router over a fresh database and inserts one product (250 cents).
pub async fn new(pool: PgPool) -> Self {
let product_id: i64 = sqlx::query_scalar(
"INSERT INTO products (sku, name, price_cents) VALUES ('PEN-1', 'Pen', 250) RETURNING id",
)
.fetch_one(&pool)
.await
.unwrap();
let gateway = Arc::new(FakeGateway::default());
let state = AppState {
pool: pool.clone(),
payments: gateway.clone(),
};
TestApp {
router: app(state),
pool,
gateway,
product_id,
}
}
/// Sends one request in process and returns the status and the JSON body.
pub async fn send(&self, request: Request<Body>) -> (StatusCode, Value) {
let response = self.router.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, body)
}
pub async fn seed_order(&self, customer_id: i64) -> i64 {
sqlx::query_scalar(
"INSERT INTO orders (customer_id, product_id, quantity, total_cents, status)
VALUES ($1, $2, 1, 250, 'paid') RETURNING id",
)
.bind(customer_id)
.bind(self.product_id)
.fetch_one(&self.pool)
.await
.unwrap()
}
pub async fn only_order(&self) -> (i64, String) {
sqlx::query_as("SELECT id, status FROM orders")
.fetch_one(&self.pool)
.await
.unwrap()
}
pub async fn order_count(&self) -> i64 {
sqlx::query_scalar("SELECT count(*) FROM orders")
.fetch_one(&self.pool)
.await
.unwrap()
}
}
pub fn post_order(customer: Option<i64>, body: Value) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri("/orders")
.header("content-type", "application/json");
if let Some(id) = customer {
builder = builder.header("x-customer-id", id.to_string());
}
builder.body(Body::from(body.to_string())).unwrap()
}
pub fn get_order(customer: Option<i64>, order_id: i64) -> Request<Body> {
let mut builder = Request::builder().uri(format!("/orders/{order_id}"));
if let Some(id) = customer {
builder = builder.header("x-customer-id", id.to_string());
}
builder.body(Body::empty()).unwrap()
}tests/oneshot_basics.rs
// tests/oneshot_basics.rs
use axum::{
body::Body,
extract::Json,
http::{Request, StatusCode},
routing::{get, post},
Router,
};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use tower::ServiceExt;
async fn double(Json(body): Json<Value>) -> Json<Value> {
let n = body["n"].as_i64().unwrap_or(0);
Json(json!({ "n": n * 2 }))
}
fn app() -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/double", post(double))
}
fn json_request(body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/double")
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap()
}
#[tokio::test]
async fn health_returns_ok() {
let request = Request::builder().uri("/health").body(Body::empty()).unwrap();
let response = app().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"ok");
}
#[tokio::test]
async fn double_returns_json() {
let response = app().oneshot(json_request(r#"{"n": 4}"#)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["n"], 8);
}
#[tokio::test]
async fn wrong_content_type_is_415() {
let request = Request::builder()
.method("POST")
.uri("/double")
.body(Body::from(r#"{"n": 4}"#))
.unwrap();
let response = app().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn malformed_json_is_400() {
let response = app().oneshot(json_request("{not json")).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}tests/orders.rs
// tests/orders.rs
mod common;
use axum::http::StatusCode;
use common::{post_order, TestApp};
use serde_json::json;
use sqlx::PgPool;
#[sqlx::test]
async fn paid_order_is_stored_and_charged(pool: PgPool) {
let app = TestApp::new(pool).await;
let request = post_order(Some(7), json!({ "product_id": app.product_id, "quantity": 2 }));
let (status, body) = app.send(request).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(body["status"], "paid");
assert_eq!(body["total_cents"], 500);
let order_id = body["id"].as_i64().unwrap();
assert_eq!(app.only_order().await, (order_id, "paid".to_owned()));
assert_eq!(app.gateway.charges(), vec![(order_id, 500)]);
}
#[sqlx::test]
async fn declined_payment_cancels_the_order(pool: PgPool) {
let app = TestApp::new(pool).await;
app.gateway.decline_next();
let request = post_order(Some(7), json!({ "product_id": app.product_id, "quantity": 1 }));
let (status, body) = app.send(request).await;
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
assert_eq!(body["error"], "payment declined");
let (_, stored_status) = app.only_order().await;
assert_eq!(stored_status, "cancelled");
assert!(app.gateway.charges().is_empty());
}
#[sqlx::test]
async fn unknown_product_creates_nothing(pool: PgPool) {
let app = TestApp::new(pool).await;
let request = post_order(Some(7), json!({ "product_id": 999, "quantity": 1 }));
let (status, _) = app.send(request).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(app.order_count().await, 0);
}tests/access.rs
// tests/access.rs
mod common;
use axum::http::StatusCode;
use common::{get_order, post_order, TestApp};
use serde_json::json;
use sqlx::PgPool;
#[sqlx::test]
async fn request_without_credentials_is_401(pool: PgPool) {
let app = TestApp::new(pool).await;
let order_id = app.seed_order(7).await;
let (status, _) = app.send(get_order(None, order_id)).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let request = post_order(None, json!({ "product_id": app.product_id, "quantity": 1 }));
let (status, _) = app.send(request).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[sqlx::test]
async fn customer_cannot_read_another_customers_order(pool: PgPool) {
let app = TestApp::new(pool).await;
let order_id = app.seed_order(7).await;
let (status, body) = app.send(get_order(Some(8), order_id)).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"], "not found");
let (status, body) = app.send(get_order(Some(7), order_id)).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], order_id);
}
#[sqlx::test]
async fn zero_quantity_is_rejected(pool: PgPool) {
let app = TestApp::new(pool).await;
let request = post_order(Some(7), json!({ "product_id": app.product_id, "quantity": 0 }));
let (status, body) = app.send(request).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body["error"], "quantity must be positive");
}tests/contract.rs
// tests/contract.rs
mod common;
use axum::http::StatusCode;
use common::{get_order, TestApp};
use sqlx::PgPool;
#[sqlx::test]
async fn order_response_shape_is_stable(pool: PgPool) {
let app = TestApp::new(pool).await;
let order_id = app.seed_order(7).await;
let (status, body) = app.send(get_order(Some(7), order_id)).await;
assert_eq!(status, StatusCode::OK);
let mut keys: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort();
assert_eq!(keys, ["customer_id", "id", "status", "total_cents"]);
assert!(body["id"].is_i64());
assert!(body["customer_id"].is_i64());
assert!(body["status"].is_string());
assert!(body["total_cents"].is_i64());
}
#[sqlx::test]
async fn error_response_shape_is_stable(pool: PgPool) {
let app = TestApp::new(pool).await;
let (status, body) = app.send(get_order(Some(7), 12345)).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let keys: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
assert_eq!(keys, ["error"]);
assert!(body["error"].is_string());
}tests/e2e.rs
// tests/e2e.rs
mod common;
use common::TestApp;
use sqlx::PgPool;
#[sqlx::test]
async fn real_server_answers_health(pool: PgPool) {
let app = TestApp::new(pool).await;
// Port 0 lets the operating system choose a free port.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = app.router.clone();
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let response = reqwest::get(format!("http://{addr}/health")).await.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(response.text().await.unwrap(), "ok");
}Start PostgreSQL and run the tests:
docker run --name pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=storefront -p 5432:5432 -d postgres:16
export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/storefrontcargo testcargo test also prints result lines for the library and binary targets (0 tests each) and the doc-tests. The lines for the five test files are below, with the elapsed time omitted:
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (access)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (contract)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (e2e)
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (oneshot_basics)
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (orders)The service also runs on its own. Start it in one terminal. It applies the migration at startup.
cargo runIn a second terminal, insert a product and send requests:
docker exec pg psql -U postgres -d storefront \
-c "INSERT INTO products (sku, name, price_cents) VALUES ('PEN-1', 'Pen', 250)"
req() { curl -s -w ' [%{http_code}]\n' "$@"; }
J='content-type: application/json'
req localhost:3000/health
req -X POST localhost:3000/orders -H "$J" -H 'x-customer-id: 7' -d '{"product_id":1,"quantity":2}'
req localhost:3000/orders/1 -H 'x-customer-id: 7'
req localhost:3000/orders/1 -H 'x-customer-id: 8'
req localhost:3000/orders/1ok [200]
{"id":1,"customer_id":7,"status":"paid","total_cents":500} [201]
{"id":1,"customer_id":7,"status":"paid","total_cents":500} [200]
{"error":"not found"} [404]
{"error":"unauthorized"} [401]Summary
Routerimplementstower::Service, soServiceExt::oneshottests a handler without networking.- Read response bodies with
http_body_util::BodyExt::collect. - Build the router in a function that takes its dependencies. Put a trait only at a remote seam.
#[sqlx::test]creates a database per test, applies migrations, and passes aPgPool.- Run integration tests on the production database engine and major version.
- A fake replaces a remote dependency and exposes switches that cause failures.
- Test the three authorization cases: no credentials, bad credentials, and the wrong owner.
- Contract tests pin JSON shapes and compare against
serde_json::Value. - Bind to
127.0.0.1:0for real-server tests and read the address from the listener.
