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

# PostgreSQL tests (requires podman/docker)
cargo test --test storage_postgres --features postgres

# Redis tests
cargo test --test storage_redis --features redis

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

Note: Requires the podman socket:

systemctl --user start podman.socket

Shared Test Macros

All storage backends implement the same traits. Test macros define cases once:

// 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 backend invokes the macros:

// tests/storage_postgres.rs
#[tokio::test]
async fn test_postgres_event_store() {
let (_container, url) = start_postgres().await;
let pool = connect_and_migrate(&url).await;
let store = PostgresEventStore::new(pool);

run_event_store_tests!(&store);
}

Next Steps