Articles 28 through 31 established routes, SQLite persistence, a background worker, and readiness as separate contracts. Passing those checks does not prove the deployment boundary. A real listener may accept a request while the worker fails to persist its result. An application may report a clean shutdown after dropping work it already accepted. The capstone has to cross HTTP, the queue, the database, and the process lifecycle in one test.
The fixture uses Rust 2024 with pinned Axum 0.8.6, Tokio 1.53.1, and SQLx 0.8.6. Each test owns a temporary SQLite file and a listener on 127.0.0.1:0, then sends requests with a real HTTP client. The suite covers worker failure, duplicate input, a missing endpoint, an invalid database path, and shutdown drain as well as the successful path.
1. Test outside the unit boundary
Sending a oneshot request to a Router is a fast way to cover routing, extractors, and middleware. It does not bind a listener or exercise connection and process shutdown. This fixture starts the server on an ephemeral port and reads its HTTP responses. It still avoids the public network: scripted://ok, scripted://fail, and scripted://slow make the outbound-check result deterministic.
Every case creates a new temporary directory. Its migrated file database, server task, and worker task live only for that case. No shared port or database can make the result depend on test order. RunningApp returns the address selected by the OS along with the shutdown handle.
2. Cross the API, queue, and database together
The success case creates a row through POST /endpoints, then calls POST /endpoints/{id}/checks. The handler puts the ID on a bounded Tokio channel and returns 202 Accepted. The worker reads the URL back from SQLite, computes the outcome, and writes the endpoint's latest state plus a history row in one transaction. A final GET observes the stored 204.
There is no mock repository in this path. Migrations, SQL parameters, serialization, queue ownership, and the worker transaction are production code. DNS and TLS are deliberately outside it. A deployment gate should not depend on a third-party network remaining available.
A scripted://fail check writes scripted transport failure with no status, so failure remains visible in history. Duplicate IDs return 409; a check for an unknown endpoint returns 404; startup rejects a directory used as the database file. These cases preserve which boundary rejected the operation instead of collapsing everything into a panic or timeout.
3. Stop HTTP, then drain accepted work
Graceful shutdown has an order. The application clears readiness and completes Axum's shutdown future. The server stops accepting connections and waits for current requests. When the Router drops, it releases the final queue sender. The worker processes every queued ID, sees None from recv(), and exits. Only then does shutdown finish joining the tasks.
#[tokio::test]
async fn shutdown_stops_http_and_drains_accepted_work() {
let harness = Harness::start().await;
harness.create("slow", "scripted://slow").await;
assert_eq!(harness.enqueue("slow").await.status(), StatusCode::ACCEPTED);
let address = harness.app.address;
let report = harness.app.shutdown().await.unwrap();
assert_eq!(report.completed_checks, 1);
assert!(harness.client
.get(format!("http://{address}/health/ready"))
.send().await.is_err());
}
The test enqueues a slow scripted check and immediately shuts down. The report must contain one completed check, and a new connection to the listener must fail. It also reopens the database through a fresh pool and verifies the persisted 204. Merely checking that the shutdown method returned would not prove that accepted work was drained.
The fixture has no drain deadline. A deployed service still has to decide whether to cancel remaining HTTP checks or terminate the process when that deadline expires. This example fixes the narrower contract that short, accepted jobs are not discarded.
4. Make the lockfile part of the release gate
Exact direct versions in Cargo.toml do not pin the complete transitive graph. The fixture keeps its generated Cargo.lock and passes --locked to every CI and release command. A missing lockfile or a resolution change makes Cargo fail rather than update silently.
cd examples/article-32-axum-integration-testing-container
cargo fmt --all -- --check
cargo check --locked --all-targets --all-features
cargo clippy --locked --all-targets --all-features -- -D warnings
cargo test --locked --all-features
cargo test --locked --release --all-features
cargo build --locked --release
Both debug and release tests run, followed by a release build. That catches optimization-sensitive behavior and proves that the shipping binary links. Eight tests cover the API/database round trip, worker success and failure, request failures, readiness, startup failure, shutdown drain, and Dockerfile structure.
--locked reproduces dependency resolution. It does not by itself pin the compiler, linker, base-image bytes, or timestamps. Byte-identical artifacts require additional controls for the toolchain, image digests, build environment, and timestamp inputs.
5. Separate builder and runtime stages
The builder needs the Rust toolchain and dependency sources. The runtime needs the binary and a health probe. The multi-stage Dockerfile copies only the release executable into the final stage, switches to numeric UID 10001, and confines writable application data to /data.
FROM rust:1.98.1-bookworm@sha256:ae1a730a949f727611a5c684e1e26e5a9bb9885b34f65a442744ca8a61c86ca5 AS builder
WORKDIR /work
COPY Cargo.toml Cargo.lock ./
COPY migrations ./migrations
COPY src ./src
RUN cargo build --locked --release
FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS runtime
RUN mkdir -p /data && chown 10001:10001 /data
COPY --from=builder /work/target/release/article-32-axum-integration-testing-container /usr/local/bin/monitor-api
USER 10001:10001
ENV BIND_ADDR=0.0.0.0:3000 DATABASE_PATH=/data/monitor.sqlite
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=2s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/monitor-api", "--healthcheck"]
ENTRYPOINT ["/usr/local/bin/monitor-api"]
HEALTHCHECK uses the release binary's --healthcheck mode to call /health/ready. The application returns from startup only after migrations, the pool, and the worker are ready, so a failed dependency prevents the container from becoming healthy. A Docker health check does not automatically configure every orchestrator's readiness policy; connect the command and timing to the target platform.
Both FROM instructions pin the verified multi-platform manifest digest. An unchanged Cargo lockfile and base manifest therefore select the same dependencies and base filesystem. Host architecture, BuildKit, linker, and timestamp inputs can still affect byte-for-byte compiler output.
6. Close the loop with a short runbook
Build and run from the fixture directory. The SQLite file belongs on a named volume.
docker build --pull --tag endpoint-monitor:article-32 .
docker run --rm --name endpoint-monitor \
-p 3000:3000 \
-v endpoint-monitor-data:/data \
endpoint-monitor:article-32
Check readiness, create an endpoint, enqueue a check, and read the result in that order.
curl --fail http://127.0.0.1:3000/health/ready
curl --fail -H 'content-type: application/json' \
-d '{"id":"demo","url":"scripted://ok"}' \
http://127.0.0.1:3000/endpoints
curl --fail -X POST http://127.0.0.1:3000/endpoints/demo/checks
curl --fail http://127.0.0.1:3000/endpoints/demo/checks
docker stop endpoint-monitor
docker stop sends the process a termination signal, which reaches the binary's signal path and starts Axum shutdown plus worker drain. If readiness fails, inspect the container log and confirm that /data is writable. Back up the volume before changing migrations, and never edit a migration that has already run.
The fixture's HTTP checker remains scripted. Production DNS, TLS, timeout, and proxy-header behavior need adapter and environment tests. The capstone does establish that the previous articles' contracts meet inside one process: accepted work becomes database history or an explicit failure, and graceful shutdown waits for that boundary to close.
Full source code
The complete runnable source for this article is available in the Chapter 32 project on GitHub.
Leave a Reply