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 it 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. Your whole app is one of those.
💡 If the router is a service, then 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 that lets you do this is oneshot, an extension method from tower::ServiceExt. It takes ownership of the service, drives one request through it, and gives you the response. You enable it with one dependency:
The util feature is what brings in ServiceExt (and therefore .oneshot). With that in place, a test for GET /books looks like this:
use ServiceExt; // brings .oneshot into scope
use ;
async
What just happened: #[tokio::test] gives the test an async runtime (the same way #[tokio::main] does for main). We built the same router the real app uses by calling app(), then oneshot drove a single hand-built GET /books request through it. Request::builder()...body(Body::empty()) is a request with no body — exactly what a GET needs. The first .unwrap() unwraps building the request; the second unwraps the Result oneshot returns. Afterward res is a real Response, and res.status() is whatever your handler set. This runs in well under a millisecond and never opens a socket. Note that oneshot consumes app — that's fine here because each test builds its own fresh router.
Testing a POST with a JSON body is the same shape with two additions: you attach the body and set the Content-Type header so axum's Json extractor knows to parse it.
async
What just happened: we set the method to POST, the URI to /books, and — this part matters — the content-type header to application/json. Without that header, the Json<T> extractor rejects the request before your handler ever runs, and you'd be testing the wrong path (a 415 Unsupported Media Type instead of your create logic). Body::from(body) turns the JSON string into a request body. We assert 201 Created, the status your create handler returns. To test inputs you expect to fail — a missing field, malformed JSON — send the bad payload and assert the 400/422 and error shape your IntoResponse error type 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," which is fine in a test where you control the input; in request-handling code you'd pass a real ceiling so a giant body can't exhaust memory. Then serde_json::from_slice deserializes those bytes into your Book struct, and you assert on real fields. That's a full round trip: request in, typed value out, no network in sight.
This is the heart of testing an axum app. The rest — table-driven cases, fixtures, running it all on every push — is general Rust testing, covered in testing in CI.
Structure: one fn app() -> Router that main and tests both build
You may have noticed every test above started with let app = app();. That app() function is the structural move that makes all of this clean: factor your router construction into one function that returns 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 "should match production" but quietly drifts out of sync — there's one source of truth. The moment you find yourself copy-pasting route setup into a test, stop and pull out an app().
📝 If your handlers need shared state — a database pool, config (Phase 4) — make it
fn app(state: AppState) -> Routerthat ends with.with_state(state). Thenmainbuilds the real pool and tests pass a fake or an in-memory one. The signature change is small; the testability win is large.
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. Requests in progress are severed, 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 — you 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 that completes on Ctrl-C (SIGINT, what you press locally), and one that completes on SIGTERM (what orchestrators like Kubernetes send on a restart). The #[cfg(unix)] / #[cfg(not(unix))] pair handles the fact that SIGTERM doesn't exist on Windows — there, terminate is a future that never resolves (pending), so only Ctrl-C triggers shutdown. tokio::select! waits for whichever fires first and then returns, which resolves the future and starts the drain. You've traded zero lines for a clean exit, which is exactly the trade a production service needs.
Deploy shape: release binary, tiny container, env config, real logs
Rust's shipping superpower is the same as Go's: you compile to a single native binary with no interpreter, no node_modules, no virtualenv to install. Build the optimized version:
What just happened: --release turns on optimizations (and turns off debug assertions), producing a binary in target/release/ that's dramatically faster than the default debug build. It compiles slower — that's the trade — so you 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 itself, which means nothing outside can connect. The same env-var 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 — basically nothing but the C runtime your binary dynamically links against, with no shell and no package manager — and we copy just the one executable into it. (We use cc-debian12 rather than static because a default cargo build links libc dynamically; distroless/cc provides exactly that and nothing more.) 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 you added back in Phase 5 dutifully 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 (a load balancer, an ingress). The proxy terminates TLS (HTTPS), can serve static assets, and load-balances across multiple 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 — let the proxy do it.
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."
}
]
← Phase 7: Error Handling · Guide overview · Phase 9: Where to Go Next →
Check your understanding
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?