Delightful integration tests in Rust
- title
- Delightful integration tests in Rust
- type
- summary
- summary
- RAII plus testcontainers-rs gives every Rust integration test its own isolated infrastructure
- tags
- rust, testing, docker, raii
- created
- 2026-07-29
- updated
- 2026-09-13
A chapter from the rust-magic-patterns collection (alexpusch). The complaint it starts from is that Rust's test harness has no setup and teardown, which jest and pytest fixtures both provide, and that Rust runs tests concurrently on separate threads by default, so building and sharing global state is awkward. The resolution is that the missing feature turns out not to be needed: raii gets you per-test infrastructure instead of shared infrastructure, and per-test infrastructure is better.
The crate is testcontainers-rs. Its idea is that a Docker container is a resource, so Drop can clean it up.
Doing it by hand first
Before reaching for the crate, the post builds the pattern directly with bollard. A RabbitMqContainer struct holds a Docker handle and a container ID; start() creates a rabbitmq:3.8.22-management container exposing 5672/tcp and starts it. Then:
impl Drop for RabbitMqContainer {
fn drop(&mut self) {
let docker = self.docker.clone();
let container_id = self.container_id.clone();
async_drop(async move {
docker
.remove_container(&container_id, Some(RemoveContainerOptions {
force: true, ..Default::default()
}))
.await
.expect("Failed to remove container");
});
}
}
The test body becomes let _rabbitmq = RabbitMqContainer::start().await?; and nothing else.
Two things are wrong with this and the post says both. Stopping a container is async, and AsyncDrop is nightly-only and still in progress upstream (rust-lang/rust#126482), so the async_drop helper borrowed from testcontainers-rs runs the cleanup on a different thread and blocks drop until it finishes. Called "not ideal, but good enough for tests." And Drop doesn't run at all under SIGINT, SIGTERM, SIGKILL, or the OOM killer; some of those are catchable and some are not, and testcontainers-rs ships a Watchdog that mitigates part of the problem.
What the crate adds
The demo application is deliberately not a toy-sized toy: two RabbitMQ consumers (ToyOrder and ToyReview) plus an HTTP server serving analytics endpoints backed by Postgres with a Redis cache. Four moving pieces of infrastructure for a small app.
Implementing the Image trait defines a container's properties — name, tag, command, mounts:
impl Image for RabbitMqImage {
fn name(&self) -> &str { "rabbitmq" }
fn tag(&self) -> &str { "3.8.22-management" }
}
That version fails. Publishing to a container that has started but not initialized produces IO error: Connection reset by peer (os error 104), and the fix is the method the post admits it withheld:
fn ready_conditions(&self) -> Vec<WaitFor> {
vec![WaitFor::message_on_stdout("Server startup complete")]
}
This is the piece the author calls key to making the whole thing ergonomic. Every service has some initialization to wait for, and encoding "wait for this string on stdout" next to the image definition means no test ever has to know about it. In practice you often don't write Image yourself at all, since testcontainers-modules ships definitions for RabbitMQ, Postgres, Redis and many others.
A TestEnv struct then owns everything: the three ContainerAsync handles as underscore-prefixed fields kept alive only for their Drop, plus the channel, connection pool, Redis connection, and API address the tests actually use. The three containers start concurrently under tokio::try_join!, the app is constructed with api_port: 0 and its real address read back with local_address(), and the whole app is spawned onto the runtime.
The finished test publishes five ToyOrdered events spaced five minutes apart, then queries the top_toys endpoint over a window from TEST_TIME + 3min to TEST_TIME + 17min and asserts two G.I. Joe orders and one Barbie. The window deliberately clips the first and last events, so the assertion is on the query's time filtering, not just on ingest.
The sleep in the middle
Between the publish and the assertion sits wait_for_consistency().await, which is a one-second static sleep, and the post refuses to pretend otherwise: "the worst possible solution." Messages go into an async queue and nothing external tells you when they've all been processed. Too long adds seconds to every test; too short makes the test flaky, or worse, occasionally passing. Testcontainers solves the provisioning problem and leaves this one untouched.
What isolation buys
Each test gets its own broker, its own database, and its own schema, so one test cannot pollute another's tables and one test's messages cannot reach another's consumers. That isolation is what makes unlimited concurrency safe, which is the same property Rust's default concurrent test execution was making difficult under the shared-fixture model.
Infrastructure under program control also becomes testable in its own right. Simulating a Redis outage is env.stop_redis().await?; a natural extension is something like env.start_postgres_with_cpu_limit(...) for degraded-database behavior. Neither is expressible in a docker-compose.yaml invoked by a wrapper script — and staying inside the process means the command remains cargo test rather than a bespoke harness the team has to learn.
The cost is startup time. Every test waits for containers to launch, waits for readiness conditions, and runs full migrations. Running tests concurrently offsets some of it, bounded by the machine's resources. The post frames this as a tradeoff each project evaluates rather than a settled answer, and points out that integration tests are one layer of a suite whose unit tests pay none of this cost.
The closing note is that none of this is testing-specific. tempfile uses the same pattern to remove generated files at runtime; RAII extends to any out-of-process resource whose lifetime you can pin to an owner.
The Golang equivalent lands in a different place, which is worth noting alongside this. t-context-go-testing covers Jonathan Hall's rule that a context's lifetime should match its owner's, and his explicit example of a case where T.Context() is wrong is a Testcontainers container shared across the whole test binary — sharing it is the assumed default there, and the hazard is a per-test hook tearing down something broader. The Rust version inverts that by making per-test the cheap default and never introducing the shared thing at all.
anatomy-of-a-test makes the same real-dependency argument for a single Gleam test, using an in-memory SQLite database and a test-only constructor.