# Delightful integration tests in Rust

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](https://github.com/testcontainers/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:

```rust
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:

```rust
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:

```rust
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.
