Testing & Production
You've grown the books API from a single route into a real REST service - extractors, shared state, tower middleware, CRUD, an error type that turns into responses. Now comes the part that decides whether anyone trusts it: proving it works, and running it somewhere real without falling over at 3am during a deploy. Both turn out to be small, once you see the one fact that makes them small.
The mental model: your Router is a tower::Service, so testing is calling it in memory
Here's the fact that makes axum a joy to test. A Router is a tower::Service - the same abstraction every tower layer speaks. A service is, at heart, a thing you hand a Request and get back a Response, and your whole app is one of those.
💡 If the router is a service, a test is nothing more than handing it a request and awaiting the response - directly, in your own process. No network, no port, no
tokio::spawnrunning a server in the background. You build a fake request, the router runs the entire chain (middleware, routing, extractors, your handler), and you read the response back - all in memory, in microseconds.
The tool is oneshot, an extension method from tower::ServiceExt: it takes ownership of the service, drives one request through it, and gives you the response.
The util feature brings in ServiceExt (and .oneshot). A test for GET /books:
use ServiceExt; // brings .oneshot into scope
use ;
async
What just happened: #[tokio::test] gives the test an async runtime, like #[tokio::main] does for main. We built the same router the real app uses, then oneshot drove a hand-built GET /books request through it - body(Body::empty()) because a GET needs none. res comes back a real Response; res.status() is whatever your handler set. Runs in under a millisecond, no socket opened. oneshot consumes app, which is fine since each test builds its own.
Testing a POST with a JSON body is the same shape with two additions: attach the body and set the Content-Type header so axum's Json extractor knows to parse it.
async
What just happened: method POST, URI /books, and - this part matters - the content-type header set to application/json. Without it, Json<T> rejects the request before your handler runs, and you'd be testing the wrong path (415, not your create logic). Body::from(body) becomes the request body; we assert 201 Created. For inputs you expect to fail, send the bad payload and assert the 400/422 your AppError produces (Phase 7).
To check the body of a response, not the status, you pull the bytes out. axum's body is a stream, so you collect it with to_bytes:
use to_bytes;
let bytes = to_bytes.await.unwrap;
let book: Book = from_slice.unwrap;
assert_eq!;
What just happened: res.into_body() takes ownership of the response body, and to_bytes reads the whole stream into a byte buffer. The second argument is a size cap - usize::MAX means "no limit," fine in a test where you control the input, but you'd pass a real ceiling in request-handling code so a giant body can't exhaust memory. serde_json::from_slice deserializes those bytes into your Book struct, and you assert on real fields - a full round trip: request in, typed value out, no network in sight.
That's the heart of testing an axum app. The rest - table-driven cases, fixtures, running it on every push - is general Rust testing, covered in testing in CI.
Structure: one fn app() -> Router that main and tests both build
Every test above started with let app = app();. That's the structural move that makes this clean: factor router construction into one function returning the fully-wired Router. Both main and your tests call it, so they exercise the exact same routing, middleware, and state.
use ;
async
What just happened: all route registration and layering lives in one place. main binds a TCP listener and serves app(); a test builds the same app() and drives it with oneshot. There's no second, slightly-different set of routes that quietly drifts out of sync - one source of truth. The moment you find yourself copy-pasting route setup into a test, stop and pull out an app().
📝 Handlers needing shared state - a database pool, config (Phase 4) - make it
fn app(state: AppState) -> Routerending in.with_state(state).mainbuilds the real pool; tests pass a fake or in-memory one.
Graceful shutdown: drain in-flight requests instead of dropping them
axum::serve(listener, app()).await runs forever - until the process is killed. For learning that's perfect. For a real deploy it has a gap: when your platform restarts the service (a deploy, a scale-down, a SIGTERM), the process is cut off mid-flight and clients see broken connections.
What you want instead is a graceful shutdown: on a shutdown signal, stop accepting new connections, finish the requests already in progress, then exit. axum has this built in - hand serve a future that resolves when it's time to stop:
use signal;
async
async
What just happened: with_graceful_shutdown takes a future - shutdown_signal() - and watches it while serving. The moment that future resolves, axum stops accepting new connections and waits for in-flight requests to drain before serve returns. Inside shutdown_signal, we build two futures: one completing on Ctrl-C (SIGINT, what you press locally), one on SIGTERM (what orchestrators like Kubernetes send on a restart). The #[cfg(unix)] pair handles SIGTERM not existing on Windows - there, terminate never resolves, so only Ctrl-C triggers shutdown. tokio::select! waits for whichever fires first and starts the drain.
Deploy shape: release binary, tiny container, env config, real logs
Rust ships like Go: compile to a single native binary, no interpreter, no node_modules, no virtualenv. Build the optimized version:
What just happened: --release turns on optimizations (and off debug assertions), producing a binary in target/release/ dramatically faster than the debug build. It compiles slower - that's the trade - so keep using plain cargo run for development and only build --release for what you ship.
Read configuration - at minimum the port and your database URL - from the environment, not hard-coded constants. This is the 12-factor convention, and it's what platforms expect:
let port = var.unwrap_or_else;
let addr = format!;
let listener = bind.await.unwrap;
What just happened: we read PORT from the environment and fall back to 3000 for local dev, so the same binary runs unchanged whether it's on your laptop or a platform that injects PORT=10000. Bind to 0.0.0.0, not 127.0.0.1 - inside a container, 127.0.0.1 is only reachable from within the container, so nothing outside can connect. The same principle applies to DATABASE_URL and any secrets: configuration comes from the environment so the artifact stays identical across environments.
A minimal multi-stage Dockerfile compiles the binary in one stage and copies only it into a near-empty final image:
# Build stage - has the full Rust toolchain
# Run stage - distroless: no shell, no package manager, just enough to run a binary
What just happened: the first stage has the whole Rust toolchain and compiles the binary; the second is a distroless/cc image - just the C runtime your binary dynamically links against, no shell, no package manager - and we copy in only the one executable. (cc-debian12 rather than static because a default cargo build links libc dynamically.) The result is a container measured in tens of megabytes with a tiny attack surface - no shell means no shell for an attacker to drop into.
One easy-to-miss detail: your TraceLayer logs nothing until you install a subscriber. The tracing ecosystem separates emitting events (what TraceLayer does) from printing them (what a subscriber does). Add tracing-subscriber and initialize it once at startup:
What just happened: tracing_subscriber::fmt::init() installs a subscriber that formats events and writes them to stdout, honoring the RUST_LOG env var (e.g. RUST_LOG=info) for level filtering. Without this one line, the TraceLayer from Phase 5 emits per-request spans into the void and you see nothing. With it, requests show up in your logs - and in a container, stdout is exactly where your platform collects them.
Finally, put a reverse proxy in front - nginx, Caddy, or whatever your platform provides. It terminates TLS, can serve static assets, and load-balances across instances of your binary. Your axum app speaks plain HTTP on its port; the proxy faces the public internet - you generally don't terminate TLS in axum itself.
That's the whole deploy shape: one release binary, configured by env vars, in a small container, logging to stdout, behind a proxy. Taking it the rest of the way to a live URL - picking a host, wiring CI, the domain and TLS specifics - is covered in ship your side project.
Recap
- A
Routeris atower::Service, so you test it in memory with no network: addtowerwith theutilfeature, bringtower::ServiceExtinto scope, andapp.oneshot(request).awaitdrives one request through the entire chain. Assert onres.status(); read the body withaxum::body::to_bytes(res.into_body(), usize::MAX). - For a POST, set
method, thecontent-type: application/jsonheader (or theJsonextractor rejects it), and aBody::from(json). - Factor router construction into one
fn app() -> Router(orfn app(state) -> Router) that bothmainand tests build - one source of truth, no drift. - Add a graceful shutdown with
axum::serve(listener, app()).with_graceful_shutdown(shutdown_signal()), whereshutdown_signal()usestokio::signalto await Ctrl-C /SIGTERMandtokio::select!to fire on whichever comes first. - Ship a
cargo build --releasebinary in a small multi-stage container (builder →distroless/cc), readPORT/DATABASE_URLfrom the environment, calltracing_subscriber::fmt::init()soTraceLayeractually logs, and put a reverse proxy in front for TLS and load balancing.
Quick check
Lock in the core fact (the router is a service) and the two production must-haves:
[
{
"q": "Why can you test an axum router with oneshot and no real network?",
"choices": ["axum spins up a hidden test server on a random port", "A Router is a tower::Service, so a test hands it a Request and awaits the Response directly in-process", "oneshot mocks the TCP stack at the OS level", "You can't - axum tests always need a running server"],
"answer": 1,
"explain": "A Router is a tower::Service. ServiceExt::oneshot drives a single hand-built Request through the entire middleware-and-handler chain in memory and returns the Response - nothing touches a socket."
},
{
"q": "When testing a POST that uses the Json extractor, what must you set on the request besides the body?",
"choices": ["Nothing - Json parses any body", "The content-type: application/json header, or the Json extractor rejects the request before your handler runs", "A Content-Length header you compute by hand", "An Authorization header"],
"answer": 1,
"explain": "Without content-type: application/json, the Json<T> extractor rejects the request (415) before your handler executes, so you'd be testing the wrong path. Set the header so the body is parsed as JSON."
},
{
"q": "What does with_graceful_shutdown(shutdown_signal()) give you over a plain axum::serve(...).await?",
"choices": ["Faster request handling", "On Ctrl-C / SIGTERM it stops accepting new connections and drains in-flight requests before exiting, instead of being cut off mid-flight", "Automatic TLS termination", "It restarts the server on panic"],
"answer": 1,
"explain": "with_graceful_shutdown watches a future (built from tokio::signal for Ctrl-C and SIGTERM). When it resolves, axum stops accepting connections and lets in-progress requests finish, so a deploy or restart doesn't sever live requests."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why can you test an axum router with oneshot and no real network?
2. When testing a POST that uses the Json extractor, what must you set on the request besides the body?
3. What does with_graceful_shutdown(shutdown_signal()) give you over a plain axum::serve(...).await?