Skip to main content

Testcontainers

Angzarr uses testcontainers to automatically provision real databases during test execution.


Why Testcontainers

  • Zero setup — Tests start required containers automatically
  • Isolation — Each test gets a fresh container
  • Realistic — Tests run against real databases, not mocks
  • Parallel-safe — Dynamic port binding prevents conflicts
  • RAII cleanup — Containers stop when dropped

Basic Pattern

use testcontainers::{
core::{IntoContainerPort, WaitFor},
runners::AsyncRunner,
GenericImage, ImageExt,
};

async fn start_postgres() -> (ContainerAsync<GenericImage>, String) {
let image = GenericImage::new("postgres", "16")
.with_exposed_port(5432.tcp())
.with_wait_for(WaitFor::message_on_stdout(
"database system is ready to accept connections",
));

let container = image
.with_env_var("POSTGRES_USER", "testuser")
.with_env_var("POSTGRES_PASSWORD", "testpass")
.with_env_var("POSTGRES_DB", "testdb")
.with_startup_timeout(Duration::from_secs(60))
.start()
.await
.expect("Failed to start container");

// Brief delay ensures full readiness
tokio::time::sleep(Duration::from_secs(1)).await;

let host_port = container
.get_host_port_ipv4(5432)
.await
.expect("Failed to get mapped port");

let connection_string = format!(
"postgres://testuser:testpass@localhost:{}/testdb",
host_port
);

(container, connection_string)
}

Key Points

Keep Container Handle in Scope

The container stops when dropped. Use let (_container, url) = ...:

#[tokio::test]
async fn test_database_operations() {
let (_container, url) = start_postgres().await;

// Container alive for test duration
let pool = connect(&url).await;
// ... run tests ...

// Container stops when _container is dropped
}

Use Dynamic Ports

Never hardcode ports. Use get_host_port_ipv4():

let host_port = container.get_host_port_ipv4(5432).await.unwrap();
let url = format!("postgres://user:pass@localhost:{}/db", host_port);

Wait for Readiness

Use WaitFor conditions or brief sleeps:

let image = GenericImage::new("postgres", "16")
.with_wait_for(WaitFor::message_on_stdout(
"database system is ready to accept connections",
));

Backend-Specific Setup

PostgreSQL

async fn start_postgres() -> (ContainerAsync<GenericImage>, String) {
let image = GenericImage::new("postgres", "16")
.with_exposed_port(5432.tcp())
.with_wait_for(WaitFor::message_on_stdout("database system is ready"));

let container = image
.with_env_var("POSTGRES_USER", "test")
.with_env_var("POSTGRES_PASSWORD", "test")
.with_env_var("POSTGRES_DB", "test")
.start().await.unwrap();

let port = container.get_host_port_ipv4(5432).await.unwrap();
(container, format!("postgres://test:test@localhost:{}/test", port))
}

Redis

async fn start_redis() -> (ContainerAsync<GenericImage>, String) {
let image = GenericImage::new("redis", "7")
.with_exposed_port(6379.tcp())
.with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"));

let container = image.start().await.unwrap();
let port = container.get_host_port_ipv4(6379).await.unwrap();
(container, format!("redis://localhost:{}", port))
}

RabbitMQ

async fn start_rabbitmq() -> (ContainerAsync<GenericImage>, String) {
let image = GenericImage::new("rabbitmq", "3-management")
.with_exposed_port(5672.tcp())
.with_wait_for(WaitFor::message_on_stdout("started"));

let container = image
.with_env_var("RABBITMQ_DEFAULT_USER", "test")
.with_env_var("RABBITMQ_DEFAULT_PASS", "test")
.start().await.unwrap();

let port = container.get_host_port_ipv4(5672).await.unwrap();
(container, format!("amqp://test:test@localhost:{}", port))
}

Running Tests

Full-Support Backends (SQLite, PostgreSQL)

Backends supporting all storage traits use Gherkin interface tests:

# SQLite (in-memory, no containers)
cargo test --test interfaces --features "sqlite test-utils"

# PostgreSQL (requires podman/docker)
STORAGE_BACKEND=postgres cargo test --test interfaces --features "postgres test-utils"

Partial-Support Backends

Backends with partial storage support use direct macro tests:

# Redis (SnapshotStore only)
cargo test --test storage_redis --features redis

# immudb (EventStore only)
cargo test --test storage_immudb --features immudb

# NATS (EventStore only)
cargo test --test storage_nats --features nats

Bus Tests

# AMQP tests
cargo test --test bus_amqp --features amqp

Note: Requires the podman socket:

systemctl --user start podman.socket

Test Architecture

Gherkin Interface Tests (Preferred)

For backends supporting all three storage traits (EventStore, SnapshotStore, PositionStore), use the Gherkin interface tests. These provide human-readable contract specifications:

# tests/interfaces/features/event_store.feature
Scenario: First event in an aggregate's history starts at sequence 0
Given an aggregate "player" with no events
When I add 1 event to the aggregate
Then the aggregate should have 1 event
And the first event should have sequence 0

Shared Test Macros (Partial Support)

For backends with partial storage support, shared test macros ensure consistent behavior:

// tests/storage/event_store_tests.rs
macro_rules! run_event_store_tests {
($store:expr) => {{
test_add_and_get($store).await;
test_add_multiple_events($store).await;
test_get_from($store).await;
// ... more tests
}};
}

Each partial-support backend invokes the macros:

// tests/storage_redis.rs (SnapshotStore only)
#[tokio::test]
async fn test_redis_snapshot_store() {
let (_container, url) = start_redis().await;
let store = RedisSnapshotStore::new(&url, None).await.unwrap();

run_snapshot_store_tests!(&store);
}

Next Steps