Day 11 · Tests & Docker
Day 10 finished the API’s behavior; this day proves it and ships it. We’ll write integration tests that build the actual app and drive every endpoint against an in-memory database — no network, no ports, no live DB — and then wrap the release binary in a small, non-root Docker image. This is the day that turns “it runs on my machine” into “it runs.”
Test the wiring, not just the functions
Section titled “Test the wiring, not just the functions”You could unit-test each handler function in isolation. But the bugs that bite an API live in the
wiring: a route registered on the wrong method, an extractor that fails to deserialize, a status
code that’s 200 when it should be 201, a serde field that serializes under the wrong name. None of
those show up if you call the handler directly — they only appear when a real request flows through
the real router.
That’s why apilite is split into a library crate (src/lib.rs) and a thin binary (src/main.rs).
The library exposes router(state), so the tests can build the exact same Router the server runs —
and exercise it in-process.
src/lib.rs → router(), AppState, all modules ← tests build against THIS src/main.rs → reads config, calls router(), serves tests/api.rs → integration tests (separate crate, uses apilite as a dependency)The in-process pattern: oneshot
Section titled “The in-process pattern: oneshot”The trick is tower’s ServiceExt::oneshot: a Router is a service (request in, response out), so
a test can hand it one request and inspect the response directly — skipping TCP entirely.
use apilite::{db, router, AppState};use axum::body::Body;use axum::http::{Request, StatusCode};use http_body_util::BodyExt; // .collect() to read the response bodyuse tower::ServiceExt; // .oneshot()
async fn test_app() -> axum::Router { let pool = db::create_pool("sqlite::memory:").await.unwrap(); db::init_schema(&pool).await.unwrap(); // fresh schema, every test router(AppState { pool })}
#[tokio::test]async fn create_then_get_note() { let app = test_app().await;
let req = Request::builder() .method("POST").uri("/notes") .header("content-type", "application/json") .body(Body::from(r#"{"title":"first","body":"hello"}"#)) .unwrap();
let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::CREATED);}Two details earn their keep. #[tokio::test] gives each test its own async runtime, so handlers can
.await. And app.clone() works because the Router holds an Arc-backed AppState — the clone
shares the same pool, so data written by one request is visible to the next within a test.
Under the hood — why each test is hermetic
Section titled “Under the hood — why each test is hermetic”Recall the in-memory trap from Day 9: each connection to
sqlite::memory: gets its own database. Here that quirk becomes a feature. Every call to
test_app() builds a brand-new single-connection pool over a brand-new in-memory database:
test A → pool A → its own :memory: db (notes: just A's) test B → pool B → its own :memory: db (notes: just B's) ↑ no shared state → no cross-test contaminationcargo test runs tests in parallel across threads by default, and this design is safe under that
precisely because nothing is shared. The same property — isolation by not sharing mutable state —
that the borrow checker enforces inside a process, you’re now enforcing across the test suite by
construction.
Packaging: a multi-stage Dockerfile
Section titled “Packaging: a multi-stage Dockerfile”A Rust build needs a whole compiler toolchain; a running Rust binary needs almost nothing. A multi-stage build exploits that gap: stage one compiles with the full toolchain, stage two copies only the binary into a tiny base. The fat builder image is discarded.
# ---- Stage 1: build ----FROM rust:1-slim AS builderWORKDIR /app
# Build dependencies alone first, so Docker caches them across source changes.COPY Cargo.toml Cargo.lock ./RUN mkdir src && echo 'fn main() {}' > src/main.rs && echo '' > src/lib.rs \ && cargo build --release && rm -rf src
# Now the real source; only this layer rebuilds when you edit code.COPY src ./srcRUN touch src/main.rs src/lib.rs && cargo build --release --bin apilite
# ---- Stage 2: runtime ----FROM debian:bookworm-slim AS runtimeRUN useradd --create-home --uid 10001 appuserCOPY --from=builder /app/target/release/apilite /usr/local/bin/apilite
ENV APILITE_BIND_ADDR=0.0.0.0:3000 \ DATABASE_URL=sqlite:/home/appuser/apilite.dbUSER appuserEXPOSE 3000CMD ["apilite"]Three deliberate choices in the runtime stage. 0.0.0.0 (not 127.0.0.1) so the server is reachable
from outside the container. A dedicated non-root appuser. And only the binary is copied — no
source, no compiler.
Under the hood — the dependency-cache layer
Section titled “Under the hood — the dependency-cache layer”That odd “dummy main.rs” step is a caching trick. Docker caches each layer and rebuilds a layer only
when its inputs change. By copying just Cargo.toml/Cargo.lock and building a stub binary first,
the expensive dependency compile (axum, tokio, sqlx and friends — the bulk of the build) lands in
its own layer keyed only on the manifests. Edit your own source afterward and Docker reuses the cached
dependency layer, recompiling only apilite. Without the trick, every one-line code change recompiles
the entire dependency tree from scratch.
Production polish
Section titled “Production polish”A few small things separate a demo from a service, and apilite has them: a GET /health endpoint that
answers without touching the database (so a load balancer can check liveness cheaply), config read
from the environment with defaults, structured request logging via the tracing layer, and a single
AppError that never leaks internals. A natural next step is graceful shutdown —
axum::serve(...).with_graceful_shutdown(signal) lets in-flight requests finish when the container
gets a SIGTERM instead of being cut off mid-response.
The thread
Section titled “The thread”What does building this force you to understand — and what is Rust’s compiler protecting you from? By now the answer has a shape you can feel across the whole project. Inside the process, the compiler enforces “shared XOR mutable” so concurrent requests can’t race over the pool. Across the test suite, you got isolation for free by not sharing mutable state — the same principle, applied by hand. And in the container, you shrank the blast radius by shipping less and dropping root. Rust guarantees memory safety and data-race freedom; it does not guarantee you used a non-root user or parameterized your SQL. Knowing exactly where the compiler’s protection ends and your engineering judgment begins — that’s the real graduation from “I know Rust” to “I can ship Rust.”
What you built
Section titled “What you built”A complete async REST API: routing and async handlers (Day 8), a
shared SQLite pool and CRUD (Day 9), a unified error type with
tracing middleware (Day 10), and now integration tests plus a
production image. cargo test runs the whole API against an in-memory database in under a second;
docker build . produces a small, non-root image ready to deploy.
Project 4, askr, takes the same async foundations to an AI CLI — HTTP streaming and a tiny RAG. For the full path and what comes after Phase 1, see the playbook overview.
Check your understanding
Section titled “Check your understanding”- Why does apilite split into a library crate (
lib.rs) plus a thinmain.rs, and what does that split make possible for the tests? - What does
tower::ServiceExt::oneshotlet a test do, and which two layers of real infrastructure does it let the test skip? - Each test calls
create_pool("sqlite::memory:")afresh. Why does that make the suite safe to run in parallel, with test order irrelevant? - A multi-stage Dockerfile has a
builderstage and aruntimestage. What goes in each, and why is the final image so much smaller than the builder? - Why copy
Cargo.toml/Cargo.lockand build a stub binary before copying your real source? What does running as a non-root user buy you, given CVE-2019-5736?
Show answers
- The library exposes
router(state)(andAppState, the modules), so the tests — a separate crate that depends onapilite— can build the exact sameRouterthe server runs and exercise it in-process. A baremain.rscouldn’t be imported, so the tests would have nothing to call. oneshottreats theRouteras a service: hand it oneRequest, get oneResponseback. The test skips the TCP network and binding a port entirely — no sockets, no “address already in use,” just request-in/response-out.- Each call creates a private, empty in-memory database pinned to one connection, so no two tests share state. With nothing shared, parallel execution can’t cause contamination and test order can’t matter — isolation by construction.
- The builder stage has the full Rust toolchain and compiles the release binary; the runtime
stage is a slim base (
debian:bookworm-slim) with only the copied binary. The final image is ~20× smaller because it carries no compiler, no source, and no build cache — just the executable. - Dependencies change rarely but take most of the build time; building them in their own layer (keyed
on the manifests) lets Docker cache them, so editing your source recompiles only apilite, not
every crate. Running as non-root limits the damage if the container runtime is exploited — as in
CVE-2019-5736, where a container could overwrite host
runcand escape to root; least privilege is defense in depth.