|
| 1 | +# Docker Compose Support |
| 2 | + |
| 3 | +Testcontainers for Rust supports running multi-container applications defined in Docker Compose files. This is useful when your tests need multiple interconnected services or when you want to reuse existing docker-compose configurations from your development environment. |
| 4 | + |
| 5 | +> **Note:** Docker Compose support is currently only available for async runtimes. Synchronous/blocking support may be added in a future release. |
| 6 | +
|
| 7 | +## Installation |
| 8 | + |
| 9 | +Add the `docker-compose` feature to your dependencies: |
| 10 | + |
| 11 | +```toml |
| 12 | +[dev-dependencies] |
| 13 | +testcontainers = { version = "x.y.z", features = ["docker-compose"] } |
| 14 | +``` |
| 15 | + |
| 16 | +## Minimal Example |
| 17 | + |
| 18 | +```rust |
| 19 | +use testcontainers::compose::DockerCompose; |
| 20 | + |
| 21 | +#[tokio::test] |
| 22 | +async fn test_redis() -> Result<(), Box<dyn std::error::Error>> { |
| 23 | + let mut compose = DockerCompose::with_local_client(&["tests/docker-compose.yml"]); |
| 24 | + compose.up().await?; |
| 25 | + |
| 26 | + let redis = compose.service("redis").expect("redis service"); |
| 27 | + let port = redis.get_host_port_ipv4(6379).await?; |
| 28 | + |
| 29 | + // Use redis at localhost:{port} |
| 30 | + let client = redis::Client::open(format!("redis://localhost:{}", port))?; |
| 31 | + let mut con = client.get_connection()?; |
| 32 | + redis::cmd("PING").query::<String>(&mut con)?; |
| 33 | + |
| 34 | + Ok(()) |
| 35 | +} |
| 36 | +``` |
| 37 | + |
| 38 | +With `docker-compose.yml`: |
| 39 | + |
| 40 | +```yaml |
| 41 | +services: |
| 42 | + redis: |
| 43 | + image: redis:7-alpine |
| 44 | + ports: |
| 45 | + - "6379" |
| 46 | +``` |
| 47 | +
|
| 48 | +## Basic Usage |
| 49 | +
|
| 50 | +Use [`DockerCompose`](https://docs.rs/testcontainers/latest/testcontainers/compose/struct.DockerCompose.html) to start services defined in your compose files: |
| 51 | + |
| 52 | +```rust |
| 53 | +use testcontainers::compose::DockerCompose; |
| 54 | +
|
| 55 | +#[tokio::test] |
| 56 | +async fn test_with_compose() -> Result<(), Box<dyn std::error::Error>> { |
| 57 | + let mut compose = DockerCompose::with_local_client(&["tests/docker-compose.yml"]); |
| 58 | +
|
| 59 | + compose.up().await?; |
| 60 | +
|
| 61 | + // Access service by name |
| 62 | + let web = compose.service("web").expect("web service"); |
| 63 | + let port = web.get_host_port_ipv4(8080).await?; |
| 64 | +
|
| 65 | + let response = reqwest::get(format!("http://localhost:{}", port)).await?; |
| 66 | + assert!(response.status().is_success()); |
| 67 | +
|
| 68 | + Ok(()) |
| 69 | + // Automatic cleanup on drop |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +## Accessing Services |
| 74 | + |
| 75 | +After calling `up()`, you can access individual services by name. The `service()` method returns a reference to the container, providing full access to the container API: |
| 76 | + |
| 77 | +### Get Service Container |
| 78 | + |
| 79 | +```rust |
| 80 | +compose.up().await?; |
| 81 | +
|
| 82 | +let redis = compose.service("redis").expect("redis service exists"); |
| 83 | +
|
| 84 | +// Full container API available |
| 85 | +let port = redis.get_host_port_ipv4(6379).await?; |
| 86 | +let logs = redis.stdout(true); |
| 87 | +redis.exec(ExecCommand::new(["redis-cli", "PING"])).await?; |
| 88 | +``` |
| 89 | + |
| 90 | +### List All Services |
| 91 | + |
| 92 | +```rust |
| 93 | +compose.up().await?; |
| 94 | +
|
| 95 | +for service_name in compose.services() { |
| 96 | + println!("Service: {}", service_name); |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +### Access Ports, Logs, and Execute Commands |
| 101 | + |
| 102 | +Services return a container reference with the full API: |
| 103 | + |
| 104 | +```rust |
| 105 | +compose.up().await?; |
| 106 | +
|
| 107 | +let redis = compose.service("redis").expect("redis service"); |
| 108 | +
|
| 109 | +// Get mapped ports |
| 110 | +let port = redis.get_host_port_ipv4(6379).await?; |
| 111 | +let ipv6_port = redis.get_host_port_ipv6(6379).await?; |
| 112 | +
|
| 113 | +// Stream logs |
| 114 | +let stdout = redis.stdout(true); |
| 115 | +let stderr = redis.stderr(false); |
| 116 | +
|
| 117 | +// Execute commands |
| 118 | +let result = redis.exec(ExecCommand::new(["redis-cli", "PING"])).await?; |
| 119 | +
|
| 120 | +// Get container info |
| 121 | +let container_id = redis.id(); |
| 122 | +let host = redis.get_host().await?; |
| 123 | +``` |
| 124 | + |
| 125 | +## Client Modes |
| 126 | + |
| 127 | +### Local Client (Default) |
| 128 | + |
| 129 | +Uses the locally installed `docker compose` CLI: |
| 130 | + |
| 131 | +```rust |
| 132 | +let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]); |
| 133 | +compose.up().await?; |
| 134 | +``` |
| 135 | + |
| 136 | +**Requirements:** |
| 137 | +- Docker CLI with Compose plugin installed locally |
| 138 | +- Compose files must be on the filesystem |
| 139 | + |
| 140 | +### Containerised Client |
| 141 | + |
| 142 | +Runs `docker compose` inside a container (no local Docker CLI required): |
| 143 | + |
| 144 | +```rust |
| 145 | +let mut compose = DockerCompose::with_containerised_client(&["docker-compose.yml"]).await; |
| 146 | +compose.up().await?; |
| 147 | +``` |
| 148 | + |
| 149 | +**Benefits:** |
| 150 | +- No local Docker CLI installation needed |
| 151 | +- Consistent compose version across environments |
| 152 | +- Useful for CI/CD where Docker CLI might not be available |
| 153 | + |
| 154 | +## Configuration Options |
| 155 | + |
| 156 | +### Environment Variables |
| 157 | + |
| 158 | +Pass environment variables to your compose stack: |
| 159 | + |
| 160 | +```rust |
| 161 | +use std::collections::HashMap; |
| 162 | +
|
| 163 | +let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]) |
| 164 | + .with_env("DATABASE_URL", "postgres://test:test@db:5432/test") |
| 165 | + .with_env("REDIS_PORT", "6380"); |
| 166 | +
|
| 167 | +compose.up().await?; |
| 168 | +``` |
| 169 | + |
| 170 | +Or use a HashMap for bulk configuration: |
| 171 | + |
| 172 | +```rust |
| 173 | +let mut env_vars = HashMap::new(); |
| 174 | +env_vars.insert("API_KEY".to_string(), "test-key-123".to_string()); |
| 175 | +env_vars.insert("DEBUG".to_string(), "true".to_string()); |
| 176 | +
|
| 177 | +let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]) |
| 178 | + .with_env_vars(env_vars); |
| 179 | +
|
| 180 | +compose.up().await?; |
| 181 | +``` |
| 182 | + |
| 183 | +### Lifecycle and Cleanup |
| 184 | + |
| 185 | +You can either let the stack automatically clean up on drop, or explicitly tear it down: |
| 186 | + |
| 187 | +```rust |
| 188 | +// Option 1: Automatic cleanup (default behavior) |
| 189 | +{ |
| 190 | + let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]); |
| 191 | + compose.up().await?; |
| 192 | + // Use services... |
| 193 | +} // Automatically cleaned up here |
| 194 | +
|
| 195 | +// Option 2: Explicit teardown |
| 196 | +let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]); |
| 197 | +compose.up().await?; |
| 198 | +// Use services... |
| 199 | +compose.down().await?; // Explicit cleanup, consumes compose |
| 200 | +``` |
| 201 | + |
| 202 | +Control what gets removed during cleanup: |
| 203 | + |
| 204 | +```rust |
| 205 | +let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]); |
| 206 | +
|
| 207 | +// Remove volumes on cleanup (default: true) |
| 208 | +compose.with_remove_volumes(true); |
| 209 | +
|
| 210 | +// Remove images on cleanup (default: false) |
| 211 | +compose.with_remove_images(false); |
| 212 | +
|
| 213 | +compose.up().await?; |
| 214 | +``` |
| 215 | + |
| 216 | +### Build and Pull Options |
| 217 | + |
| 218 | +Configure whether to build or pull images before starting: |
| 219 | + |
| 220 | +```rust |
| 221 | +let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]) |
| 222 | + .with_build(true) // Build images defined in compose file |
| 223 | + .with_pull(true); // Pull latest images before starting |
| 224 | +
|
| 225 | +compose.up().await?; |
| 226 | +``` |
| 227 | + |
| 228 | +## Multiple Compose Files |
| 229 | + |
| 230 | +You can use multiple compose files (e.g., base + override): |
| 231 | + |
| 232 | +```rust |
| 233 | +let mut compose = DockerCompose::with_local_client(&[ |
| 234 | + "docker-compose.yml", |
| 235 | + "docker-compose.test.yml", |
| 236 | +]); |
| 237 | +
|
| 238 | +compose.up().await?; |
| 239 | +``` |
| 240 | + |
| 241 | +## Complete Example |
| 242 | + |
| 243 | +```rust |
| 244 | +use testcontainers::{ |
| 245 | + compose::DockerCompose, |
| 246 | + core::IntoContainerPort, |
| 247 | +}; |
| 248 | +
|
| 249 | +#[tokio::test] |
| 250 | +async fn integration_test_with_compose() -> Result<(), Box<dyn std::error::Error>> { |
| 251 | + let mut compose = DockerCompose::with_local_client(&[ |
| 252 | + "tests/docker-compose.yml", |
| 253 | + ]) |
| 254 | + .with_env("POSTGRES_PASSWORD", "test-password") |
| 255 | + .with_env("REDIS_MAXMEMORY", "256mb"); |
| 256 | +
|
| 257 | + compose.up().await?; |
| 258 | +
|
| 259 | + // List all running services |
| 260 | + println!("Running services: {:?}", compose.services()); |
| 261 | +
|
| 262 | + // Access database |
| 263 | + let db_port = compose.get_host_port_ipv4("db", 5432).await?; |
| 264 | + let db_url = format!("postgres://postgres:test-password@localhost:{}/test", db_port); |
| 265 | + let db_pool = sqlx::PgPool::connect(&db_url).await?; |
| 266 | +
|
| 267 | + // Run migrations or setup |
| 268 | + sqlx::query("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY)") |
| 269 | + .execute(&db_pool) |
| 270 | + .await?; |
| 271 | +
|
| 272 | + // Access Redis |
| 273 | + let redis_port = compose.get_host_port_ipv4("redis", 6379).await?; |
| 274 | + let redis_client = redis::Client::open(format!("redis://localhost:{}", redis_port))?; |
| 275 | + let mut con = redis_client.get_connection()?; |
| 276 | + redis::cmd("SET").arg("test-key").arg("test-value").query::<()>(&mut con)?; |
| 277 | +
|
| 278 | + // Access web service |
| 279 | + let web_port = compose.get_host_port_ipv4("web", 8080).await?; |
| 280 | + let response = reqwest::get(format!("http://localhost:{}/health", web_port)) |
| 281 | + .await? |
| 282 | + .text() |
| 283 | + .await?; |
| 284 | +
|
| 285 | + assert_eq!(response, "OK"); |
| 286 | +
|
| 287 | + Ok(()) |
| 288 | + // Automatic cleanup: containers, networks, and volumes are removed |
| 289 | +} |
| 290 | +``` |
| 291 | + |
| 292 | +## Sample Compose File |
| 293 | + |
| 294 | +Here's an example `docker-compose.yml` that works with the above code: |
| 295 | + |
| 296 | +```yaml |
| 297 | +services: |
| 298 | + db: |
| 299 | + image: postgres:16 |
| 300 | + environment: |
| 301 | + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} |
| 302 | + POSTGRES_DB: test |
| 303 | + ports: |
| 304 | + - "5432" |
| 305 | +
|
| 306 | + redis: |
| 307 | + image: redis:7-alpine |
| 308 | + command: redis-server --maxmemory ${REDIS_MAXMEMORY:-128mb} |
| 309 | + ports: |
| 310 | + - "6379" |
| 311 | +
|
| 312 | + web: |
| 313 | + image: my-web-app:latest |
| 314 | + environment: |
| 315 | + DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/test |
| 316 | + REDIS_URL: redis://redis:6379 |
| 317 | + ports: |
| 318 | + - "8080" |
| 319 | + depends_on: |
| 320 | + - db |
| 321 | + - redis |
| 322 | +``` |
| 323 | + |
| 324 | +## Best Practices |
| 325 | + |
| 326 | +### Use Unique Project Names |
| 327 | + |
| 328 | +Each test gets a unique project name automatically via UUID, preventing conflicts between parallel tests. No manual configuration needed. |
| 329 | + |
| 330 | +### Rely on Compose's --wait Flag |
| 331 | + |
| 332 | +The compose `up()` method uses Docker Compose's built-in `--wait` functionality, which waits for services to be healthy before returning. Configure healthchecks in your compose file: |
| 333 | + |
| 334 | +```yaml |
| 335 | +services: |
| 336 | + web: |
| 337 | + image: nginx |
| 338 | + healthcheck: |
| 339 | + test: ["CMD", "curl", "-f", "http://localhost"] |
| 340 | + interval: 5s |
| 341 | + timeout: 3s |
| 342 | + retries: 3 |
| 343 | + ports: |
| 344 | + - "80" |
| 345 | +``` |
| 346 | + |
| 347 | +### Clean Up Resources |
| 348 | + |
| 349 | +By default, volumes are removed on cleanup but images are not. Adjust based on your needs: |
| 350 | + |
| 351 | +```rust |
| 352 | +// Keep volumes for debugging or to reuse data across test runs |
| 353 | +compose.with_remove_volumes(false); |
| 354 | +
|
| 355 | +// Remove images to save disk space |
| 356 | +compose.with_remove_images(true); |
| 357 | +``` |
| 358 | + |
| 359 | +## Troubleshooting |
| 360 | + |
| 361 | +### Service Not Found |
| 362 | + |
| 363 | +If `compose.service("name")` returns `None`: |
| 364 | + |
| 365 | +1. Check the service name matches exactly what's in your compose file |
| 366 | +2. Ensure `up()` was called and succeeded |
| 367 | +3. Verify the service started successfully (check Docker logs) |
| 368 | + |
| 369 | +### Port Not Exposed Error |
| 370 | + |
| 371 | +If `get_host_port_ipv4()` fails: |
| 372 | + |
| 373 | +1. Ensure the port is listed in the `ports:` section of your compose file |
| 374 | +2. Use the **container's internal port**, not the host port |
| 375 | +3. Example: If mapped as `"8081:80"`, use `compose.get_host_port_ipv4("web", 80)` |
| 376 | + |
| 377 | +### Compose Command Fails |
| 378 | + |
| 379 | +If `up()` returns an error: |
| 380 | + |
| 381 | +1. Verify Docker Compose is installed: `docker compose version` |
| 382 | +2. Check compose file is valid: `docker compose -f your-file.yml config` |
| 383 | +3. Ensure all required images are available or can be pulled |
| 384 | + |
0 commit comments