Skip to content

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.”

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 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 body
use 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 contamination

cargo 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.

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 builder
WORKDIR /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 ./src
RUN touch src/main.rs src/lib.rs && cargo build --release --bin apilite
# ---- Stage 2: runtime ----
FROM debian:bookworm-slim AS runtime
RUN useradd --create-home --uid 10001 appuser
COPY --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.db
USER appuser
EXPOSE 3000
CMD ["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.

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 shutdownaxum::serve(...).with_graceful_shutdown(signal) lets in-flight requests finish when the container gets a SIGTERM instead of being cut off mid-response.

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.”

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.

  1. Why does apilite split into a library crate (lib.rs) plus a thin main.rs, and what does that split make possible for the tests?
  2. What does tower::ServiceExt::oneshot let a test do, and which two layers of real infrastructure does it let the test skip?
  3. Each test calls create_pool("sqlite::memory:") afresh. Why does that make the suite safe to run in parallel, with test order irrelevant?
  4. A multi-stage Dockerfile has a builder stage and a runtime stage. What goes in each, and why is the final image so much smaller than the builder?
  5. Why copy Cargo.toml/Cargo.lock and 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
  1. The library exposes router(state) (and AppState, the modules), so the tests — a separate crate that depends on apilite — can build the exact same Router the server runs and exercise it in-process. A bare main.rs couldn’t be imported, so the tests would have nothing to call.
  2. oneshot treats the Router as a service: hand it one Request, get one Response back. The test skips the TCP network and binding a port entirely — no sockets, no “address already in use,” just request-in/response-out.
  3. 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.
  4. 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.
  5. 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 runc and escape to root; least privilege is defense in depth.