Rust

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_port to avoid local port clashes (or let Docker choose a random port).
  • Wait for readiness with simple WiatFor strategy (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).