Skip to content

Commit 1aefb1c

Browse files
DDtKeymbodmer
andauthored
feat: support docker-compose (#864)
Closes #59 This PR (#774) continues against the updated main branch. In general, most of the logic and code have been preserved. This PR mostly completes the work that was started some time ago. --------- Signed-off-by: mbodmer <[email protected]> Co-authored-by: mbodmer <[email protected]>
1 parent 31dc40e commit 1aefb1c

File tree

25 files changed

+1623
-299
lines changed

25 files changed

+1623
-299
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ authors = [
1010
"Mervyn McCreight",
1111
]
1212
edition = "2021"
13-
keywords = ["docker", "testcontainers"]
13+
keywords = ["docker", "testcontainers", "docker-compose"]
1414
license = "MIT OR Apache-2.0"
1515
readme = "README.md"
1616
repository = "https://github.com/testcontainers/testcontainers-rs"

docs/features/docker_compose.md

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
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

Comments
 (0)