Rust Testing Strategy #
This doc outlines how wetest our Rust libraries and apps.
- Unit tests: fast, pure Rust tests for code that doesn’t need middleware.
- Integration tests: run real middleware (RabbitMQ, Redis, S3 via LocalStack, Postgres/Yugabyte) using the testcontainers crate. use mock for external services like Xero, QBO, etc.
Tooling we use #
- Rust test stack: tokio (async tests), anyhow (ergonomic errors)
- Containers:
testcontainers = "0.25"with AsyncRunner API - Mock:
waremock. TBD. - Feature flags (per crate):
mq-tests,s3-tests,redis-tests, etc.- Workspace alias:
integration-test-all-> enables all integration tests across crates.
Why
testcontainers? It boots real services in Docker right from our tests, so we validate against actual middleware without dev/staging cloud infra.
What goes where #
- Unit tests (src/**, #[cfg(test)])
- Pure logic, helpers, serializers, formatters, retry math, etc.
- No network, no containers.
- Integration tests (tests/**)
- Exercise public APIs that talk to middleware (publish/subscribe, upload/read/delete, cache ops, DB repos, etc.)
- Each service gets its own test folder/binary (e.g., tests/rabbitmq, tests/redis, tests/s3)
Feature flags (keep container tests opt-in) #
Each crate:
# Cargo.toml
[features]
bonsai-cache-integration-test = [] # or bonsai-mq-integration-test, etc.
integration-test-all = ["bonsai-cache-integration-test"]
Crates without integration tests still define:
[features]
integration-test-all = []
Workspace oneliner:
cargo test --workspace -F integration-test-all
# or
cargo test --workspace --all-features
Without feature flags cargo only runs unittests
Minimal integration pattern (eample: Redis) #
Key ideas (we repeat this for RabbitMQ, S3/LocalStack, YB):
- start a single container per test binary and reuse it.
- Use
with_mapped_portto avoid local port clashes (or let Docker choose a random port). - Wait for readiness with simple
WiatForstrategy (log line or HTTP health)
// tests/redis/basic.rs
#![cfg(feature = "redis-tests")]
use anyhow::Result;
use testcontainers::{
ContainerAsync, GenericImage,
core::{ImageExt, IntoContainerPort, WaitFor},
runners::AsyncRunner,
};
use tokio::time::{sleep, Duration};
#[tokio::test]
async fn can_set_and_get() -> Result<()> {
// 1) Boot Redis (one-off for this test; in practice, put this in tests/redis/common.rs and reuse)
let image = GenericImage::new("redis", "7")
.with_exposed_port(6379_u16.tcp())
.with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
.into_container_request()
.with_mapped_port(6380u16, 6379_u16.tcp());
let container: ContainerAsync<GenericImage> = image.start().await?;
let host = container.get_host().await?;
let url = format!("redis://{host}:6380/");
// 2) Use real client against real Redis
let client = redis::Client::open(url)?;
let mut conn = client.get_async_connection().await?;
redis::cmd("SET").arg("k").arg("v").query_async::<_, ()>(&mut conn).await?;
let v: String = redis::cmd("GET").arg("k").query_async(&mut conn).await?;
assert_eq!(v, "v");
// Give container a moment before drop in some CI environments
sleep(Duration::from_millis(10)).await;
Ok(())
}
Apply the same pattern to RabbitMQ (publish/consume), S3 via LocalStack (put/get/delete), and Postgres/Yugabyte (schema + queries). Keep the examples tiny; the point is real infra, minimal fuss.
Unit test pattern (no containers) #
- Co-locate in the module:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_headers() {
assert_eq!(format_key("abcd1234"), "****1234");
}
}
- Use pure data + deterministic assertions
- For functions that normally hit middleware, factor the logic so the pure parts are testable without I/O (e.g., payload building, routing key selection, TTL bucketing, key naming, retry/backoff math).
Running tests #
- Everything:
cargo test --workspace --all-features
- Just one crate + its integration tests:
cargo test -p bonsai-mq -F bonsai-mq-integration-test
- Unittests only
cargo test
Pratical notes #
- Prefer one test binary per service per crate (e.g., tests/rabbitmq/…) to avoid port collisions and to maximize container reuse.
- Use short, unique resource names per test (bucket names, queues/keys).
- Keep integration tests small and focused: one behavior per test (publish-then-consume, put-then-get-then-delete, set-then-get, simple query).
- If an operation is flaky on initial readiness, add a tiny retry/backoff in the test (not in prod code).