Introduction
Testcontainers OCaml is a library that provides lightweight, disposable containers for integration testing. It enables OCaml developers to write tests against real services—databases, message queues, and other infrastructure—without complex setup or shared test environments.
Why Testcontainers?
Integration tests that rely on mocks or in-memory implementations often miss real-world bugs. Configuration mismatches, protocol differences, and edge cases in actual services go undetected until production.
Testcontainers solves this by:
- Running real services in Docker containers during tests
- Isolating each test run with fresh, disposable containers
- Managing lifecycle automatically—containers start before tests and terminate after
- Providing consistent environments across local development and CI/CD
Why OCaml?
OCaml's type system and emphasis on correctness make it an excellent choice for building reliable systems. Testcontainers OCaml brings the same philosophy to testing:
- Type-safe container configuration using the builder pattern
- Lwt-based async operations that integrate naturally with OCaml's async ecosystem
- Functional API design with composable wait strategies and configuration
Example
A complete integration test with PostgreSQL:
open Lwt.Syntax
open Testcontainers
let test_database () =
Postgres_container.with_postgres
~config:(fun c -> c
|> Postgres_container.with_database "myapp"
|> Postgres_container.with_username "test"
|> Postgres_container.with_password "secret")
(fun container connection_string ->
(* connection_string: postgresql://test:secret@127.0.0.1:54321/myapp *)
let* result = My_db.query connection_string "SELECT 1" in
assert (result = 1);
Lwt.return_unit)
The container starts automatically, waits until PostgreSQL is ready to accept connections, runs your test, and cleans up—regardless of whether the test passes or fails.
Features
| Feature | Description |
|---|---|
| Container Lifecycle | Automatic start, stop, and cleanup |
| Port Mapping | Dynamic port allocation with easy access |
| Wait Strategies | Port, log, HTTP, exec, and health check waiting |
| Networks | Isolated Docker networks for multi-container tests |
| File Operations | Copy files to and from containers |
| Pre-built Modules | PostgreSQL, MySQL, MongoDB, Redis, RabbitMQ |
Architecture
┌─────────────────────────────────────────────────────┐
│ Your Test Code │
├─────────────────────────────────────────────────────┤
│ Testcontainers OCaml │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Container │ │ Wait │ │ Network │ │
│ │ Request │ │ Strategy │ │ Module │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Docker Client (Unix Socket) │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ Docker Engine │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Postgres │ │ Redis │ │ MySQL │ ... │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────┘
Prior Art
This library is inspired by:
License
Testcontainers OCaml is released under the Apache License 2.0.
Installation
Prerequisites
Before installing Testcontainers OCaml, ensure you have:
- OCaml 5.0+ and opam 2.0+
- Docker installed and running
- dune build system
Verify Docker is running:
docker info
Installing via opam
# Core library
opam install testcontainers
# Module-specific packages (install as needed)
opam install testcontainers-postgres
opam install testcontainers-mysql
opam install testcontainers-mongodb
opam install testcontainers-redis
opam install testcontainers-rabbitmq
Adding to Your Project
dune-project
(lang dune 3.0)
(name my_project)
(package
(name my_project)
(depends
(ocaml (>= 5.0))
(testcontainers (>= 1.0))
(testcontainers-postgres (>= 1.0)) ; if using PostgreSQL
(alcotest :with-test)
(alcotest-lwt :with-test)))
dune (test directory)
(test
(name my_tests)
(libraries
my_project
testcontainers
testcontainers-postgres
alcotest
alcotest-lwt
lwt
lwt.unix)
(preprocess (pps lwt_ppx)))
Manual Installation (from source)
git clone https://github.com/benodiwal/testcontainers-ocaml.git
cd testcontainers-ocaml
opam install . --deps-only
dune build
dune install
Verifying Installation
Create a simple test to verify everything works:
(* test_install.ml *)
open Lwt.Syntax
let () = Lwt_main.run (
let* result = Testcontainers.Docker_client.ping () in
if result then
print_endline "Testcontainers OCaml is working!"
else
print_endline "Could not connect to Docker";
Lwt.return_unit
)
Run it:
dune exec ./test_install.exe
Docker Configuration
Unix Socket (Default)
Testcontainers connects to Docker via the Unix socket at /var/run/docker.sock. This is the default on Linux and macOS.
Docker Desktop (macOS/Windows)
Docker Desktop exposes the socket automatically. No additional configuration needed.
Rootless Docker
If using rootless Docker, the socket is typically at:
$XDG_RUNTIME_DIR/docker.sock
Set the DOCKER_HOST environment variable:
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
Remote Docker Host
For remote Docker daemons (not yet supported—coming soon):
export DOCKER_HOST=tcp://remote-host:2375
Troubleshooting
"Cannot connect to Docker daemon"
- Ensure Docker is running:
docker ps - Check socket permissions:
ls -la /var/run/docker.sock - Add your user to the docker group:
sudo usermod -aG docker $USER
"Image pull timeout"
First test run may be slow due to image downloads. Pre-pull images:
docker pull postgres:16-alpine
docker pull redis:7-alpine
docker pull mysql:8
docker pull mongo:7
docker pull rabbitmq:3-management-alpine
"Port already in use"
Testcontainers uses dynamic port allocation. If you see port conflicts, ensure no containers from previous failed test runs are still active:
docker ps -a | grep testcontainers
docker rm -f $(docker ps -aq --filter "label=testcontainers")
Quick Start
This guide walks you through creating your first integration test with Testcontainers OCaml in under 5 minutes.
The Goal
We'll write a test that:
- Starts a Redis container
- Connects and sets a value
- Retrieves and verifies the value
- Automatically cleans up
Step 1: Project Setup
Create a new directory and initialize:
mkdir my_integration_tests
cd my_integration_tests
Create dune-project:
(lang dune 3.0)
(name my_integration_tests)
Create dune:
(executable
(name main)
(libraries
testcontainers
testcontainers-redis
lwt
lwt.unix)
(preprocess (pps lwt_ppx)))
Step 2: Write the Test
Create main.ml:
open Lwt.Syntax
open Testcontainers
let () = Lwt_main.run (
print_endline "Starting Redis container...";
Testcontainers_redis.Redis_container.with_redis (fun container uri ->
Printf.printf "Redis is running at: %s\n" uri;
(* Get connection details *)
let* host = Testcontainers_redis.Redis_container.host container in
let* port = Testcontainers_redis.Redis_container.port container in
Printf.printf "Host: %s, Port: %d\n" host port;
(* In a real test, you would use a Redis client here *)
(* For example with redis-lwt:
let* client = Redis_lwt.connect ~host ~port () in
let* () = Redis_lwt.set client "key" "value" in
let* result = Redis_lwt.get client "key" in
assert (result = Some "value");
*)
print_endline "Test completed successfully!";
Lwt.return_unit
)
)
Step 3: Run It
dune exec ./main.exe
Output:
Starting Redis container...
Redis is running at: redis://127.0.0.1:55432
Host: 127.0.0.1, Port: 55432
Test completed successfully!
The container starts, your code runs, and then the container is automatically removed.
Step 4: Using with Alcotest
For real test suites, integrate with Alcotest:
Create test/dune:
(test
(name test_main)
(libraries
testcontainers
testcontainers-redis
alcotest
alcotest-lwt
lwt
lwt.unix)
(preprocess (pps lwt_ppx)))
Create test/test_main.ml:
open Lwt.Syntax
let test_redis_connection _switch () =
Testcontainers_redis.Redis_container.with_redis (fun _container uri ->
Alcotest.(check bool) "uri starts with redis://" true
(String.length uri > 8 && String.sub uri 0 8 = "redis://");
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "My Integration Tests" [
"redis", [
Alcotest_lwt.test_case "connection" `Slow test_redis_connection;
];
]
)
Run tests:
dune runtest
What Just Happened?
- Container Request:
with_rediscreated a container configuration - Image Pull: Docker pulled
redis:7-alpine(first run only) - Container Start: A new Redis container started
- Wait Strategy: Library waited until Redis was ready to accept connections
- Port Mapping: Docker assigned a random available port
- Your Code: The callback received the container and connection URI
- Cleanup: Container was stopped and removed after the callback completed
Next Steps
- Writing Your First Test - A more detailed walkthrough
- Containers - Understanding container lifecycle
- PostgreSQL Module - Using database containers
Writing Your First Test
This tutorial walks through creating a realistic integration test for a user repository backed by PostgreSQL.
Scenario
You have a User_repository module that stores users in PostgreSQL. You want to test:
- Creating a user
- Retrieving a user by ID
- Listing all users
Project Structure
my_app/
├── dune-project
├── lib/
│ ├── dune
│ └── user_repository.ml
└── test/
├── dune
└── test_user_repository.ml
The Repository Module
lib/user_repository.ml:
(* Simplified example - in practice you'd use Caqti or similar *)
type user = {
id: int;
name: string;
email: string;
}
type t = {
connection_string: string;
}
let create connection_string = { connection_string }
let add_user t ~name ~email =
(* Execute: INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id *)
ignore (t, name, email);
Lwt.return { id = 1; name; email }
let get_user t ~id =
(* Execute: SELECT id, name, email FROM users WHERE id = $1 *)
ignore (t, id);
Lwt.return_some { id; name = "Test"; email = "test@example.com" }
let list_users t =
(* Execute: SELECT id, name, email FROM users *)
ignore t;
Lwt.return []
The Test File
test/test_user_repository.ml:
open Lwt.Syntax
open Testcontainers
(* Test helper: create repository with containerized Postgres *)
let with_repository f =
Testcontainers_postgres.Postgres_container.with_postgres
~config:(fun c -> c
|> Testcontainers_postgres.Postgres_container.with_database "testdb"
|> Testcontainers_postgres.Postgres_container.with_username "testuser"
|> Testcontainers_postgres.Postgres_container.with_password "testpass")
(fun container connection_string ->
(* Run migrations or schema setup *)
let* (_exit_code, _output) = Container.exec container [
"psql"; "-U"; "testuser"; "-d"; "testdb"; "-c";
"CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
);"
] in
(* Create repository and run test *)
let repo = User_repository.create connection_string in
f repo)
(* Test: Adding a user *)
let test_add_user _switch () =
with_repository (fun repo ->
let* user = User_repository.add_user repo
~name:"Alice"
~email:"alice@example.com"
in
Alcotest.(check string) "name" "Alice" user.name;
Alcotest.(check string) "email" "alice@example.com" user.email;
Alcotest.(check bool) "has id" true (user.id > 0);
Lwt.return_unit
)
(* Test: Retrieving a user *)
let test_get_user _switch () =
with_repository (fun repo ->
let* created = User_repository.add_user repo
~name:"Bob"
~email:"bob@example.com"
in
let* retrieved = User_repository.get_user repo ~id:created.id in
match retrieved with
| None -> Alcotest.fail "User not found"
| Some user ->
Alcotest.(check int) "id matches" created.id user.id;
Alcotest.(check string) "name matches" "Bob" user.name;
Lwt.return_unit
)
(* Test: Listing users *)
let test_list_users _switch () =
with_repository (fun repo ->
let* _ = User_repository.add_user repo ~name:"User1" ~email:"u1@test.com" in
let* _ = User_repository.add_user repo ~name:"User2" ~email:"u2@test.com" in
let* users = User_repository.list_users repo in
Alcotest.(check int) "two users" 2 (List.length users);
Lwt.return_unit
)
(* Test runner *)
let () =
Lwt_main.run (
Alcotest_lwt.run "User Repository" [
"users", [
Alcotest_lwt.test_case "add user" `Slow test_add_user;
Alcotest_lwt.test_case "get user" `Slow test_get_user;
Alcotest_lwt.test_case "list users" `Slow test_list_users;
];
]
)
Test dune File
test/dune:
(test
(name test_user_repository)
(libraries
my_app
testcontainers
testcontainers-postgres
alcotest
alcotest-lwt
lwt
lwt.unix)
(preprocess (pps lwt_ppx)))
Running the Tests
dune runtest
Output:
Testing `User Repository'.
[OK] users 0 add user.
[OK] users 1 get user.
[OK] users 2 list users.
Test Successful in 12.5s. 3 tests run.
Key Patterns
1. Wrapper Function
Create a with_repository helper that:
- Starts the container
- Runs migrations/setup
- Creates your module instance
- Passes it to the test
let with_repository f =
Postgres_container.with_postgres ~config:... (fun container conn_str ->
(* setup *)
let repo = create conn_str in
f repo)
2. Test Isolation
Each test gets a fresh container. No shared state between tests:
let test_a _switch () =
with_repository (fun repo -> (* fresh database *))
let test_b _switch () =
with_repository (fun repo -> (* another fresh database *))
3. Schema Setup via Exec
Use Container.exec to run setup commands:
let* (_code, _out) = Container.exec container [
"psql"; "-U"; "user"; "-d"; "db"; "-c"; "CREATE TABLE..."
] in
4. Mark Tests as Slow
Integration tests take time. Mark them appropriately:
Alcotest_lwt.test_case "my test" `Slow test_function
Optimizing Test Speed
Share Container Within Test Suite
If tests don't interfere, share one container:
let container_ref = ref None
let get_container () =
match !container_ref with
| Some c -> Lwt.return c
| None ->
let* c = Postgres_container.start (Postgres_container.create ()) in
container_ref := Some c;
Lwt.return c
Use Alpine Images
Alpine-based images are smaller and start faster:
Postgres_container.with_image "postgres:16-alpine"
Pre-pull Images
Add to CI setup:
docker pull postgres:16-alpine
Next Steps
- Wait Strategies - Customize readiness detection
- File Operations - Copy test fixtures into containers
- Best Practices - Production-ready patterns
Containers
The Container module is the heart of Testcontainers OCaml. It manages the complete lifecycle of Docker containers—from configuration through cleanup.
Container Request
Before starting a container, you build a request describing what you want:
open Testcontainers
let request =
Container_request.create "nginx:alpine"
|> Container_request.with_exposed_port (Port.tcp 80)
|> Container_request.with_env "NGINX_HOST" "localhost"
|> Container_request.with_wait_strategy
(Wait_strategy.for_http ~port:(Port.tcp 80) "/")
Available Configuration
| Function | Description |
|---|---|
create image | Create request with Docker image |
with_exposed_port port | Expose a port |
with_exposed_ports ports | Expose multiple ports |
with_env key value | Set environment variable |
with_envs pairs | Set multiple env vars |
with_cmd args | Override CMD |
with_entrypoint args | Override ENTRYPOINT |
with_working_dir dir | Set working directory |
with_user user | Run as specific user |
with_name name | Set container name |
with_label key value | Add label |
with_labels pairs | Add multiple labels |
with_mount mount | Add volume mount |
with_privileged bool | Run in privileged mode |
with_wait_strategy strategy | Set readiness check |
with_startup_timeout seconds | Max wait time |
with_auto_remove bool | Remove on stop |
Starting Containers
Manual Lifecycle
open Lwt.Syntax
let run_test () =
let request = Container_request.create "redis:7-alpine"
|> Container_request.with_exposed_port (Port.tcp 6379)
in
(* Start container *)
let* container = Container.start request in
(* Use container *)
let* host = Container.host container in
let* port = Container.mapped_port container (Port.tcp 6379) in
Printf.printf "Redis at %s:%d\n" host port;
(* Stop and remove *)
let* () = Container.stop container in
let* () = Container.terminate container in
Lwt.return_unit
Automatic Lifecycle (Recommended)
Use with_container for automatic cleanup:
let run_test () =
let request = Container_request.create "redis:7-alpine"
|> Container_request.with_exposed_port (Port.tcp 6379)
in
Container.with_container request (fun container ->
let* host = Container.host container in
let* port = Container.mapped_port container (Port.tcp 6379) in
Printf.printf "Redis at %s:%d\n" host port;
Lwt.return_unit
)
(* Container automatically stopped and removed here *)
Container Operations
Getting Connection Details
(* Host is always 127.0.0.1 for local Docker *)
let* host = Container.host container in
(* Get mapped port (Docker assigns random available port) *)
let* port = Container.mapped_port container (Port.tcp 5432) in
(* Get all mapped ports *)
let* ports = Container.mapped_ports container in
Executing Commands
Run commands inside the container:
let* (exit_code, output) = Container.exec container ["echo"; "hello"] in
Printf.printf "Exit: %d, Output: %s\n" exit_code output;
(* Run shell commands *)
let* (exit_code, output) = Container.exec container [
"sh"; "-c"; "echo $HOME && whoami"
] in
Accessing Logs
(* Get all logs *)
let* logs = Container.logs container in
print_endline logs;
(* Stream logs with callback *)
let* () = Container.follow_logs
~on_log:(fun chunk -> Printf.printf "%s" chunk; Lwt.return_unit)
container
in
(* Logs are useful for debugging test failures *)
Container Networking Info
(* Get container's IP address *)
let* ip = Container.container_ip container in
Printf.printf "Container IP: %s\n" ip;
(* Get all IPs (when connected to multiple networks) *)
let* ips = Container.container_ips container in
List.iter (fun (network, ip) ->
Printf.printf "Network %s: %s\n" network ip
) ips;
(* Get network aliases *)
let* aliases = Container.network_aliases container in
List.iter (fun (network, alias) ->
Printf.printf "Alias on %s: %s\n" network alias
) aliases;
(* Get gateway address *)
let* gateway = Container.gateway container in
Printf.printf "Gateway: %s\n" gateway;
Full Container Inspection
(* Get complete container info *)
let* info = Container.inspect container in
Printf.printf "Container ID: %s\n" info.id;
Printf.printf "Name: %s\n" info.name;
Printf.printf "Running: %b\n" info.state.running;
Printf.printf "IP Address: %s\n" info.network_settings.ip_address;
Checking State
(* Is container running? *)
let* running = Container.is_running container in
(* Get detailed state *)
let* state = Container.state container in
match state with
| `Running -> print_endline "Running"
| `Exited -> print_endline "Exited"
| `Created -> print_endline "Created but not started"
| `Paused -> print_endline "Paused"
| _ -> print_endline "Other state"
Container ID and Name
let id = Container.id container in (* e.g., "a1b2c3d4e5f6" *)
let name = Container.name container in (* e.g., "/fervent_euler" *)
Port Mapping
Docker maps container ports to random available host ports:
Container Port 5432/tcp → Host Port 54321
Container Port 80/tcp → Host Port 32768
Port Types
(* TCP port (most common) *)
let pg_port = Port.tcp 5432
(* UDP port *)
let dns_port = Port.udp 53
(* Parse from string *)
let port = Port.of_string "8080/tcp"
Exposing Ports
(* Single port *)
Container_request.with_exposed_port (Port.tcp 80) request
(* Multiple ports *)
Container_request.with_exposed_ports [
Port.tcp 80;
Port.tcp 443;
Port.tcp 8080;
] request
Environment Variables
let request =
Container_request.create "postgres:16"
|> Container_request.with_env "POSTGRES_PASSWORD" "secret"
|> Container_request.with_env "POSTGRES_USER" "admin"
|> Container_request.with_env "POSTGRES_DB" "myapp"
(* Or use with_envs for multiple *)
|> Container_request.with_envs [
("POSTGRES_PASSWORD", "secret");
("POSTGRES_USER", "admin");
("POSTGRES_DB", "myapp");
]
Volume Mounts
Bind Mounts
Mount host directory into container:
let mount = Volume.bind
~host:"/path/on/host"
~container:"/path/in/container"
()
let request =
Container_request.create "nginx:alpine"
|> Container_request.with_mount mount
Read-Only Mounts
let mount = Volume.bind
~mode:Volume.ReadOnly
~host:"/configs"
~container:"/etc/nginx/conf.d"
()
Named Volumes
let mount = Volume.volume
~name:"my-data"
~container:"/data"
()
Tmpfs Mounts
In-memory filesystem:
let mount = Volume.tmpfs
~container:"/tmp"
~size:67108864 (* 64MB *)
()
Command and Entrypoint
(* Override CMD *)
let request =
Container_request.create "alpine:latest"
|> Container_request.with_cmd ["sleep"; "3600"]
(* Override ENTRYPOINT *)
let request =
Container_request.create "alpine:latest"
|> Container_request.with_entrypoint ["/bin/sh"; "-c"]
|> Container_request.with_cmd ["echo hello && sleep 3600"]
Labels
Labels help identify and filter containers:
let request =
Container_request.create "nginx:alpine"
|> Container_request.with_label "project" "my-app"
|> Container_request.with_label "environment" "test"
|> Container_request.with_labels [
("version", "1.0");
("team", "backend");
]
Best Practices
Always Use with_container
(* Good: automatic cleanup *)
Container.with_container request (fun c -> ...)
(* Risky: manual cleanup might be skipped on error *)
let* c = Container.start request in
(* if error occurs here, container leaks *)
let* () = Container.terminate c in
Use Specific Image Tags
(* Good: reproducible *)
Container_request.create "postgres:16.1-alpine"
(* Risky: may change unexpectedly *)
Container_request.create "postgres:latest"
Set Reasonable Timeouts
Container_request.with_startup_timeout 120.0 (* 2 minutes *)
Use Alpine Images When Possible
(* Faster to pull, smaller footprint *)
"postgres:16-alpine"
"redis:7-alpine"
"nginx:alpine"
Wait Strategies
Wait strategies ensure your container is fully ready before tests run. A container being "started" doesn't mean the service inside is accepting connections.
The Problem
(* This might fail! *)
let* container = Container.start postgres_request in
let* result = Database.connect connection_string in (* PostgreSQL not ready yet *)
PostgreSQL needs several seconds after container start to initialize and accept connections. Wait strategies solve this.
Available Strategies
Port Strategy
Wait until a port is accepting connections:
let strategy = Wait_strategy.for_listening_port (Port.tcp 5432)
let request =
Container_request.create "postgres:16"
|> Container_request.with_exposed_port (Port.tcp 5432)
|> Container_request.with_wait_strategy strategy
This is the most common strategy. It attempts TCP connections until one succeeds.
Log Strategy
Wait for a specific message in container logs:
(* Wait for exact string *)
let strategy = Wait_strategy.for_log "database system is ready to accept connections"
(* Wait for string to appear N times *)
let strategy = Wait_strategy.for_log ~occurrence:2 "ready for connections"
MySQL logs "ready for connections" twice—once for internal use, once when truly ready. The occurrence parameter handles this.
Log Regex Strategy
Wait for a pattern in logs:
let strategy = Wait_strategy.for_log_regex "listening on.*port 5432"
HTTP Strategy
Wait for an HTTP endpoint to respond:
(* Basic: wait for 200 OK on / *)
let strategy = Wait_strategy.for_http "/"
(* With options *)
let strategy = Wait_strategy.for_http
~port:(Port.tcp 8080)
~status_codes:[200; 201; 204]
"/health"
Useful for web applications with health check endpoints.
Exec Strategy
Wait until a command succeeds (exit code 0):
let strategy = Wait_strategy.for_exec ["pg_isready"; "-U"; "postgres"]
let strategy = Wait_strategy.for_exec [
"sh"; "-c"; "curl -sf http://localhost:8080/health"
]
Health Check Strategy
Wait for Docker's built-in HEALTHCHECK (if defined in image):
let strategy = Wait_strategy.for_health_check ()
No Wait
Skip waiting entirely (use with caution):
let strategy = Wait_strategy.none
Configuring Strategies
Timeout
Maximum time to wait before failing:
let strategy =
Wait_strategy.for_listening_port (Port.tcp 5432)
|> Wait_strategy.with_timeout 120.0 (* 2 minutes *)
Default timeout is 60 seconds.
Poll Interval
How often to check:
let strategy =
Wait_strategy.for_listening_port (Port.tcp 5432)
|> Wait_strategy.with_poll_interval 0.5 (* every 500ms *)
Default is 100ms.
Combining Strategies
All (AND)
Wait for all strategies to succeed:
let strategy = Wait_strategy.all [
Wait_strategy.for_listening_port (Port.tcp 5432);
Wait_strategy.for_log "ready to accept connections";
]
Any (OR)
Wait for any strategy to succeed:
let strategy = Wait_strategy.any [
Wait_strategy.for_http "/health";
Wait_strategy.for_log "Application started";
]
Strategy Selection Guide
| Service | Recommended Strategy |
|---|---|
| PostgreSQL | for_log "ready to accept connections" |
| MySQL | for_log ~occurrence:2 "ready for connections" |
| MongoDB | for_log "Waiting for connections" |
| Redis | for_listening_port (Port.tcp 6379) |
| RabbitMQ | for_log "Started" or HTTP to management port |
| Elasticsearch | for_http "/_cluster/health" |
| Nginx | for_listening_port (Port.tcp 80) |
| Custom App | for_http "/health" or for_log "..." |
Examples
PostgreSQL
let request =
Container_request.create "postgres:16"
|> Container_request.with_exposed_port (Port.tcp 5432)
|> Container_request.with_env "POSTGRES_PASSWORD" "secret"
|> Container_request.with_wait_strategy
(Wait_strategy.for_log "database system is ready to accept connections")
MySQL
let request =
Container_request.create "mysql:8"
|> Container_request.with_exposed_port (Port.tcp 3306)
|> Container_request.with_env "MYSQL_ROOT_PASSWORD" "secret"
|> Container_request.with_wait_strategy
(Wait_strategy.for_log ~occurrence:2 "ready for connections")
Web Application with Health Check
let request =
Container_request.create "my-app:latest"
|> Container_request.with_exposed_port (Port.tcp 8080)
|> Container_request.with_wait_strategy
(Wait_strategy.all [
Wait_strategy.for_listening_port (Port.tcp 8080);
Wait_strategy.for_http ~port:(Port.tcp 8080) "/health";
])
Service Depending on Database
(* First, ensure database is ready via exec *)
let request =
Container_request.create "postgres:16"
|> Container_request.with_exposed_port (Port.tcp 5432)
|> Container_request.with_env "POSTGRES_PASSWORD" "secret"
|> Container_request.with_wait_strategy
(Wait_strategy.for_exec ["pg_isready"; "-U"; "postgres"])
Debugging Wait Failures
When a wait strategy times out:
-
Check container logs:
let* logs = Container.logs container in print_endline logs; -
Increase timeout:
Wait_strategy.with_timeout 180.0 (* 3 minutes *) -
Try a simpler strategy first:
Wait_strategy.for_listening_port (Port.tcp 5432) -
Check if the image works manually:
docker run -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:16
Custom Wait Logic
For complex scenarios, implement custom waiting after container start:
let wait_for_custom_condition container =
let rec loop attempts =
if attempts <= 0 then
Lwt.fail_with "Timeout waiting for condition"
else
let* (exit_code, _) = Container.exec container ["test"; "-f"; "/ready"] in
if exit_code = 0 then
Lwt.return_unit
else begin
let* () = Lwt_unix.sleep 0.5 in
loop (attempts - 1)
end
in
loop 60 (* 30 seconds *)
let run_test () =
Container.with_container request (fun container ->
let* () = wait_for_custom_condition container in
(* Now run tests *)
...
)
Networks
Docker networks enable containers to communicate with each other. This is essential for testing multi-service architectures.
Creating Networks
Basic Network
open Lwt.Syntax
open Testcontainers
let run_test () =
let* network = Network.create "my-test-network" in
Printf.printf "Network ID: %s\n" (Network.id network);
Printf.printf "Network Name: %s\n" (Network.name network);
(* Use the network... *)
let* () = Network.remove network in
Lwt.return_unit
With Driver
let* network = Network.create ~driver:"bridge" "my-network" in
Available drivers:
bridge(default) - Standard isolated networkhost- Use host's network stacknone- No networking
Automatic Cleanup
Use with_network for automatic cleanup:
let run_test () =
Network.with_network "test-network" (fun network ->
Printf.printf "Network created: %s\n" (Network.name network);
(* Network automatically removed after this block *)
Lwt.return_unit
)
Multi-Container Communication
Architecture Example
┌─────────────────────────────────────────────┐
│ test-network │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ webapp │ ──── │ postgres │ │
│ │ Port 8080 │ │ Port 5432 │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────┘
Implementation
open Lwt.Syntax
open Testcontainers
let test_app_with_database () =
Network.with_network "app-network" (fun network ->
(* Start PostgreSQL *)
let pg_request =
Container_request.create "postgres:16-alpine"
|> Container_request.with_exposed_port (Port.tcp 5432)
|> Container_request.with_env "POSTGRES_PASSWORD" "secret"
|> Container_request.with_env "POSTGRES_DB" "appdb"
|> Container_request.with_name "postgres"
|> Container_request.with_wait_strategy
(Wait_strategy.for_log "ready to accept connections")
in
Container.with_container pg_request (fun pg_container ->
(* Start application container that connects to postgres *)
let app_request =
Container_request.create "my-app:latest"
|> Container_request.with_exposed_port (Port.tcp 8080)
|> Container_request.with_env "DATABASE_URL"
"postgresql://postgres:secret@postgres:5432/appdb"
|> Container_request.with_wait_strategy
(Wait_strategy.for_http "/health")
in
Container.with_container app_request (fun app_container ->
(* Test the application *)
let* host = Container.host app_container in
let* port = Container.mapped_port app_container (Port.tcp 8080) in
Printf.printf "App available at http://%s:%d\n" host port;
Lwt.return_unit
)
)
)
Container Names as Hostnames
Within a Docker network, containers can reach each other by name:
(* Container named "postgres" *)
Container_request.with_name "postgres"
(* Another container can connect using hostname "postgres" *)
Container_request.with_env "DB_HOST" "postgres"
Note: Container names must be unique. Use unique names or let Docker generate them.
Network Properties
let* network = Network.create "my-network" in
(* Network ID (Docker's internal ID) *)
let id = Network.id network in (* e.g., "a1b2c3d4e5f6..." *)
(* Network name *)
let name = Network.name network in (* "my-network" *)
Use Cases
Testing Microservices
let test_microservices () =
Network.with_network "microservices" (fun _network ->
(* Service A *)
let* service_a = Container.start (
Container_request.create "service-a:latest"
|> Container_request.with_name "service-a"
|> Container_request.with_exposed_port (Port.tcp 8080)
) in
(* Service B connects to Service A *)
let* service_b = Container.start (
Container_request.create "service-b:latest"
|> Container_request.with_name "service-b"
|> Container_request.with_env "SERVICE_A_URL" "http://service-a:8080"
|> Container_request.with_exposed_port (Port.tcp 8081)
) in
(* Run tests against service-b which uses service-a *)
let* host = Container.host service_b in
let* port = Container.mapped_port service_b (Port.tcp 8081) in
(* Test endpoints... *)
let* () = Container.terminate service_b in
let* () = Container.terminate service_a in
Lwt.return_unit
)
Testing with Multiple Databases
let test_multi_db () =
Network.with_network "multi-db" (fun _network ->
(* PostgreSQL for users *)
let* pg = Container.start (
Container_request.create "postgres:16-alpine"
|> Container_request.with_name "users-db"
|> Container_request.with_env "POSTGRES_PASSWORD" "secret"
) in
(* Redis for cache *)
let* redis = Container.start (
Container_request.create "redis:7-alpine"
|> Container_request.with_name "cache"
) in
(* MongoDB for events *)
let* mongo = Container.start (
Container_request.create "mongo:7"
|> Container_request.with_name "events-db"
) in
(* Your application connects to:
- users-db:5432
- cache:6379
- events-db:27017 *)
(* Cleanup *)
let* () = Container.terminate mongo in
let* () = Container.terminate redis in
let* () = Container.terminate pg in
Lwt.return_unit
)
Best Practices
Use Unique Network Names
let network_name = Printf.sprintf "test-%s" (Uuidm.to_string (Uuidm.v4_gen (Random.State.make_self_init ()) ()))
Network.with_network network_name (fun network -> ...)
Always Clean Up
(* Good: automatic cleanup *)
Network.with_network "test-net" (fun network -> ...)
(* Manual: ensure cleanup happens *)
let* network = Network.create "test-net" in
Lwt.finalize
(fun () -> run_tests network)
(fun () -> Network.remove network)
Use with_container Inside with_network
Network.with_network "net" (fun _network ->
Container.with_container request1 (fun c1 ->
Container.with_container request2 (fun c2 ->
(* Both containers on same network, both cleaned up automatically *)
run_tests c1 c2
)
)
)
Limitations
Current limitations (may be addressed in future versions):
- No network aliases - Containers are reachable by name only
- No custom subnets - Uses Docker's default subnet allocation
- No IPv6 - IPv4 only
- Bridge driver only tested - Other drivers may work but aren't tested
File Operations
Testcontainers OCaml supports copying files to and from containers. This is useful for:
- Providing configuration files
- Loading test fixtures
- Extracting logs or generated files
- Setting up initial database state
Copying Content to Container
String Content
Copy string content directly to a file in the container:
open Lwt.Syntax
open Testcontainers
let setup_config container =
let config_content = {|
server:
port: 8080
host: 0.0.0.0
database:
url: postgres://localhost/test
|} in
Container.copy_content_to container
~content:config_content
~dest:"/app/config.yaml"
The dest parameter is the full path including filename.
Example: Custom nginx Configuration
let run_nginx_test () =
let request =
Container_request.create "nginx:alpine"
|> Container_request.with_exposed_port (Port.tcp 80)
in
Container.with_container request (fun container ->
(* Copy custom nginx config *)
let nginx_conf = {|
server {
listen 80;
location / {
return 200 'Hello from test!';
add_header Content-Type text/plain;
}
}
|} in
let* () = Container.copy_content_to container
~content:nginx_conf
~dest:"/etc/nginx/conf.d/default.conf"
in
(* Reload nginx to pick up new config *)
let* _ = Container.exec container ["nginx"; "-s"; "reload"] in
(* Now test the endpoint *)
Lwt.return_unit
)
Copying Files to Container
Copy a file from the host filesystem:
let setup_test_data container =
Container.copy_file_to container
~src:"/path/to/local/testdata.sql"
~dest:"/docker-entrypoint-initdb.d/"
Example: Database Initialization
let test_with_seed_data () =
let request =
Container_request.create "postgres:16"
|> Container_request.with_exposed_port (Port.tcp 5432)
|> Container_request.with_env "POSTGRES_PASSWORD" "secret"
in
Container.with_container request (fun container ->
(* Copy seed data *)
let* () = Container.copy_file_to container
~src:"./test/fixtures/seed.sql"
~dest:"/tmp/"
in
(* Execute it *)
let* (exit_code, output) = Container.exec container [
"psql"; "-U"; "postgres"; "-f"; "/tmp/seed.sql"
] in
if exit_code <> 0 then
Printf.printf "Seed failed: %s\n" output;
(* Run tests with seeded data *)
Lwt.return_unit
)
Copying Directories to Container
Copy an entire directory from the host to the container:
let copy_test_fixtures container =
Container.copy_dir_to container
~src:"/path/to/local/fixtures"
~dest:"/app"
The directory is copied with its contents. If src is /path/to/fixtures, the contents will appear at /app/fixtures/ in the container.
Example: Multi-file Configuration
let setup_app_config () =
let request =
Container_request.create "my-app:latest"
|> Container_request.with_exposed_port (Port.tcp 8080)
in
Container.with_container request (fun container ->
(* Copy entire config directory *)
let* () = Container.copy_dir_to container
~src:"./config"
~dest:"/app"
in
(* Now /app/config/ contains all files from ./config *)
Lwt.return_unit
)
Copying Files from Container
Extract files from a running container:
let extract_logs container =
Container.copy_file_from container
~src:"/var/log/app.log"
~dest:"/tmp/test-app.log"
Example: Extracting Test Artifacts
let test_with_artifacts () =
Container.with_container request (fun container ->
(* Run some process that generates output *)
let* _ = Container.exec container [
"sh"; "-c"; "my-tool --output /tmp/report.json"
] in
(* Extract the generated report *)
let* () = Container.copy_file_from container
~src:"/tmp/report.json"
~dest:"./test-output/report.json"
in
(* Parse and verify the report *)
Lwt.return_unit
)
Common Patterns
Configuration Injection
let with_configured_app config_json f =
let request =
Container_request.create "my-app:latest"
|> Container_request.with_exposed_port (Port.tcp 8080)
in
Container.with_container request (fun container ->
(* Inject configuration *)
let* () = Container.copy_content_to container
~content:config_json
~dest:"/app/config.json"
in
(* Restart app to pick up config, or use signal *)
let* _ = Container.exec container ["kill"; "-HUP"; "1"] in
let* () = Lwt_unix.sleep 1.0 in
f container
)
Test Fixtures
let copy_fixtures container fixtures_dir =
let files = Sys.readdir fixtures_dir in
Lwt_list.iter_s (fun file ->
let src = Filename.concat fixtures_dir file in
Container.copy_file_to container ~src ~dest:"/fixtures/"
) (Array.to_list files)
Database Schema Setup
let setup_schema container =
let schema = {|
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
title VARCHAR(255) NOT NULL,
body TEXT
);
|} in
let* () = Container.copy_content_to container
~content:schema
~dest:"/tmp/schema.sql"
in
Container.exec container [
"psql"; "-U"; "postgres"; "-d"; "testdb"; "-f"; "/tmp/schema.sql"
]
SSL/TLS Certificates
let setup_tls container =
(* Copy certificate files *)
let* () = Container.copy_file_to container
~src:"./certs/server.crt"
~dest:"/etc/ssl/certs/"
in
let* () = Container.copy_file_to container
~src:"./certs/server.key"
~dest:"/etc/ssl/private/"
in
Lwt.return_unit
Destination Paths
File to Directory
If dest ends with /, the file keeps its original name:
(* Source: /local/myfile.txt *)
(* Dest: /container/path/myfile.txt *)
Container.copy_file_to container
~src:"/local/myfile.txt"
~dest:"/container/path/"
File to File (Rename)
If dest doesn't end with /, it's used as the full path:
(* Source: /local/myfile.txt *)
(* Dest: /container/path/renamed.txt *)
Container.copy_file_to container
~src:"/local/myfile.txt"
~dest:"/container/path/renamed.txt"
Content to File
For copy_content_to, dest must be the full file path:
Container.copy_content_to container
~content:"hello"
~dest:"/path/to/file.txt" (* Full path required *)
Error Handling
File operations can fail for various reasons:
let safe_copy container =
Lwt.catch
(fun () ->
Container.copy_content_to container ~content:"test" ~dest:"/app/config"
)
(fun exn ->
Printf.printf "Copy failed: %s\n" (Printexc.to_string exn);
(* Maybe the directory doesn't exist, create it first *)
let* _ = Container.exec container ["mkdir"; "-p"; "/app"] in
Container.copy_content_to container ~content:"test" ~dest:"/app/config"
)
Platform Notes
macOS
File operations work seamlessly with Docker Desktop on macOS. Extended attributes (like com.apple.provenance) are automatically handled.
Linux
No special considerations. File operations use Docker's archive API directly.
Windows
WSL2 backend recommended. File paths should use forward slashes in the container.
Error Handling
Testcontainers OCaml uses a combination of Lwt-based error handling and a custom exception type for container-related errors.
Error Types
All Testcontainers errors are wrapped in the Testcontainers_error exception:
exception Testcontainers_error of Error.t
The Error.t type covers all possible failure scenarios:
type t =
| Container_not_found of string
| Container_not_running of string
| Container_start_failed of { id : string; message : string }
| Container_stop_failed of { id : string; message : string }
| Wait_timeout of { strategy : string; timeout : float }
| Docker_error of { status : int; message : string }
| Docker_connection_failed of string
| Invalid_configuration of string
| Image_pull_failed of { image : string; message : string }
| Port_not_mapped of { container_port : int; protocol : string }
Converting Errors to Strings
let handle_error err =
let message = Error.to_string err in
Printf.printf "Error: %s\n" message
Example outputs:
Container not found: abc123def456
Failed to start container abc123: port already allocated
Wait strategy 'port:5432/tcp' timed out after 60.0s
Docker API error (status 404): No such image: invalid:latest
Port 8080/tcp not mapped
Catching Errors
Basic Pattern
open Lwt.Syntax
let run_test () =
Lwt.catch
(fun () ->
Container.with_container request (fun container ->
(* test code *)
Lwt.return_unit
))
(function
| Error.Testcontainers_error err ->
Printf.printf "Testcontainers error: %s\n" (Error.to_string err);
Lwt.return_unit
| exn ->
Printf.printf "Other error: %s\n" (Printexc.to_string exn);
Lwt.return_unit)
Handling Specific Errors
let run_with_retry () =
Lwt.catch
(fun () -> Container.start request)
(function
| Error.Testcontainers_error (Error.Image_pull_failed { image; message }) ->
Printf.printf "Failed to pull %s: %s\n" image message;
Printf.printf "Trying with local image...\n";
let local_request = Container_request.with_image "local:latest" request in
Container.start local_request
| Error.Testcontainers_error (Error.Wait_timeout { strategy; timeout }) ->
Printf.printf "Wait strategy '%s' timed out after %.1fs\n" strategy timeout;
Lwt.fail_with "Container not ready"
| Error.Testcontainers_error (Error.Docker_connection_failed msg) ->
Printf.printf "Cannot connect to Docker: %s\n" msg;
Printf.printf "Is Docker running?\n";
Lwt.fail_with "Docker not available"
| exn ->
Lwt.fail exn)
Common Error Scenarios
Docker Not Running
(* Error: Docker_connection_failed "Connection refused" *)
(* Solution: Start Docker *)
(* docker info *)
Image Not Found
(* Error: Image_pull_failed { image = "invalid:tag"; message = "not found" } *)
(* Solution: Check image name/tag *)
let request = Container_request.create "postgres:16" (* valid tag *)
Port Not Exposed
(* Error: Port_not_mapped { container_port = 5432; protocol = "tcp" } *)
(* Solution: Expose the port *)
let request =
Container_request.create "postgres:16"
|> Container_request.with_exposed_port (Port.tcp 5432) (* add this *)
Wait Strategy Timeout
(* Error: Wait_timeout { strategy = "log:ready"; timeout = 60.0 } *)
(* Solutions: *)
(* 1. Increase timeout *)
Wait_strategy.with_timeout 120.0 strategy
(* 2. Use correct wait condition *)
Wait_strategy.for_log "database system is ready" (* exact message *)
(* 3. Check container logs for actual startup message *)
let* logs = Container.logs container in
print_endline logs
Container Start Failed
(* Error: Container_start_failed { id = "abc"; message = "port already allocated" } *)
(* Solutions: *)
(* 1. Remove conflicting container *)
(* docker ps -a *)
(* docker rm -f <container_id> *)
(* 2. Use random port mapping (default behavior) *)
Container_request.with_exposed_port (Port.tcp 5432) (* Docker assigns random host port *)
Ensuring Cleanup on Error
with_container ensures cleanup even on errors:
let test_that_fails () =
Container.with_container request (fun container ->
(* This error won't leak the container *)
failwith "Test failed"
)
(* Container is still cleaned up *)
For manual lifecycle management, use Lwt.finalize:
let test_with_manual_cleanup () =
let* container = Container.start request in
Lwt.finalize
(fun () ->
(* Test code that might fail *)
do_something container)
(fun () ->
(* Always runs, even on error *)
Container.terminate container)
Debugging Tips
Enable Verbose Logging
let debug_container container =
let* logs = Container.logs container in
Printf.printf "=== Container Logs ===\n%s\n" logs;
let* state = Container.state container in
Printf.printf "State: %s\n" (match state with
| `Running -> "running"
| `Exited -> "exited"
| _ -> "other");
Lwt.return_unit
Check Docker Directly
# List containers
docker ps -a
# Check specific container
docker logs <container_id>
docker inspect <container_id>
# Check Docker events
docker events --since 1h
Inspect Failed Container
Don't terminate failed containers immediately—inspect them first:
let debug_on_failure () =
let* container = Container.start request in
Lwt.catch
(fun () ->
run_tests container)
(fun exn ->
(* Debug before cleanup *)
let* logs = Container.logs container in
Printf.printf "Container logs:\n%s\n" logs;
let id = Container.id container in
Printf.printf "Container ID: %s\n" id;
Printf.printf "Inspect with: docker inspect %s\n" id;
(* Optionally, don't terminate to allow manual inspection *)
(* let* () = Container.terminate container in *)
Lwt.fail exn)
Testing Error Handling
let test_handles_docker_errors _switch () =
(* Test with invalid image *)
let request = Container_request.create "this-image-does-not-exist:never" in
Lwt.catch
(fun () ->
let* _container = Container.start request in
Alcotest.fail "Should have failed")
(function
| Error.Testcontainers_error (Error.Image_pull_failed _) ->
Lwt.return_unit (* Expected *)
| exn ->
Alcotest.fail (Printf.sprintf "Wrong error: %s" (Printexc.to_string exn)))
PostgreSQL
The PostgreSQL module provides a pre-configured container for integration testing with PostgreSQL databases.
Quick Start
open Lwt.Syntax
open Testcontainers_postgres
let test_postgres () =
Postgres_container.with_postgres (fun container connection_string ->
Printf.printf "PostgreSQL running at: %s\n" connection_string;
(* Use connection_string with your database library *)
Lwt.return_unit
)
Installation
opam install testcontainers-postgres
In your dune file:
(libraries testcontainers-postgres)
Configuration
Basic Configuration
Postgres_container.with_postgres
~config:(fun c -> c
|> Postgres_container.with_database "myapp"
|> Postgres_container.with_username "appuser"
|> Postgres_container.with_password "secret123")
(fun container conn_str ->
(* conn_str: postgresql://appuser:secret123@127.0.0.1:54321/myapp *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | postgres:16-alpine | Docker image |
with_database | test | Database name |
with_username | test | Username |
with_password | test | Password |
Custom Image
Postgres_container.with_postgres
~config:(fun c -> c
|> Postgres_container.with_image "postgres:15"
|> Postgres_container.with_database "legacy_db")
(fun container conn_str -> ...)
Connection Details
Connection String
The module provides a ready-to-use connection string:
postgresql://username:password@host:port/database
Individual Components
Postgres_container.with_postgres (fun container conn_str ->
let* host = Postgres_container.host container in (* "127.0.0.1" *)
let* port = Postgres_container.port container in (* 54321 *)
let config = Postgres_container.create () in
let database = Postgres_container.database config in (* "test" *)
let username = Postgres_container.username config in (* "test" *)
...
)
JDBC URL
For Java-compatible tools:
let* jdbc_url = Postgres_container.jdbc_url config container in
(* jdbc:postgresql://127.0.0.1:54321/test *)
Manual Lifecycle
For more control, manage the container manually:
let run_tests () =
let config =
Postgres_container.create ()
|> Postgres_container.with_database "testdb"
|> Postgres_container.with_username "admin"
|> Postgres_container.with_password "secret"
in
let* container = Postgres_container.start config in
(* Get connection details *)
let* conn_str = Postgres_container.connection_string config container in
(* Run tests... *)
(* Cleanup *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Integration with Database Libraries
With Caqti
open Lwt.Syntax
open Caqti_lwt
let test_with_caqti () =
Postgres_container.with_postgres
~config:(fun c -> c
|> Postgres_container.with_database "test"
|> Postgres_container.with_username "test"
|> Postgres_container.with_password "test")
(fun _container conn_str ->
(* Connect using Caqti *)
let uri = Uri.of_string conn_str in
let* connection = Caqti_lwt.connect uri in
match connection with
| Ok (module Db : Caqti_lwt.CONNECTION) ->
(* Run queries *)
let query = Caqti_request.exec Caqti_type.unit
"CREATE TABLE test (id SERIAL PRIMARY KEY)" in
let* result = Db.exec query () in
(match result with
| Ok () -> print_endline "Table created"
| Error e -> print_endline (Caqti_error.show e));
Lwt.return_unit
| Error e ->
Printf.printf "Connection failed: %s\n" (Caqti_error.show e);
Lwt.return_unit
)
With PGX
open Lwt.Syntax
let test_with_pgx () =
Postgres_container.with_postgres (fun container _conn_str ->
let* host = Postgres_container.host container in
let* port = Postgres_container.port container in
let* connection = Pgx_lwt.connect
~host
~port
~user:"test"
~password:"test"
~database:"test"
()
in
let* result = Pgx_lwt.execute connection "SELECT 1 as value" in
Printf.printf "Result: %s\n" (Pgx.Value.to_string (List.hd (List.hd result)));
let* () = Pgx_lwt.close connection in
Lwt.return_unit
)
Schema Setup
Using Container Exec
let setup_schema container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"psql"; "-U"; "test"; "-d"; "test"; "-c";
{|
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
title VARCHAR(255) NOT NULL,
content TEXT,
published_at TIMESTAMP
);
CREATE INDEX idx_posts_user_id ON posts(user_id);
|}
] in
if exit_code <> 0 then
Printf.printf "Schema setup failed: %s\n" output;
Lwt.return_unit
Using File Copy
let setup_from_file container =
let* () = Testcontainers.Container.copy_file_to container
~src:"./migrations/schema.sql"
~dest:"/tmp/"
in
let* (exit_code, _) = Testcontainers.Container.exec container [
"psql"; "-U"; "test"; "-d"; "test"; "-f"; "/tmp/schema.sql"
] in
Lwt.return (exit_code = 0)
Seeding Test Data
let seed_data container =
let* (_, _) = Testcontainers.Container.exec container [
"psql"; "-U"; "test"; "-d"; "test"; "-c";
{|
INSERT INTO users (email, name) VALUES
('alice@example.com', 'Alice'),
('bob@example.com', 'Bob'),
('charlie@example.com', 'Charlie');
INSERT INTO posts (user_id, title, content) VALUES
(1, 'First Post', 'Hello World'),
(1, 'Second Post', 'More content'),
(2, 'Bob''s Post', 'Bob writes');
|}
] in
Lwt.return_unit
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_postgres
module UserRepo = struct
type t = string (* connection string *)
let create conn_str = conn_str
let add_user t ~email ~name =
(* In real code, use Caqti or similar *)
ignore (t, email, name);
Lwt.return 1
let get_user t ~id =
ignore (t, id);
Lwt.return (Some ("test@example.com", "Test"))
end
let with_test_db f =
Postgres_container.with_postgres
~config:(fun c -> c
|> Postgres_container.with_database "testdb"
|> Postgres_container.with_username "testuser"
|> Postgres_container.with_password "testpass")
(fun container conn_str ->
(* Setup schema *)
let* _ = Container.exec container [
"psql"; "-U"; "testuser"; "-d"; "testdb"; "-c";
"CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT, name TEXT)"
] in
let repo = UserRepo.create conn_str in
f repo
)
let test_add_user _switch () =
with_test_db (fun repo ->
let* id = UserRepo.add_user repo ~email:"test@example.com" ~name:"Test" in
Alcotest.(check bool) "id > 0" true (id > 0);
Lwt.return_unit
)
let test_get_user _switch () =
with_test_db (fun repo ->
let* _ = UserRepo.add_user repo ~email:"test@example.com" ~name:"Test" in
let* user = UserRepo.get_user repo ~id:1 in
Alcotest.(check bool) "user found" true (Option.is_some user);
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "User Repository" [
"users", [
Alcotest_lwt.test_case "add user" `Slow test_add_user;
Alcotest_lwt.test_case "get user" `Slow test_get_user;
];
]
)
Wait Strategy
The PostgreSQL module uses a log-based wait strategy by default:
database system is ready to accept connections
This ensures PostgreSQL is fully initialized before tests run.
Troubleshooting
Connection Refused
If you get connection errors immediately after container starts:
- Ensure you're using
with_postgres(handles waiting automatically) - Check the wait strategy completed successfully
- Verify the port mapping:
Container.mapped_port container (Port.tcp 5432)
Authentication Failed
Check your configuration matches:
(* These must match *)
Postgres_container.with_username "myuser"
Postgres_container.with_password "mypass"
(* Connection: postgresql://myuser:mypass@... *)
Slow Startup
PostgreSQL can take 5-15 seconds to start. If tests timeout:
Container_request.with_startup_timeout 60.0
Database Does Not Exist
Ensure the database name in your connection matches:
Postgres_container.with_database "mydb"
(* Creates database "mydb" automatically *)
MySQL
The MySQL module provides a pre-configured container for integration testing with MySQL databases.
Quick Start
open Lwt.Syntax
open Testcontainers_mysql
let test_mysql () =
Mysql_container.with_mysql (fun container connection_string ->
Printf.printf "MySQL running at: %s\n" connection_string;
Lwt.return_unit
)
Installation
opam install testcontainers-mysql
In your dune file:
(libraries testcontainers-mysql)
Configuration
Basic Configuration
Mysql_container.with_mysql
~config:(fun c -> c
|> Mysql_container.with_database "myapp"
|> Mysql_container.with_username "appuser"
|> Mysql_container.with_password "secret123"
|> Mysql_container.with_root_password "rootsecret")
(fun container conn_str ->
(* conn_str: mysql://appuser:secret123@127.0.0.1:33061/myapp *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | mysql:8 | Docker image |
with_database | test | Database name |
with_username | test | Username |
with_password | test | Password |
with_root_password | root | Root password |
Custom Image
Mysql_container.with_mysql
~config:(fun c -> c
|> Mysql_container.with_image "mysql:5.7"
|> Mysql_container.with_database "legacy_db")
(fun container conn_str -> ...)
Connection Details
Connection String
mysql://username:password@host:port/database
JDBC URL
let* jdbc_url = Mysql_container.jdbc_url config container in
(* jdbc:mysql://127.0.0.1:33061/test *)
Individual Components
Mysql_container.with_mysql (fun container conn_str ->
let* host = Mysql_container.host container in
let* port = Mysql_container.port config container in
let database = Mysql_container.database config in
let username = Mysql_container.username config in
...
)
Manual Lifecycle
let run_tests () =
let config =
Mysql_container.create ()
|> Mysql_container.with_database "testdb"
|> Mysql_container.with_username "admin"
|> Mysql_container.with_password "secret"
|> Mysql_container.with_root_password "rootpass"
in
let* container = Mysql_container.start config in
let* conn_str = Mysql_container.connection_string config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Schema Setup
Using mysql Client
let setup_schema container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"mysql"; "-u"; "test"; "-ptest"; "test"; "-e";
{|
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
total DECIMAL(10,2),
status ENUM('pending', 'completed', 'cancelled'),
FOREIGN KEY (user_id) REFERENCES users(id)
);
|}
] in
if exit_code <> 0 then
Printf.printf "Schema setup failed: %s\n" output;
Lwt.return_unit
Using Root User
For administrative operations:
let create_additional_database container db_name =
let* (exit_code, _) = Testcontainers.Container.exec container [
"mysql"; "-u"; "root"; "-proot"; "-e";
Printf.sprintf "CREATE DATABASE %s;" db_name
] in
Lwt.return (exit_code = 0)
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_mysql
let with_test_db f =
Mysql_container.with_mysql
~config:(fun c -> c
|> Mysql_container.with_database "shop"
|> Mysql_container.with_username "shopuser"
|> Mysql_container.with_password "shoppass"
|> Mysql_container.with_root_password "rootpass")
(fun container conn_str ->
(* Setup schema *)
let* _ = Container.exec container [
"mysql"; "-u"; "shopuser"; "-pshoppass"; "shop"; "-e";
{|
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(10,2) NOT NULL
);
|}
] in
f conn_str
)
let test_products _switch () =
with_test_db (fun conn_str ->
Printf.printf "Connected to: %s\n" conn_str;
(* Your test logic here *)
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "MySQL Tests" [
"products", [
Alcotest_lwt.test_case "basic" `Slow test_products;
];
]
)
Wait Strategy
MySQL logs "ready for connections" twice during startup:
- First for the temporary server during initialization
- Second when actually ready
The module waits for the second occurrence:
Wait_strategy.for_log ~occurrence:2 "ready for connections"
MySQL vs MariaDB
For MariaDB, use the same module with a different image:
Mysql_container.with_mysql
~config:(fun c -> c
|> Mysql_container.with_image "mariadb:11")
(fun container conn_str -> ...)
Troubleshooting
Access Denied
Ensure password is set correctly:
Mysql_container.with_password "mypassword"
(* Connection must use same password *)
Unknown Database
The database is created automatically. Ensure names match:
Mysql_container.with_database "mydb"
(* Use "mydb" in connection string *)
Slow Startup
MySQL 8 can take 15-30 seconds to initialize:
Container_request.with_startup_timeout 90.0
Character Set Issues
For UTF-8 support, configure the server:
let request =
Container_request.create "mysql:8"
|> Container_request.with_cmd [
"--character-set-server=utf8mb4";
"--collation-server=utf8mb4_unicode_ci"
]
MongoDB
The MongoDB module provides a pre-configured container for integration testing with MongoDB.
Quick Start
open Lwt.Syntax
open Testcontainers_mongo
let test_mongo () =
Mongo_container.with_mongo (fun container connection_string ->
Printf.printf "MongoDB running at: %s\n" connection_string;
Lwt.return_unit
)
Installation
opam install testcontainers-mongo
In your dune file:
(libraries testcontainers-mongo)
Configuration
Without Authentication (Default)
(* Simple setup - no auth required *)
Mongo_container.with_mongo (fun container conn_str ->
(* conn_str: mongodb://127.0.0.1:27017 *)
...
)
With Authentication
Mongo_container.with_mongo
~config:(fun c -> c
|> Mongo_container.with_username "admin"
|> Mongo_container.with_password "secret123")
(fun container conn_str ->
(* conn_str: mongodb://admin:secret123@127.0.0.1:27017 *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | mongo:7 | Docker image |
with_username | None | Admin username (optional) |
with_password | None | Admin password (optional) |
Custom Image
Mongo_container.with_mongo
~config:(fun c -> c
|> Mongo_container.with_image "mongo:6")
(fun container conn_str -> ...)
Connection Details
Connection String
Without auth:
mongodb://127.0.0.1:27017
With auth:
mongodb://username:password@127.0.0.1:27017
Individual Components
Mongo_container.with_mongo (fun container conn_str ->
let* host = Mongo_container.host container in
let* port = Mongo_container.port config container in
let username = Mongo_container.username config in
let password = Mongo_container.password config in
...
)
Manual Lifecycle
let run_tests () =
let config =
Mongo_container.create ()
|> Mongo_container.with_username "admin"
|> Mongo_container.with_password "secret"
in
let* container = Mongo_container.start config in
let* conn_str = Mongo_container.connection_string config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Using with MongoDB Libraries
With mongo (OCaml MongoDB driver)
open Lwt.Syntax
let test_with_mongo_driver () =
Mongo_container.with_mongo (fun container conn_str ->
(* Parse connection string or use components *)
let* host = Mongo_container.host container in
let* port = Mongo_container.port (Mongo_container.create ()) container in
(* Connect using your MongoDB library *)
(* Example with a hypothetical OCaml MongoDB client:
let* client = Mongo.connect ~host ~port () in
let db = Mongo.database client "testdb" in
let collection = Mongo.collection db "users" in
...
*)
Printf.printf "Connected to MongoDB at %s:%d\n" host port;
Lwt.return_unit
)
Data Setup Using mongosh
Create Collections and Indexes
let setup_database container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"mongosh"; "--eval"; {|
use testdb;
db.createCollection("users");
db.users.createIndex({ email: 1 }, { unique: true });
db.createCollection("posts");
db.posts.createIndex({ userId: 1 });
db.posts.createIndex({ createdAt: -1 });
|}
] in
if exit_code <> 0 then
Printf.printf "Setup failed: %s\n" output;
Lwt.return_unit
Insert Test Data
let seed_data container =
let* (_, _) = Testcontainers.Container.exec container [
"mongosh"; "--eval"; {|
use testdb;
db.users.insertMany([
{ email: "alice@example.com", name: "Alice", age: 30 },
{ email: "bob@example.com", name: "Bob", age: 25 },
{ email: "charlie@example.com", name: "Charlie", age: 35 }
]);
db.posts.insertMany([
{ userId: "alice", title: "First Post", content: "Hello!" },
{ userId: "bob", title: "Bob's Post", content: "Hi there!" }
]);
|}
] in
Lwt.return_unit
Verify Data
let verify_data container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"mongosh"; "--quiet"; "--eval"; {|
use testdb;
JSON.stringify(db.users.find().toArray());
|}
] in
Printf.printf "Users: %s\n" output;
Lwt.return (exit_code = 0)
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_mongo
let with_test_mongo f =
Mongo_container.with_mongo
~config:(fun c -> c
|> Mongo_container.with_username "testadmin"
|> Mongo_container.with_password "testpass")
(fun container conn_str ->
(* Setup initial data *)
let* _ = Container.exec container [
"mongosh"; "-u"; "testadmin"; "-p"; "testpass";
"--authenticationDatabase"; "admin"; "--eval";
{|
use appdb;
db.items.insertOne({ name: "Test Item", price: 9.99 });
|}
] in
f container conn_str
)
let test_find_items _switch () =
with_test_mongo (fun container _conn_str ->
let* (exit_code, output) = Container.exec container [
"mongosh"; "-u"; "testadmin"; "-p"; "testpass";
"--authenticationDatabase"; "admin"; "--quiet"; "--eval";
{| use appdb; db.items.countDocuments(); |}
] in
Alcotest.(check int) "exit code" 0 exit_code;
Alcotest.(check bool) "has output" true (String.length output > 0);
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "MongoDB Tests" [
"items", [
Alcotest_lwt.test_case "find items" `Slow test_find_items;
];
]
)
Wait Strategy
The MongoDB module waits for:
Waiting for connections
This indicates MongoDB is ready to accept client connections.
Replica Sets
For testing replica set features:
let setup_replica_set () =
let request =
Container_request.create "mongo:7"
|> Container_request.with_cmd ["--replSet"; "rs0"]
|> Container_request.with_exposed_port (Port.tcp 27017)
in
Container.with_container request (fun container ->
(* Initialize replica set *)
let* _ = Container.exec container [
"mongosh"; "--eval"; "rs.initiate()"
] in
(* Wait for replica set to be ready *)
let* () = Lwt_unix.sleep 5.0 in
(* Now use the replica set *)
Lwt.return_unit
)
Troubleshooting
Authentication Failed
When using authentication:
(* Both username and password must be set *)
Mongo_container.with_username "admin"
|> Mongo_container.with_password "secret"
(* Connection string must include credentials *)
(* mongodb://admin:secret@127.0.0.1:27017 *)
Connection Refused
Ensure the container is fully started:
(* Use with_mongo which handles waiting *)
Mongo_container.with_mongo (fun container conn_str ->
(* Safe to connect here *)
...
)
mongosh Not Found
Older MongoDB images use mongo instead of mongosh:
(* For MongoDB < 6.0 *)
Container.exec container ["mongo"; "--eval"; "..."]
(* For MongoDB >= 6.0 *)
Container.exec container ["mongosh"; "--eval"; "..."]
Slow Startup
MongoDB typically starts in 5-10 seconds, but can be slower:
Container_request.with_startup_timeout 60.0
Redis
The Redis module provides a pre-configured container for integration testing with Redis.
Quick Start
open Lwt.Syntax
open Testcontainers_redis
let test_redis () =
Redis_container.with_redis (fun container uri ->
Printf.printf "Redis running at: %s\n" uri;
Lwt.return_unit
)
Installation
opam install testcontainers-redis
In your dune file:
(libraries testcontainers-redis)
Configuration
Basic Usage
Redis_container.with_redis (fun container uri ->
(* uri: redis://127.0.0.1:6379 *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | redis:7-alpine | Docker image |
Custom Image
Redis_container.with_redis
~config:(fun c -> c
|> Redis_container.with_image "redis:6-alpine")
(fun container uri -> ...)
Connection Details
Redis URI
redis://127.0.0.1:6379
Individual Components
Redis_container.with_redis (fun container uri ->
let* host = Redis_container.host container in (* "127.0.0.1" *)
let* port = Redis_container.port container in (* 6379 *)
...
)
Manual Lifecycle
let run_tests () =
let config = Redis_container.create () in
let* container = Redis_container.start config in
let* uri = Redis_container.uri config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Using with Redis Libraries
With redis-lwt
open Lwt.Syntax
let test_with_redis_lwt () =
Redis_container.with_redis (fun container _uri ->
let* host = Redis_container.host container in
let* port = Redis_container.port container in
(* Connect using redis-lwt *)
(* let* conn = Redis_lwt.connect ~host ~port () in *)
(* Example operations:
let* () = Redis_lwt.set conn "key" "value" in
let* result = Redis_lwt.get conn "key" in
assert (result = Some "value");
*)
Printf.printf "Connected to Redis at %s:%d\n" host port;
Lwt.return_unit
)
Data Operations via CLI
Set and Get Values
let test_basic_operations container =
(* SET operation *)
let* (exit_code, _) = Testcontainers.Container.exec container [
"redis-cli"; "SET"; "mykey"; "myvalue"
] in
assert (exit_code = 0);
(* GET operation *)
let* (exit_code, output) = Testcontainers.Container.exec container [
"redis-cli"; "GET"; "mykey"
] in
assert (exit_code = 0);
Printf.printf "Value: %s\n" (String.trim output);
Lwt.return_unit
Working with Data Structures
let test_data_structures container =
(* List operations *)
let* _ = Testcontainers.Container.exec container [
"redis-cli"; "RPUSH"; "mylist"; "a"; "b"; "c"
] in
let* (_, output) = Testcontainers.Container.exec container [
"redis-cli"; "LRANGE"; "mylist"; "0"; "-1"
] in
Printf.printf "List: %s\n" output;
(* Hash operations *)
let* _ = Testcontainers.Container.exec container [
"redis-cli"; "HSET"; "user:1"; "name"; "Alice"; "email"; "alice@example.com"
] in
let* (_, output) = Testcontainers.Container.exec container [
"redis-cli"; "HGETALL"; "user:1"
] in
Printf.printf "Hash: %s\n" output;
(* Set operations *)
let* _ = Testcontainers.Container.exec container [
"redis-cli"; "SADD"; "tags"; "ocaml"; "redis"; "testing"
] in
let* (_, output) = Testcontainers.Container.exec container [
"redis-cli"; "SMEMBERS"; "tags"
] in
Printf.printf "Set: %s\n" output;
Lwt.return_unit
Running Lua Scripts
let test_lua_script container =
let script = {|
local key = KEYS[1]
local increment = ARGV[1]
local current = redis.call('GET', key) or 0
local new_value = tonumber(current) + tonumber(increment)
redis.call('SET', key, new_value)
return new_value
|} in
let* (exit_code, output) = Testcontainers.Container.exec container [
"redis-cli"; "EVAL"; script; "1"; "counter"; "5"
] in
Printf.printf "New counter value: %s\n" (String.trim output);
Lwt.return (exit_code = 0)
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_redis
let test_cache_operations _switch () =
Redis_container.with_redis (fun container _uri ->
(* Test SET/GET *)
let* (code, _) = Container.exec container [
"redis-cli"; "SET"; "session:123"; "user_data_here"
] in
Alcotest.(check int) "SET succeeds" 0 code;
let* (code, output) = Container.exec container [
"redis-cli"; "GET"; "session:123"
] in
Alcotest.(check int) "GET succeeds" 0 code;
Alcotest.(check string) "value matches" "user_data_here" (String.trim output);
(* Test TTL *)
let* (code, _) = Container.exec container [
"redis-cli"; "SETEX"; "temp:key"; "60"; "temporary"
] in
Alcotest.(check int) "SETEX succeeds" 0 code;
let* (code, output) = Container.exec container [
"redis-cli"; "TTL"; "temp:key"
] in
Alcotest.(check int) "TTL succeeds" 0 code;
let ttl = int_of_string (String.trim output) in
Alcotest.(check bool) "TTL > 0" true (ttl > 0);
Lwt.return_unit
)
let test_pub_sub _switch () =
Redis_container.with_redis (fun container _uri ->
(* Publish a message (subscriber would need separate connection) *)
let* (code, output) = Container.exec container [
"redis-cli"; "PUBLISH"; "notifications"; "Hello subscribers!"
] in
Alcotest.(check int) "PUBLISH succeeds" 0 code;
Printf.printf "Subscribers received: %s\n" (String.trim output);
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "Redis Tests" [
"cache", [
Alcotest_lwt.test_case "operations" `Slow test_cache_operations;
];
"pubsub", [
Alcotest_lwt.test_case "publish" `Slow test_pub_sub;
];
]
)
Wait Strategy
Redis starts very quickly. The module uses a port-based wait strategy:
Wait_strategy.for_listening_port (Port.tcp 6379)
Redis Configuration
Custom Redis Configuration
let with_custom_config () =
let request =
Container_request.create "redis:7-alpine"
|> Container_request.with_exposed_port (Port.tcp 6379)
|> Container_request.with_cmd [
"redis-server";
"--maxmemory"; "100mb";
"--maxmemory-policy"; "allkeys-lru"
]
in
Container.with_container request (fun container ->
(* Redis with memory limits *)
Lwt.return_unit
)
Persistence Disabled
For faster tests:
Container_request.with_cmd [
"redis-server";
"--save"; "";
"--appendonly"; "no"
]
Troubleshooting
Connection Refused
Redis starts quickly, but ensure you're using with_redis:
(* Good *)
Redis_container.with_redis (fun container uri -> ...)
(* May fail if Redis isn't ready *)
let* container = Container.start request in
(* immediate connection attempt *)
Memory Issues
For large datasets in tests:
Container_request.with_cmd [
"redis-server";
"--maxmemory"; "256mb"
]
Slow Tests
Redis operations are fast. If tests are slow, check:
- Network latency (shouldn't be an issue with local Docker)
- Large data volumes
- Expensive Lua scripts
RabbitMQ
The RabbitMQ module provides a pre-configured container for integration testing with RabbitMQ message broker.
Quick Start
open Lwt.Syntax
open Testcontainers_rabbitmq
let test_rabbitmq () =
Rabbitmq_container.with_rabbitmq (fun container amqp_url ->
Printf.printf "RabbitMQ running at: %s\n" amqp_url;
Lwt.return_unit
)
Installation
opam install testcontainers-rabbitmq
In your dune file:
(libraries testcontainers-rabbitmq)
Configuration
Basic Configuration
Rabbitmq_container.with_rabbitmq
~config:(fun c -> c
|> Rabbitmq_container.with_username "myuser"
|> Rabbitmq_container.with_password "mypass"
|> Rabbitmq_container.with_vhost "/myapp")
(fun container amqp_url ->
(* amqp_url: amqp://myuser:mypass@127.0.0.1:5672/myapp *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | rabbitmq:3-management-alpine | Docker image |
with_username | guest | Username |
with_password | guest | Password |
with_vhost | / | Virtual host |
Custom Image
Rabbitmq_container.with_rabbitmq
~config:(fun c -> c
|> Rabbitmq_container.with_image "rabbitmq:3.12-alpine")
(fun container amqp_url -> ...)
Connection Details
AMQP URL
amqp://username:password@host:port/vhost
Individual Components
Rabbitmq_container.with_rabbitmq (fun container amqp_url ->
let* host = Rabbitmq_container.host container in
let* amqp_port = Rabbitmq_container.amqp_port container in
let* mgmt_port = Rabbitmq_container.management_port container in
let config = Rabbitmq_container.create () in
let username = Rabbitmq_container.username config in
let password = Rabbitmq_container.password config in
let vhost = Rabbitmq_container.vhost config in
...
)
Management UI
The default image includes the management plugin:
let* mgmt_port = Rabbitmq_container.management_port container in
Printf.printf "Management UI: http://127.0.0.1:%d\n" mgmt_port;
(* Default credentials: guest/guest *)
Manual Lifecycle
let run_tests () =
let config =
Rabbitmq_container.create ()
|> Rabbitmq_container.with_username "admin"
|> Rabbitmq_container.with_password "secret"
|> Rabbitmq_container.with_vhost "/test"
in
let* container = Rabbitmq_container.start config in
let* amqp_url = Rabbitmq_container.amqp_url config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Queue and Exchange Setup
Using rabbitmqadmin
let setup_queues container =
(* Declare exchange *)
let* (code, _) = Testcontainers.Container.exec container [
"rabbitmqadmin"; "declare"; "exchange";
"name=events"; "type=topic"; "durable=true"
] in
assert (code = 0);
(* Declare queue *)
let* (code, _) = Testcontainers.Container.exec container [
"rabbitmqadmin"; "declare"; "queue";
"name=user.events"; "durable=true"
] in
assert (code = 0);
(* Create binding *)
let* (code, _) = Testcontainers.Container.exec container [
"rabbitmqadmin"; "declare"; "binding";
"source=events"; "destination=user.events";
"routing_key=user.*"
] in
assert (code = 0);
Lwt.return_unit
Using rabbitmqctl
let setup_with_rabbitmqctl container =
(* Add user *)
let* _ = Testcontainers.Container.exec container [
"rabbitmqctl"; "add_user"; "appuser"; "apppass"
] in
(* Set permissions *)
let* _ = Testcontainers.Container.exec container [
"rabbitmqctl"; "set_permissions"; "-p"; "/";
"appuser"; ".*"; ".*"; ".*"
] in
(* Add vhost *)
let* _ = Testcontainers.Container.exec container [
"rabbitmqctl"; "add_vhost"; "myapp"
] in
Lwt.return_unit
Publishing and Consuming Messages
Publish Test Message
let publish_message container =
let* (code, _) = Testcontainers.Container.exec container [
"rabbitmqadmin"; "publish";
"exchange=amq.default";
"routing_key=test.queue";
"payload=Hello from test!"
] in
Lwt.return (code = 0)
Get Message from Queue
let get_message container =
let* (code, output) = Testcontainers.Container.exec container [
"rabbitmqadmin"; "get"; "queue=test.queue"
] in
Printf.printf "Message: %s\n" output;
Lwt.return (code = 0)
Check Queue Status
let check_queue_status container queue_name =
let* (code, output) = Testcontainers.Container.exec container [
"rabbitmqadmin"; "list"; "queues";
"name"; "messages"; "consumers"
] in
Printf.printf "Queues:\n%s\n" output;
Lwt.return (code = 0)
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_rabbitmq
let with_test_rabbitmq f =
Rabbitmq_container.with_rabbitmq
~config:(fun c -> c
|> Rabbitmq_container.with_username "testuser"
|> Rabbitmq_container.with_password "testpass")
(fun container amqp_url ->
(* Setup queue *)
let* _ = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"declare"; "queue"; "name=test.queue"; "durable=false"
] in
f container amqp_url
)
let test_publish_consume _switch () =
with_test_rabbitmq (fun container _amqp_url ->
(* Publish message *)
let* (code, _) = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"publish"; "routing_key=test.queue";
"payload=Test message content"
] in
Alcotest.(check int) "publish succeeds" 0 code;
(* Consume message *)
let* (code, output) = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"get"; "queue=test.queue"; "ackmode=ack_requeue_false"
] in
Alcotest.(check int) "get succeeds" 0 code;
Alcotest.(check bool) "has content" true
(String.length output > 0);
Lwt.return_unit
)
let test_exchange_routing _switch () =
with_test_rabbitmq (fun container _amqp_url ->
(* Create topic exchange *)
let* _ = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"declare"; "exchange"; "name=events"; "type=topic"
] in
(* Create queues *)
let* _ = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"declare"; "queue"; "name=user.created"
] in
let* _ = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"declare"; "queue"; "name=user.deleted"
] in
(* Bind queues *)
let* _ = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"declare"; "binding";
"source=events"; "destination=user.created";
"routing_key=user.created"
] in
(* Publish to exchange *)
let* (code, _) = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"publish"; "exchange=events"; "routing_key=user.created";
"payload={\"user_id\": 123}"
] in
Alcotest.(check int) "publish succeeds" 0 code;
(* Verify message routed correctly *)
let* (code, output) = Container.exec container [
"rabbitmqadmin"; "-u"; "testuser"; "-p"; "testpass";
"get"; "queue=user.created"
] in
Alcotest.(check int) "get succeeds" 0 code;
Alcotest.(check bool) "message routed" true
(String.length output > 0);
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "RabbitMQ Tests" [
"messaging", [
Alcotest_lwt.test_case "publish/consume" `Slow test_publish_consume;
Alcotest_lwt.test_case "exchange routing" `Slow test_exchange_routing;
];
]
)
Wait Strategy
RabbitMQ module waits for the broker to be fully started:
Wait_strategy.for_log "Server startup complete"
Ports
| Port | Description |
|---|---|
| 5672 | AMQP |
| 15672 | Management UI (HTTP) |
| 15692 | Prometheus metrics |
Troubleshooting
Connection Refused on Port 5672
Ensure RabbitMQ is fully started:
(* Use with_rabbitmq which handles waiting *)
Rabbitmq_container.with_rabbitmq (fun container amqp_url ->
(* Safe to connect here *)
...
)
Authentication Failed
Check credentials match:
Rabbitmq_container.with_username "myuser"
|> Rabbitmq_container.with_password "mypass"
(* AMQP URL must use same credentials *)
Virtual Host Not Found
Create vhost or use default:
(* Default vhost *)
Rabbitmq_container.with_vhost "/"
(* Or create custom vhost via rabbitmqctl *)
Container.exec container ["rabbitmqctl"; "add_vhost"; "myapp"]
Slow Startup
RabbitMQ can take 10-30 seconds:
Container_request.with_startup_timeout 60.0
Management Plugin Not Available
Use an image with management:
Rabbitmq_container.with_image "rabbitmq:3-management-alpine"
Kafka
The Kafka module provides a pre-configured Apache Kafka container using KRaft mode (no ZooKeeper required) for integration testing.
Quick Start
open Lwt.Syntax
open Testcontainers_kafka
let test_kafka () =
Kafka_container.with_kafka (fun container bootstrap_servers ->
Printf.printf "Kafka running at: %s\n" bootstrap_servers;
Lwt.return_unit
)
Installation
opam install testcontainers-kafka
In your dune file:
(libraries testcontainers-kafka)
Configuration
Basic Usage
Kafka_container.with_kafka (fun container bootstrap_servers ->
(* bootstrap_servers: "127.0.0.1:9092" *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | apache/kafka:3.7.0 | Docker image |
Custom Image
Kafka_container.with_kafka
~config:(fun c -> c
|> Kafka_container.with_image "apache/kafka:3.6.0")
(fun container bootstrap_servers -> ...)
Connection Details
Bootstrap Servers
127.0.0.1:9092
Individual Components
Kafka_container.with_kafka (fun container bootstrap_servers ->
let* host = Kafka_container.host container in (* "127.0.0.1" *)
let* port = Kafka_container.port container in (* 9092 *)
...
)
Manual Lifecycle
let run_tests () =
let config = Kafka_container.create () in
let* container = Kafka_container.start config in
let* bootstrap = Kafka_container.bootstrap_servers config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Topic Management
Create a Topic
let create_topic container topic_name =
let* (exit_code, output) = Testcontainers.Container.exec container [
"/opt/kafka/bin/kafka-topics.sh";
"--create";
"--topic"; topic_name;
"--bootstrap-server"; "localhost:9092";
"--partitions"; "1";
"--replication-factor"; "1"
] in
assert (exit_code = 0);
Printf.printf "Created topic: %s\n" topic_name;
Lwt.return_unit
List Topics
let list_topics container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"/opt/kafka/bin/kafka-topics.sh";
"--list";
"--bootstrap-server"; "localhost:9092"
] in
Printf.printf "Topics: %s\n" output;
Lwt.return_unit
Producing and Consuming Messages
Produce Messages
let produce_message container topic message =
let* (exit_code, _) = Testcontainers.Container.exec container [
"sh"; "-c";
Printf.sprintf "echo '%s' | /opt/kafka/bin/kafka-console-producer.sh --topic %s --bootstrap-server localhost:9092"
message topic
] in
Lwt.return (exit_code = 0)
Consume Messages
let consume_messages container topic =
let* (exit_code, output) = Testcontainers.Container.exec container [
"/opt/kafka/bin/kafka-console-consumer.sh";
"--topic"; topic;
"--bootstrap-server"; "localhost:9092";
"--from-beginning";
"--timeout-ms"; "5000"
] in
Printf.printf "Messages: %s\n" output;
Lwt.return_unit
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_kafka
let test_kafka_messaging _switch () =
Kafka_container.with_kafka (fun container _bootstrap ->
(* Create topic *)
let* (code, _) = Container.exec container [
"/opt/kafka/bin/kafka-topics.sh";
"--create"; "--topic"; "test-topic";
"--bootstrap-server"; "localhost:9092";
"--partitions"; "1";
"--replication-factor"; "1"
] in
Alcotest.(check int) "topic created" 0 code;
(* Produce message *)
let* (code, _) = Container.exec container [
"sh"; "-c";
"echo 'Hello Kafka!' | /opt/kafka/bin/kafka-console-producer.sh --topic test-topic --bootstrap-server localhost:9092"
] in
Alcotest.(check int) "message produced" 0 code;
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "Kafka Tests" [
"messaging", [
Alcotest_lwt.test_case "produce" `Slow test_kafka_messaging;
];
]
)
Wait Strategy
Kafka uses a log-based wait strategy to ensure the broker is ready:
Wait_strategy.for_log ~timeout:60.0 "Kafka Server started"
KRaft Mode
This module uses Apache Kafka in KRaft mode, which means:
- No ZooKeeper dependency
- Faster startup
- Simpler configuration
- Single container deployment
Troubleshooting
Connection Refused
Kafka needs time to start. Always use with_kafka:
(* Good *)
Kafka_container.with_kafka (fun container bootstrap -> ...)
(* May fail if Kafka isn't ready *)
let* container = Container.start request in
(* immediate connection attempt *)
Topic Creation Fails
Ensure the broker is fully started before creating topics. The wait strategy handles this automatically with with_kafka.
Consumer Timeout
For tests, use a reasonable timeout:
"--timeout-ms"; "5000" (* 5 seconds *)
Elasticsearch
The Elasticsearch module provides a pre-configured Elasticsearch container for integration testing with full-text search and analytics.
Quick Start
open Lwt.Syntax
open Testcontainers_elasticsearch
let test_elasticsearch () =
Elasticsearch_container.with_elasticsearch (fun container url ->
Printf.printf "Elasticsearch running at: %s\n" url;
Lwt.return_unit
)
Installation
opam install testcontainers-elasticsearch
In your dune file:
(libraries testcontainers-elasticsearch)
Configuration
Basic Usage
Elasticsearch_container.with_elasticsearch (fun container url ->
(* url: "http://127.0.0.1:9200" *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | elasticsearch:8.12.0 | Docker image |
with_password | changeme | Elastic user password |
Custom Configuration
Elasticsearch_container.with_elasticsearch
~config:(fun c -> c
|> Elasticsearch_container.with_image "elasticsearch:7.17.0"
|> Elasticsearch_container.with_password "mysecretpassword")
(fun container url -> ...)
Connection Details
HTTP URL
http://127.0.0.1:9200
Individual Components
Elasticsearch_container.with_elasticsearch (fun container url ->
let* host = Elasticsearch_container.host container in (* "127.0.0.1" *)
let* port = Elasticsearch_container.port container in (* 9200 *)
...
)
Manual Lifecycle
let run_tests () =
let config = Elasticsearch_container.create () in
let* container = Elasticsearch_container.start config in
let* url = Elasticsearch_container.url config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Index Operations
Create an Index
let create_index container index_name =
let* (exit_code, output) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
Printf.sprintf "http://localhost:9200/%s" index_name;
"-H"; "Content-Type: application/json";
"-d"; {|{"settings": {"number_of_shards": 1, "number_of_replicas": 0}}|}
] in
Printf.printf "Create index response: %s\n" output;
Lwt.return (exit_code = 0)
Delete an Index
let delete_index container index_name =
let* (exit_code, _) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "DELETE";
Printf.sprintf "http://localhost:9200/%s" index_name
] in
Lwt.return (exit_code = 0)
Document Operations
Index a Document
let index_document container index_name doc_id json_body =
let* (exit_code, output) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
Printf.sprintf "http://localhost:9200/%s/_doc/%s" index_name doc_id;
"-H"; "Content-Type: application/json";
"-d"; json_body
] in
Printf.printf "Index response: %s\n" output;
Lwt.return (exit_code = 0)
Get a Document
let get_document container index_name doc_id =
let* (exit_code, output) = Testcontainers.Container.exec container [
"curl"; "-s";
Printf.sprintf "http://localhost:9200/%s/_doc/%s" index_name doc_id
] in
Printf.printf "Document: %s\n" output;
Lwt.return output
Search Documents
let search container index_name query =
let* (exit_code, output) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "GET";
Printf.sprintf "http://localhost:9200/%s/_search" index_name;
"-H"; "Content-Type: application/json";
"-d"; query
] in
Printf.printf "Search results: %s\n" output;
Lwt.return output
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_elasticsearch
let test_elasticsearch_crud _switch () =
Elasticsearch_container.with_elasticsearch (fun container _url ->
(* Create index *)
let* (code, _) = Container.exec container [
"curl"; "-s"; "-X"; "PUT"; "http://localhost:9200/products";
"-H"; "Content-Type: application/json";
"-d"; {|{"settings": {"number_of_shards": 1}}|}
] in
Alcotest.(check int) "index created" 0 code;
(* Index document *)
let* (code, _) = Container.exec container [
"curl"; "-s"; "-X"; "PUT"; "http://localhost:9200/products/_doc/1";
"-H"; "Content-Type: application/json";
"-d"; {|{"name": "OCaml Book", "price": 29.99, "category": "books"}|}
] in
Alcotest.(check int) "document indexed" 0 code;
(* Refresh index for immediate search *)
let* _ = Container.exec container [
"curl"; "-s"; "-X"; "POST"; "http://localhost:9200/products/_refresh"
] in
(* Search *)
let* (code, output) = Container.exec container [
"curl"; "-s"; "http://localhost:9200/products/_search?q=name:OCaml"
] in
Alcotest.(check int) "search succeeds" 0 code;
Alcotest.(check bool) "found results" true (String.length output > 0);
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "Elasticsearch Tests" [
"crud", [
Alcotest_lwt.test_case "operations" `Slow test_elasticsearch_crud;
];
]
)
Wait Strategy
Elasticsearch uses an HTTP health check wait strategy:
Wait_strategy.for_http ~port:(Port.tcp 9200)
~status_codes:[200] "/_cluster/health?wait_for_status=yellow"
Security Configuration
The module runs Elasticsearch with security disabled for easier testing:
xpack.security.enabled=false
For production-like testing with security enabled, use a custom container configuration.
Performance Tips
Single Node Setup
For faster tests, use single shard and no replicas:
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
}
}
Disable Refresh Interval
For bulk indexing tests:
{
"settings": {
"refresh_interval": "-1"
}
}
Troubleshooting
Container Startup Slow
Elasticsearch requires significant memory. Ensure Docker has at least 2GB RAM available.
Search Returns No Results
Remember to refresh the index after indexing:
let* _ = Container.exec container [
"curl"; "-s"; "-X"; "POST"; "http://localhost:9200/myindex/_refresh"
] in
Connection Refused
Always use with_elasticsearch which waits for the cluster to be ready:
(* Good *)
Elasticsearch_container.with_elasticsearch (fun container url -> ...)
LocalStack
The LocalStack module provides a pre-configured LocalStack container for testing AWS services locally without needing real AWS credentials.
Quick Start
open Lwt.Syntax
open Testcontainers_localstack
let test_localstack () =
Localstack_container.with_localstack (fun container url ->
Printf.printf "LocalStack running at: %s\n" url;
Lwt.return_unit
)
Installation
opam install testcontainers-localstack
In your dune file:
(libraries testcontainers-localstack)
Configuration
Basic Usage
Localstack_container.with_localstack (fun container url ->
(* url: "http://127.0.0.1:4566" *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | localstack/localstack:3.0 | Docker image |
with_services | ["s3"] | AWS services to enable |
with_region | us-east-1 | AWS region |
Enable Multiple Services
Localstack_container.with_localstack
~config:(fun c -> c
|> Localstack_container.with_services ["s3"; "sqs"; "dynamodb"; "lambda"])
(fun container url -> ...)
Custom Region
Localstack_container.with_localstack
~config:(fun c -> c
|> Localstack_container.with_region "eu-west-1")
(fun container url -> ...)
Connection Details
Endpoint URL
http://127.0.0.1:4566
All AWS services are available on this single endpoint.
Individual Components
Localstack_container.with_localstack (fun container url ->
let* host = Localstack_container.host container in (* "127.0.0.1" *)
let* port = Localstack_container.port container in (* 4566 *)
...
)
Manual Lifecycle
let run_tests () =
let config =
Localstack_container.create ()
|> Localstack_container.with_services ["s3"; "sqs"]
in
let* container = Localstack_container.start config in
let* url = Localstack_container.url config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
AWS CLI Examples
S3 Operations
let test_s3 container =
(* Create bucket *)
let* (exit_code, _) = Testcontainers.Container.exec container [
"awslocal"; "s3"; "mb"; "s3://my-bucket"
] in
assert (exit_code = 0);
(* List buckets *)
let* (exit_code, output) = Testcontainers.Container.exec container [
"awslocal"; "s3"; "ls"
] in
Printf.printf "Buckets: %s\n" output;
(* Upload file *)
let* _ = Testcontainers.Container.exec container [
"sh"; "-c"; "echo 'Hello S3!' > /tmp/test.txt"
] in
let* (exit_code, _) = Testcontainers.Container.exec container [
"awslocal"; "s3"; "cp"; "/tmp/test.txt"; "s3://my-bucket/test.txt"
] in
Lwt.return (exit_code = 0)
SQS Operations
let test_sqs container =
(* Create queue *)
let* (exit_code, output) = Testcontainers.Container.exec container [
"awslocal"; "sqs"; "create-queue"; "--queue-name"; "my-queue"
] in
Printf.printf "Queue created: %s\n" output;
(* Send message *)
let* (exit_code, _) = Testcontainers.Container.exec container [
"awslocal"; "sqs"; "send-message";
"--queue-url"; "http://localhost:4566/000000000000/my-queue";
"--message-body"; "Hello SQS!"
] in
(* Receive messages *)
let* (exit_code, output) = Testcontainers.Container.exec container [
"awslocal"; "sqs"; "receive-message";
"--queue-url"; "http://localhost:4566/000000000000/my-queue"
] in
Printf.printf "Received: %s\n" output;
Lwt.return (exit_code = 0)
DynamoDB Operations
let test_dynamodb container =
(* Create table *)
let* (exit_code, _) = Testcontainers.Container.exec container [
"awslocal"; "dynamodb"; "create-table";
"--table-name"; "users";
"--attribute-definitions"; "AttributeName=id,AttributeType=S";
"--key-schema"; "AttributeName=id,KeyType=HASH";
"--billing-mode"; "PAY_PER_REQUEST"
] in
(* Put item *)
let* (exit_code, _) = Testcontainers.Container.exec container [
"awslocal"; "dynamodb"; "put-item";
"--table-name"; "users";
"--item"; {|{"id": {"S": "1"}, "name": {"S": "Alice"}}|}
] in
(* Get item *)
let* (exit_code, output) = Testcontainers.Container.exec container [
"awslocal"; "dynamodb"; "get-item";
"--table-name"; "users";
"--key"; {|{"id": {"S": "1"}}|}
] in
Printf.printf "Item: %s\n" output;
Lwt.return (exit_code = 0)
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_localstack
let test_s3_bucket _switch () =
Localstack_container.with_localstack
~config:(fun c -> Localstack_container.with_services ["s3"] c)
(fun container _url ->
(* Create bucket *)
let* (code, _) = Container.exec container [
"awslocal"; "s3"; "mb"; "s3://test-bucket"
] in
Alcotest.(check int) "bucket created" 0 code;
(* List buckets *)
let* (code, output) = Container.exec container [
"awslocal"; "s3"; "ls"
] in
Alcotest.(check int) "list succeeds" 0 code;
Alcotest.(check bool) "bucket in list" true
(String.length output > 0);
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "LocalStack Tests" [
"s3", [
Alcotest_lwt.test_case "bucket operations" `Slow test_s3_bucket;
];
]
)
Wait Strategy
LocalStack uses a log-based wait strategy:
Wait_strategy.for_log ~timeout:60.0 "Ready."
Supported Services
LocalStack supports many AWS services including:
| Service | Description |
|---|---|
| S3 | Object storage |
| SQS | Message queuing |
| SNS | Pub/sub messaging |
| DynamoDB | NoSQL database |
| Lambda | Serverless functions |
| API Gateway | REST APIs |
| CloudWatch | Monitoring |
| IAM | Identity management |
| KMS | Key management |
| Secrets Manager | Secrets storage |
Using with AWS SDKs
Configure your AWS SDK to use the LocalStack endpoint:
(* Example configuration for aws-s3 library *)
let endpoint = url (* "http://127.0.0.1:4566" *)
let region = "us-east-1"
let credentials = {
access_key_id = "test";
secret_access_key = "test";
}
Troubleshooting
Service Not Available
Ensure the service is enabled:
Localstack_container.with_services ["s3"; "sqs"; "dynamodb"]
Slow Startup
LocalStack can take time to initialize all services. The wait strategy ensures readiness, but you can increase the timeout if needed.
awslocal Command Not Found
The awslocal command is included in the LocalStack container. For external access, use the AWS CLI with the endpoint override:
aws --endpoint-url=http://localhost:4566 s3 ls
Memcached
The Memcached module provides a pre-configured Memcached container for integration testing with distributed caching.
Quick Start
open Lwt.Syntax
open Testcontainers_memcached
let test_memcached () =
Memcached_container.with_memcached (fun container connection_string ->
Printf.printf "Memcached running at: %s\n" connection_string;
Lwt.return_unit
)
Installation
opam install testcontainers-memcached
In your dune file:
(libraries testcontainers-memcached)
Configuration
Basic Usage
Memcached_container.with_memcached (fun container connection_string ->
(* connection_string: "127.0.0.1:11211" *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | memcached:1.6-alpine | Docker image |
with_memory_mb | 64 | Memory limit in MB |
Custom Memory Limit
Memcached_container.with_memcached
~config:(fun c -> c
|> Memcached_container.with_memory_mb 128)
(fun container conn -> ...)
Custom Image
Memcached_container.with_memcached
~config:(fun c -> c
|> Memcached_container.with_image "memcached:1.5-alpine")
(fun container conn -> ...)
Connection Details
Connection String
127.0.0.1:11211
Individual Components
Memcached_container.with_memcached (fun container conn ->
let* host = Memcached_container.host container in (* "127.0.0.1" *)
let* port = Memcached_container.port config container in (* 11211 *)
...
)
Manual Lifecycle
let run_tests () =
let config =
Memcached_container.create ()
|> Memcached_container.with_memory_mb 256
in
let* container = Memcached_container.start config in
let* conn = Memcached_container.connection_string config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Basic Operations
Set and Get Values
Using the container's built-in tools:
let test_basic_operations container =
(* SET operation using printf/nc *)
let* (exit_code, _) = Testcontainers.Container.exec container [
"sh"; "-c";
"printf 'set mykey 0 0 5\\r\\nhello\\r\\n' | nc localhost 11211"
] in
assert (exit_code = 0);
(* GET operation *)
let* (exit_code, output) = Testcontainers.Container.exec container [
"sh"; "-c";
"printf 'get mykey\\r\\n' | nc localhost 11211"
] in
Printf.printf "Value: %s\n" output;
Lwt.return_unit
Delete Values
let delete_key container key =
let* (exit_code, _) = Testcontainers.Container.exec container [
"sh"; "-c";
Printf.sprintf "printf 'delete %s\\r\\n' | nc localhost 11211" key
] in
Lwt.return (exit_code = 0)
Flush All Data
let flush_all container =
let* (exit_code, _) = Testcontainers.Container.exec container [
"sh"; "-c";
"printf 'flush_all\\r\\n' | nc localhost 11211"
] in
Lwt.return (exit_code = 0)
Statistics
Get Server Stats
let get_stats container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"sh"; "-c";
"printf 'stats\\r\\n' | nc localhost 11211"
] in
Printf.printf "Stats:\n%s\n" output;
Lwt.return output
Get Slab Stats
let get_slab_stats container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"sh"; "-c";
"printf 'stats slabs\\r\\n' | nc localhost 11211"
] in
Printf.printf "Slab stats:\n%s\n" output;
Lwt.return output
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_memcached
let test_cache_operations _switch () =
Memcached_container.with_memcached (fun container _conn ->
(* Set a value *)
let* (code, output) = Container.exec container [
"sh"; "-c";
"printf 'set session:123 0 60 9\\r\\nuser_data\\r\\n' | nc localhost 11211"
] in
Alcotest.(check int) "set succeeds" 0 code;
Alcotest.(check bool) "stored" true (String.length output > 0);
(* Get the value *)
let* (code, output) = Container.exec container [
"sh"; "-c";
"printf 'get session:123\\r\\n' | nc localhost 11211"
] in
Alcotest.(check int) "get succeeds" 0 code;
Alcotest.(check bool) "has data" true
(String.length output > 0);
(* Delete the value *)
let* (code, _) = Container.exec container [
"sh"; "-c";
"printf 'delete session:123\\r\\n' | nc localhost 11211"
] in
Alcotest.(check int) "delete succeeds" 0 code;
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "Memcached Tests" [
"cache", [
Alcotest_lwt.test_case "operations" `Slow test_cache_operations;
];
]
)
Wait Strategy
Memcached uses a port-based wait strategy:
Wait_strategy.for_listening_port ~timeout:30.0 (Port.tcp 11211)
Memcached Protocol
Command Format
<command> <key> <flags> <exptime> <bytes>\r\n
<data>\r\n
Common Commands
| Command | Description |
|---|---|
set | Store a value |
get | Retrieve a value |
delete | Remove a value |
incr | Increment numeric value |
decr | Decrement numeric value |
flush_all | Clear all data |
stats | Get server statistics |
Performance Tips
Memory Configuration
For tests with large data:
Memcached_container.with_memory_mb 512
Connection Pooling
When using a Memcached client library, configure connection pooling appropriately for test performance.
Troubleshooting
Connection Refused
Memcached starts quickly, but always use with_memcached:
(* Good *)
Memcached_container.with_memcached (fun container conn -> ...)
(* May fail *)
let* container = Container.start request in
(* immediate connection *)
Data Not Found
Remember that Memcached is ephemeral - data is lost when the container stops. Each test starts with a fresh instance.
Memory Limits
If you're storing large values and getting evictions:
Memcached_container.with_memory_mb 256
MockServer
The MockServer module provides a pre-configured MockServer container for mocking HTTP/HTTPS services in integration tests.
Quick Start
open Testcontainers_mockserver
let test_mockserver () =
Mockserver_container.with_mockserver (fun container url ->
Printf.printf "MockServer running at: %s\n" url;
Lwt.return_unit
)
Installation
opam install testcontainers-mockserver
In your dune file:
(libraries testcontainers-mockserver)
Configuration
Basic Usage
Mockserver_container.with_mockserver (fun container url ->
(* url: "http://127.0.0.1:1080" *)
...
)
Configuration Options
| Function | Default | Description |
|---|---|---|
with_image | mockserver/mockserver:5.15.0 | Docker image |
with_log_level | INFO | Log level (INFO, DEBUG, TRACE, WARN, ERROR) |
with_max_expectations | None | Maximum number of expectations |
Custom Configuration
Mockserver_container.with_mockserver
~config:(fun c -> c
|> Mockserver_container.with_log_level "DEBUG"
|> Mockserver_container.with_max_expectations 100)
(fun container url -> ...)
Connection Details
HTTP URL
http://127.0.0.1:1080
Individual Components
Mockserver_container.with_mockserver (fun container url ->
let* host = Mockserver_container.host container in (* "127.0.0.1" *)
let* port = Mockserver_container.port config container in (* 1080 *)
...
)
Manual Lifecycle
let run_tests () =
let config =
Mockserver_container.create ()
|> Mockserver_container.with_log_level "DEBUG"
in
let* container = Mockserver_container.start config in
let* url = Mockserver_container.url config container in
(* Run tests... *)
let* () = Testcontainers.Container.terminate container in
Lwt.return_unit
Creating Expectations
Simple Expectation
let create_expectation container =
let* (exit_code, _) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/expectation";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {
"method": "GET",
"path": "/api/hello"
},
"httpResponse": {
"statusCode": 200,
"body": "Hello World!"
}
}|}
] in
Lwt.return (exit_code = 0)
Expectation with Headers
let create_expectation_with_headers container =
let* (exit_code, _) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/expectation";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {
"method": "GET",
"path": "/api/user",
"headers": {
"Authorization": ["Bearer token123"]
}
},
"httpResponse": {
"statusCode": 200,
"headers": {
"Content-Type": ["application/json"]
},
"body": "{\"name\": \"Alice\", \"id\": 1}"
}
}|}
] in
Lwt.return (exit_code = 0)
POST Request Expectation
let create_post_expectation container =
let* (exit_code, _) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/expectation";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {
"method": "POST",
"path": "/api/users",
"body": {
"type": "JSON",
"json": {"name": "Bob"}
}
},
"httpResponse": {
"statusCode": 201,
"body": "{\"id\": 2, \"name\": \"Bob\"}"
}
}|}
] in
Lwt.return (exit_code = 0)
Testing the Mock
Make a Request
let test_mock container =
(* First create the expectation *)
let* _ = create_expectation container in
(* Then test it *)
let* (exit_code, output) = Testcontainers.Container.exec container [
"curl"; "-s"; "http://localhost:1080/api/hello"
] in
Printf.printf "Response: %s\n" output;
Lwt.return (exit_code = 0 && output = "Hello World!")
Verification
Verify Request Was Made
let verify_request container =
let* (exit_code, output) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/verify";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {
"method": "GET",
"path": "/api/hello"
},
"times": {
"atLeast": 1
}
}|}
] in
Lwt.return (exit_code = 0)
Clearing Expectations
Clear All Expectations
let clear_all container =
let* (exit_code, _) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/clear"
] in
Lwt.return (exit_code = 0)
Reset MockServer
let reset container =
let* (exit_code, _) = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/reset"
] in
Lwt.return (exit_code = 0)
Complete Test Example
open Lwt.Syntax
open Testcontainers
open Testcontainers_mockserver
let test_api_mock _switch () =
Mockserver_container.with_mockserver (fun container url ->
(* Create expectation *)
let* (code, _) = Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/expectation";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {"path": "/api/users/1"},
"httpResponse": {
"statusCode": 200,
"body": "{\"id\": 1, \"name\": \"Alice\"}"
}
}|}
] in
Alcotest.(check int) "expectation created" 0 code;
(* Test the mock *)
let* (code, output) = Container.exec container [
"curl"; "-s"; "http://localhost:1080/api/users/1"
] in
Alcotest.(check int) "request succeeds" 0 code;
Alcotest.(check bool) "has response" true
(String.length output > 0);
(* Verify the request was made *)
let* (code, _) = Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/verify";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {"path": "/api/users/1"},
"times": {"atLeast": 1}
}|}
] in
Alcotest.(check int) "verification passes" 0 code;
Lwt.return_unit
)
let () =
Lwt_main.run (
Alcotest_lwt.run "MockServer Tests" [
"api", [
Alcotest_lwt.test_case "mock endpoints" `Slow test_api_mock;
];
]
)
Wait Strategy
MockServer uses a log-based wait strategy:
Wait_strategy.for_log ~timeout:60.0 "started on port"
Use Cases
Testing External API Dependencies
Mock external APIs your application depends on:
(* Mock a payment gateway *)
let mock_payment_api container =
let* _ = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/expectation";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {
"method": "POST",
"path": "/v1/charges"
},
"httpResponse": {
"statusCode": 200,
"body": "{\"id\": \"ch_123\", \"status\": \"succeeded\"}"
}
}|}
] in
Lwt.return_unit
Testing Error Handling
(* Mock error responses *)
let mock_error_response container =
let* _ = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/expectation";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {"path": "/api/fail"},
"httpResponse": {
"statusCode": 500,
"body": "{\"error\": \"Internal Server Error\"}"
}
}|}
] in
Lwt.return_unit
Testing Timeouts
(* Mock slow response *)
let mock_slow_response container =
let* _ = Testcontainers.Container.exec container [
"curl"; "-s"; "-X"; "PUT";
"http://localhost:1080/mockserver/expectation";
"-H"; "Content-Type: application/json";
"-d"; {|{
"httpRequest": {"path": "/api/slow"},
"httpResponse": {
"statusCode": 200,
"delay": {"timeUnit": "SECONDS", "value": 5}
}
}|}
] in
Lwt.return_unit
Troubleshooting
Expectation Not Matching
Check that the request matches exactly:
- HTTP method
- Path
- Headers (if specified)
- Body (if specified)
Enable DEBUG logging for more details:
Mockserver_container.with_log_level "DEBUG"
Connection Refused
Always use with_mockserver which waits for the server to be ready:
(* Good *)
Mockserver_container.with_mockserver (fun container url -> ...)
Multiple Expectations
MockServer matches expectations in order. Put more specific expectations before general ones.
Custom Containers
While Testcontainers OCaml provides pre-built modules for common services, you'll often need to test against custom applications or services not included in the standard modules.
Basic Custom Container
Simple Example
open Lwt.Syntax
open Testcontainers
let test_custom_app () =
let request =
Container_request.create "my-app:latest"
|> Container_request.with_exposed_port (Port.tcp 8080)
|> Container_request.with_env "APP_ENV" "test"
|> Container_request.with_env "LOG_LEVEL" "debug"
|> Container_request.with_wait_strategy
(Wait_strategy.for_http "/health")
in
Container.with_container request (fun container ->
let* host = Container.host container in
let* port = Container.mapped_port container (Port.tcp 8080) in
Printf.printf "App running at http://%s:%d\n" host port;
(* Run tests against your app *)
Lwt.return_unit
)
Creating a Reusable Module
For services you use frequently, create a dedicated module:
Module Structure
(* my_app_container.ml *)
open Lwt.Syntax
open Testcontainers
let default_image = "my-company/my-app:latest"
let default_port = 8080
type config = {
image : string;
api_key : string;
debug : bool;
}
let create () = {
image = default_image;
api_key = "test-key";
debug = true;
}
let with_image image config = { config with image }
let with_api_key key config = { config with api_key = key }
let with_debug debug config = { config with debug }
let start config =
let request =
Container_request.create config.image
|> Container_request.with_exposed_port (Port.tcp default_port)
|> Container_request.with_env "API_KEY" config.api_key
|> Container_request.with_env "DEBUG" (if config.debug then "true" else "false")
|> Container_request.with_wait_strategy
(Wait_strategy.for_http ~port:(Port.tcp default_port) "/health")
in
Container.start request
let host container =
Container.host container
let port container =
Container.mapped_port container (Port.tcp default_port)
let base_url container =
let* h = host container in
let* p = port container in
Lwt.return (Printf.sprintf "http://%s:%d" h p)
let with_my_app ?(config = Fun.id) f =
let cfg = config (create ()) in
let request =
Container_request.create cfg.image
|> Container_request.with_exposed_port (Port.tcp default_port)
|> Container_request.with_env "API_KEY" cfg.api_key
|> Container_request.with_env "DEBUG" (if cfg.debug then "true" else "false")
|> Container_request.with_wait_strategy
(Wait_strategy.for_http ~port:(Port.tcp default_port) "/health")
in
Container.with_container request (fun container ->
let* url = base_url container in
f container url)
Usage
let test_my_app () =
My_app_container.with_my_app
~config:(fun c -> c
|> My_app_container.with_api_key "secret"
|> My_app_container.with_debug false)
(fun container base_url ->
Printf.printf "Testing app at %s\n" base_url;
(* Your tests *)
Lwt.return_unit
)
Common Patterns
Web Application
let web_app_container () =
Container_request.create "my-web-app:latest"
|> Container_request.with_exposed_port (Port.tcp 3000)
|> Container_request.with_env "NODE_ENV" "test"
|> Container_request.with_env "DATABASE_URL" "postgres://..."
|> Container_request.with_wait_strategy
(Wait_strategy.for_http "/api/health")
gRPC Service
let grpc_service_container () =
Container_request.create "my-grpc-service:latest"
|> Container_request.with_exposed_port (Port.tcp 50051)
|> Container_request.with_wait_strategy
(Wait_strategy.for_listening_port (Port.tcp 50051))
Background Worker
let worker_container () =
Container_request.create "my-worker:latest"
|> Container_request.with_env "QUEUE_URL" "amqp://..."
|> Container_request.with_wait_strategy
(Wait_strategy.for_log "Worker started and listening")
Sidecar Pattern
let run_with_sidecar () =
Network.with_network "test-network" (fun _network ->
(* Main service *)
let main_request =
Container_request.create "my-service:latest"
|> Container_request.with_name "main-service"
|> Container_request.with_exposed_port (Port.tcp 8080)
in
(* Sidecar (e.g., proxy, logging agent) *)
let sidecar_request =
Container_request.create "envoy:latest"
|> Container_request.with_name "sidecar-proxy"
|> Container_request.with_exposed_port (Port.tcp 9901)
|> Container_request.with_env "SERVICE_HOST" "main-service"
in
Container.with_container main_request (fun main ->
Container.with_container sidecar_request (fun sidecar ->
(* Both containers running, can communicate via network *)
Lwt.return_unit
)
)
)
Custom Wait Strategies
Multiple Conditions
let complex_wait_strategy =
Wait_strategy.all [
(* Port must be open *)
Wait_strategy.for_listening_port (Port.tcp 8080);
(* AND health endpoint must return 200 *)
Wait_strategy.for_http "/health";
(* AND specific log message must appear *)
Wait_strategy.for_log "Application initialized";
]
Custom Exec-Based Wait
let wait_for_schema_ready =
Wait_strategy.for_exec [
"sh"; "-c";
"psql -U postgres -c 'SELECT 1 FROM migrations LIMIT 1'"
]
HTTP with Custom Validation
let wait_for_healthy_response =
Wait_strategy.for_http
~port:(Port.tcp 8080)
~status_codes:[200]
"/api/v1/health"
Configuration from Environment
let create_config_from_env () =
let image = match Sys.getenv_opt "TEST_IMAGE" with
| Some img -> img
| None -> "default:latest"
in
let debug = match Sys.getenv_opt "TEST_DEBUG" with
| Some "true" -> true
| _ -> false
in
Container_request.create image
|> Container_request.with_env "DEBUG" (string_of_bool debug)
Building Images for Tests
Using Pre-built Test Images
(* In CI, build and tag test image first *)
(* docker build -t my-app:test . *)
let request =
Container_request.create "my-app:test"
Using Docker Compose Images
(* If you have docker-compose.yml with build context *)
(* docker-compose build test-service *)
(* docker tag project_test-service:latest my-test-image:latest *)
let request =
Container_request.create "my-test-image:latest"
Testing Database Migrations
let test_migrations () =
Postgres_container.with_postgres (fun container conn_str ->
(* Copy migration files *)
let* () = Container.copy_file_to container
~src:"./migrations"
~dest:"/migrations/"
in
(* Run migrations *)
let* (exit_code, output) = Container.exec container [
"sh"; "-c";
"for f in /migrations/*.sql; do psql -U postgres -f $f; done"
] in
if exit_code <> 0 then
Printf.printf "Migration failed: %s\n" output;
(* Verify schema *)
let* (_, output) = Container.exec container [
"psql"; "-U"; "postgres"; "-c";
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
] in
Printf.printf "Tables created:\n%s\n" output;
Lwt.return_unit
)
Multi-Stage Test Setup
let integration_test_setup () =
(* Phase 1: Infrastructure *)
Network.with_network "integration-test" (fun _network ->
Postgres_container.with_postgres (fun pg_container pg_conn ->
Redis_container.with_redis (fun redis_container redis_uri ->
(* Phase 2: Run migrations *)
let* _ = Container.exec pg_container [
"psql"; "-U"; "postgres"; "-f"; "/docker-entrypoint-initdb.d/schema.sql"
] in
(* Phase 3: Start application *)
let app_request =
Container_request.create "my-app:test"
|> Container_request.with_exposed_port (Port.tcp 8080)
|> Container_request.with_env "DATABASE_URL" pg_conn
|> Container_request.with_env "REDIS_URL" redis_uri
|> Container_request.with_wait_strategy
(Wait_strategy.for_http "/health")
in
Container.with_container app_request (fun app_container ->
let* app_url = Container.host app_container in
let* app_port = Container.mapped_port app_container (Port.tcp 8080) in
(* Phase 4: Run tests *)
Printf.printf "Running tests against http://%s:%d\n" app_url app_port;
Lwt.return_unit
)
)
)
)
Best Practices
This guide covers recommended patterns and practices for effective integration testing with Testcontainers OCaml.
Test Organization
Use Helper Functions
Create wrapper functions for common setups:
(* test_helpers.ml *)
let with_postgres_db f =
Postgres_container.with_postgres
~config:(fun c -> c
|> Postgres_container.with_database "testdb"
|> Postgres_container.with_username "test"
|> Postgres_container.with_password "test")
(fun container conn_str ->
(* Run migrations *)
let* _ = setup_schema container in
f conn_str)
let with_redis_cache f =
Redis_container.with_redis (fun container uri ->
f uri)
let with_full_stack f =
with_postgres_db (fun db_url ->
with_redis_cache (fun cache_url ->
f ~db_url ~cache_url))
Test Isolation
Each test should be independent:
(* Good: Each test gets fresh state *)
let test_create_user _switch () =
with_postgres_db (fun conn_str ->
(* Fresh database for this test *)
Lwt.return_unit)
let test_delete_user _switch () =
with_postgres_db (fun conn_str ->
(* Another fresh database *)
Lwt.return_unit)
Group Related Tests
let user_tests = [
Alcotest_lwt.test_case "create" `Slow test_create_user;
Alcotest_lwt.test_case "update" `Slow test_update_user;
Alcotest_lwt.test_case "delete" `Slow test_delete_user;
]
let order_tests = [
Alcotest_lwt.test_case "place order" `Slow test_place_order;
Alcotest_lwt.test_case "cancel order" `Slow test_cancel_order;
]
let () =
Lwt_main.run (
Alcotest_lwt.run "Integration Tests" [
("users", user_tests);
("orders", order_tests);
]
)
Resource Management
Always Use with_container
(* Good: Automatic cleanup *)
Container.with_container request (fun container ->
may_fail container)
(* Container cleaned up even if may_fail raises *)
(* Risky: Manual cleanup might be skipped *)
let* container = Container.start request in
let* () = may_fail container in (* If this fails... *)
Container.terminate container (* ...this never runs *)
Nest Contexts Properly
(* Cleanup happens in reverse order *)
Network.with_network "test" (fun network ->
Container.with_container db_request (fun db ->
Container.with_container app_request (fun app ->
(* Use app and db *)
Lwt.return_unit
)
(* app cleaned up first *)
)
(* db cleaned up second *)
)
(* network cleaned up last *)
Performance Optimization
Use Specific Image Tags
(* Good: Specific, reproducible *)
Container_request.create "postgres:16.1-alpine"
(* Avoid: Can change unexpectedly *)
Container_request.create "postgres:latest"
Use Alpine Images
(* Smaller, faster to pull *)
"postgres:16-alpine" (* ~80MB *)
"redis:7-alpine" (* ~30MB *)
"mongo:7" (* ~700MB - no alpine available *)
Pre-pull Images in CI
# .github/workflows/test.yml
- name: Pull Docker images
run: |
docker pull postgres:16-alpine
docker pull redis:7-alpine
Parallel Test Execution
(* Run independent tests in parallel *)
let test_a () = with_postgres_db (fun _ -> ...)
let test_b () = with_redis_cache (fun _ -> ...)
(* These can run simultaneously *)
let* () = Lwt.join [test_a (); test_b ()] in
Share Containers When Safe
For read-only tests against the same data:
let shared_container = ref None
let get_or_create_container () =
match !shared_container with
| Some c -> Lwt.return c
| None ->
let* c = Postgres_container.start (Postgres_container.create ()) in
let* _ = seed_test_data c in
shared_container := Some c;
Lwt.return c
(* Cleanup at end of test suite *)
let cleanup () =
match !shared_container with
| Some c -> Container.terminate c
| None -> Lwt.return_unit
Configuration
Use Environment Variables for CI
let skip_slow_tests () =
Sys.getenv_opt "CI" = Some "true" &&
Sys.getenv_opt "RUN_SLOW_TESTS" <> Some "true"
let test_slow_operation _switch () =
if skip_slow_tests () then
Lwt.return_unit
else
(* actual test *)
Configurable Timeouts
let startup_timeout =
match Sys.getenv_opt "CONTAINER_TIMEOUT" with
| Some t -> float_of_string t
| None -> 60.0
let request =
Container_request.create "slow-starting-app:latest"
|> Container_request.with_startup_timeout startup_timeout
Debugging
Capture Logs on Failure
let test_with_debug _switch () =
Container.with_container request (fun container ->
Lwt.catch
(fun () -> run_test container)
(fun exn ->
(* Capture logs before re-raising *)
let* logs = Container.logs container in
Printf.printf "=== Container Logs ===\n%s\n" logs;
Lwt.fail exn))
Print Connection Details
let test_with_info _switch () =
Postgres_container.with_postgres (fun container conn_str ->
Printf.printf "Connection: %s\n" conn_str;
Printf.printf "Container ID: %s\n" (Container.id container);
run_test conn_str)
Keep Failed Containers
let keep_on_failure = Sys.getenv_opt "KEEP_FAILED_CONTAINERS" = Some "true"
let test_with_inspection _switch () =
let* container = Container.start request in
Lwt.catch
(fun () ->
let* () = run_test container in
Container.terminate container)
(fun exn ->
if keep_on_failure then begin
Printf.printf "Container %s kept for inspection\n" (Container.id container);
Lwt.fail exn
end else begin
let* () = Container.terminate container in
Lwt.fail exn
end)
Test Data
Use Factories
module UserFactory = struct
let counter = ref 0
let create ?(name = "Test User") ?(email = None) () =
incr counter;
let email = match email with
| Some e -> e
| None -> Printf.sprintf "user%d@test.com" !counter
in
{ name; email }
end
let test_user_creation _switch () =
with_postgres_db (fun conn_str ->
let user = UserFactory.create ~name:"Alice" () in
(* user.email is "user1@test.com" - unique *)
Lwt.return_unit)
Seed Data Functions
let seed_users container =
Container.exec container [
"psql"; "-U"; "postgres"; "-c";
{|
INSERT INTO users (email, name) VALUES
('alice@test.com', 'Alice'),
('bob@test.com', 'Bob');
|}
]
let seed_products container =
Container.exec container [
"psql"; "-U"; "postgres"; "-c";
{|
INSERT INTO products (name, price) VALUES
('Widget', 9.99),
('Gadget', 19.99);
|}
]
let seed_all container =
let* _ = seed_users container in
let* _ = seed_products container in
Lwt.return_unit
Error Handling
Graceful Degradation
let test_requires_docker _switch () =
Lwt.catch
(fun () ->
let* available = Docker_client.ping () in
if not available then begin
Printf.printf "Docker not available, skipping test\n";
Lwt.return_unit
end else
run_actual_test ())
(fun _ ->
Printf.printf "Docker connection failed, skipping test\n";
Lwt.return_unit)
Retry Logic
let with_retry ~attempts ~delay f =
let rec loop n =
Lwt.catch f (fun exn ->
if n <= 1 then Lwt.fail exn
else begin
Printf.printf "Attempt failed, retrying in %.1fs...\n" delay;
let* () = Lwt_unix.sleep delay in
loop (n - 1)
end)
in
loop attempts
let test_flaky_service _switch () =
with_retry ~attempts:3 ~delay:1.0 (fun () ->
Container.with_container request (fun container ->
call_flaky_endpoint container))
CI/CD Integration
GitHub Actions Example
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup OCaml
uses: ocaml/setup-ocaml@v2
with:
ocaml-compiler: 5.1
- name: Install dependencies
run: opam install . --deps-only --with-test
- name: Pull Docker images
run: |
docker pull postgres:16-alpine
docker pull redis:7-alpine
- name: Run tests
run: opam exec -- dune runtest
env:
CONTAINER_TIMEOUT: "120"
Skip Integration Tests
let skip_integration =
Sys.getenv_opt "SKIP_INTEGRATION_TESTS" = Some "true"
let integration_test name f =
if skip_integration then
Alcotest_lwt.test_case name `Quick (fun _ () -> Lwt.return_unit)
else
Alcotest_lwt.test_case name `Slow f
API Overview
This page provides a quick reference to all modules and their primary functions.
Core Modules
Testcontainers.Container
Container lifecycle management.
(* Start a container *)
val start : Container_request.t -> t Lwt.t
(* Stop a container *)
val stop : ?timeout:float -> t -> unit Lwt.t
(* Stop and remove a container *)
val terminate : t -> unit Lwt.t
(* Start, run function, then terminate *)
val with_container : Container_request.t -> (t -> 'a Lwt.t) -> 'a Lwt.t
(* Get container ID *)
val id : t -> string
(* Get container name *)
val name : t -> string
(* Get host (always 127.0.0.1) *)
val host : t -> string Lwt.t
(* Get mapped port *)
val mapped_port : t -> Port.t -> int Lwt.t
(* Get all mapped ports *)
val mapped_ports : t -> Port.mapped_port list Lwt.t
(* Check if running *)
val is_running : t -> bool Lwt.t
(* Get container state *)
val state : t -> [> `Created | `Running | `Paused | `Exited | `Dead | `Restarting] Lwt.t
(* Execute command in container *)
val exec : t -> string list -> (int * string) Lwt.t
(* Get container logs *)
val logs : ?since:float -> ?follow:bool -> t -> string Lwt.t
(* Copy file to container *)
val copy_file_to : t -> src:string -> dest:string -> unit Lwt.t
(* Copy file from container *)
val copy_file_from : t -> src:string -> dest:string -> unit Lwt.t
(* Copy string content to container *)
val copy_content_to : t -> content:string -> dest:string -> unit Lwt.t
Testcontainers.Container_request
Builder for container configuration.
(* Create request with image *)
val create : string -> t
(* Image configuration *)
val with_image : string -> t -> t
val image : t -> string
(* Port configuration *)
val with_exposed_port : Port.t -> t -> t
val with_exposed_ports : Port.t list -> t -> t
val exposed_ports : t -> Port.t list
(* Environment variables *)
val with_env : string -> string -> t -> t
val with_envs : (string * string) list -> t -> t
val environment : t -> (string * string) list
(* Command and entrypoint *)
val with_cmd : string list -> t -> t
val with_entrypoint : string list -> t -> t
val command : t -> string list option
val entrypoint : t -> string list option
(* Container settings *)
val with_name : string -> t -> t
val with_user : string -> t -> t
val with_working_dir : string -> t -> t
val with_privileged : bool -> t -> t
val name : t -> string option
val user : t -> string option
val working_dir : t -> string option
val privileged : t -> bool
(* Labels *)
val with_label : string -> string -> t -> t
val with_labels : (string * string) list -> t -> t
val labels : t -> (string * string) list
(* Mounts *)
val with_mount : Volume.mount -> t -> t
val mounts : t -> Volume.mount list
(* Wait strategy *)
val with_wait_strategy : Wait_strategy.t -> t -> t
val wait_strategy : t -> Wait_strategy.t option
(* Timeouts *)
val with_startup_timeout : float -> t -> t
val startup_timeout : t -> float
(* Auto remove *)
val with_auto_remove : bool -> t -> t
val auto_remove : t -> bool
Testcontainers.Port
Port types and utilities.
type protocol = Tcp | Udp
type t = {
port : int;
protocol : protocol;
}
type mapped_port = {
container_port : t;
host_port : int;
host_ip : string;
}
(* Create ports *)
val tcp : int -> t
val udp : int -> t
(* Parse from string *)
val of_string : string -> t (* "8080/tcp" -> Port.t *)
(* Convert to string *)
val to_string : t -> string (* "8080/tcp" *)
val to_docker_format : t -> string
(* Comparison *)
val equal : t -> t -> bool
val compare : t -> t -> int
(* Protocol conversion *)
val protocol_to_string : protocol -> string
Testcontainers.Volume
Volume and mount configuration.
type bind_mode = ReadWrite | ReadOnly
type mount =
| Bind of { host_path : string; container_path : string; mode : bind_mode }
| Volume of { name : string; container_path : string; mode : bind_mode }
| Tmpfs of { container_path : string; size : int option }
(* Create mounts *)
val bind : ?mode:bind_mode -> host:string -> container:string -> unit -> mount
val volume : ?mode:bind_mode -> name:string -> container:string -> unit -> mount
val tmpfs : ?size:int -> container:string -> unit -> mount
(* Utilities *)
val container_path : mount -> string
val to_docker_bind_format : mount -> string
val mode_to_string : bind_mode -> string
Testcontainers.Wait_strategy
Wait strategy configuration.
type t
(* Create strategies *)
val for_listening_port : Port.t -> t
val for_log : ?occurrence:int -> string -> t
val for_log_regex : string -> t
val for_http : ?port:Port.t -> ?status_codes:int list -> string -> t
val for_exec : string list -> t
val for_health_check : unit -> t
val none : t
(* Combinators *)
val all : t list -> t
val any : t list -> t
(* Configuration *)
val with_timeout : float -> t -> t
val with_poll_interval : float -> t -> t
(* Inspection *)
val name : t -> string
val timeout : t -> float
Testcontainers.Network
Docker network management.
type t
(* Create network *)
val create : ?driver:string -> string -> t Lwt.t
(* Remove network *)
val remove : t -> unit Lwt.t
(* Create, use, then remove *)
val with_network : ?driver:string -> string -> (t -> 'a Lwt.t) -> 'a Lwt.t
(* Properties *)
val id : t -> string
val name : t -> string
Testcontainers.Error
Error types and handling.
type t =
| Container_not_found of string
| Container_not_running of string
| Container_start_failed of { id : string; message : string }
| Container_stop_failed of { id : string; message : string }
| Wait_timeout of { strategy : string; timeout : float }
| Docker_error of { status : int; message : string }
| Docker_connection_failed of string
| Invalid_configuration of string
| Image_pull_failed of { image : string; message : string }
| Port_not_mapped of { container_port : int; protocol : string }
exception Testcontainers_error of t
val to_string : t -> string
val raise_error : t -> 'a
Testcontainers.Docker_client
Low-level Docker API client.
(* Health check *)
val ping : unit -> bool Lwt.t
(* Version info *)
val version : unit -> (string * string) Lwt.t
(* Image operations *)
val image_exists : string -> bool Lwt.t
val pull_image : string -> unit Lwt.t
Module Libraries
Testcontainers_postgres.Postgres_container
type config
val create : unit -> config
val with_image : string -> config -> config
val with_database : string -> config -> config
val with_username : string -> config -> config
val with_password : string -> config -> config
val database : config -> string
val username : config -> string
val start : config -> Container.t Lwt.t
val host : Container.t -> string Lwt.t
val port : config -> Container.t -> int Lwt.t
val connection_string : config -> Container.t -> string Lwt.t
val jdbc_url : config -> Container.t -> string Lwt.t
val with_postgres : ?config:(config -> config) ->
(Container.t -> string -> 'a Lwt.t) -> 'a Lwt.t
Testcontainers_mysql.Mysql_container
type config
val create : unit -> config
val with_image : string -> config -> config
val with_database : string -> config -> config
val with_username : string -> config -> config
val with_password : string -> config -> config
val with_root_password : string -> config -> config
val database : config -> string
val username : config -> string
val password : config -> string
val start : config -> Container.t Lwt.t
val host : Container.t -> string Lwt.t
val port : config -> Container.t -> int Lwt.t
val connection_string : config -> Container.t -> string Lwt.t
val jdbc_url : config -> Container.t -> string Lwt.t
val with_mysql : ?config:(config -> config) ->
(Container.t -> string -> 'a Lwt.t) -> 'a Lwt.t
Testcontainers_mongo.Mongo_container
type config
val create : unit -> config
val with_image : string -> config -> config
val with_username : string -> config -> config
val with_password : string -> config -> config
val username : config -> string
val password : config -> string
val start : config -> Container.t Lwt.t
val host : Container.t -> string Lwt.t
val port : config -> Container.t -> int Lwt.t
val connection_string : config -> Container.t -> string Lwt.t
val with_mongo : ?config:(config -> config) ->
(Container.t -> string -> 'a Lwt.t) -> 'a Lwt.t
Testcontainers_redis.Redis_container
type config
val create : unit -> config
val with_image : string -> config -> config
val start : config -> Container.t Lwt.t
val host : Container.t -> string Lwt.t
val port : Container.t -> int Lwt.t
val uri : config -> Container.t -> string Lwt.t
val with_redis : ?config:(config -> config) ->
(Container.t -> string -> 'a Lwt.t) -> 'a Lwt.t
Testcontainers_rabbitmq.Rabbitmq_container
type config
val create : unit -> config
val with_image : string -> config -> config
val with_username : string -> config -> config
val with_password : string -> config -> config
val with_vhost : string -> config -> config
val username : config -> string
val vhost : config -> string
val start : config -> Container.t Lwt.t
val host : Container.t -> string Lwt.t
val amqp_port : Container.t -> int Lwt.t
val management_port : Container.t -> int Lwt.t
val amqp_url : config -> Container.t -> string Lwt.t
val with_rabbitmq : ?config:(config -> config) ->
(Container.t -> string -> 'a Lwt.t) -> 'a Lwt.t
Configuration
This page documents all configuration options for Testcontainers OCaml.
Environment Variables
Docker Connection
| Variable | Default | Description |
|---|---|---|
DOCKER_HOST | unix:///var/run/docker.sock | Docker daemon socket |
DOCKER_TLS_VERIFY | - | Enable TLS verification |
DOCKER_CERT_PATH | - | Path to TLS certificates |
Test Configuration
| Variable | Default | Description |
|---|---|---|
SKIP_INTEGRATION_TESTS | false | Skip all integration tests |
CONTAINER_TIMEOUT | 60 | Default startup timeout (seconds) |
TESTCONTAINERS_RYUK_DISABLED | false | Disable resource cleanup (not yet implemented) |
CI Detection
| Variable | Description |
|---|---|
CI | Set to true in most CI environments |
GITHUB_ACTIONS | Set in GitHub Actions |
GITLAB_CI | Set in GitLab CI |
CIRCLECI | Set in CircleCI |
Default Values
Container Request Defaults
(* Default values when creating a container request *)
let defaults = {
exposed_ports = [];
environment = [];
labels = [];
command = None;
entrypoint = None;
working_dir = None;
user = None;
privileged = false;
mounts = [];
wait_strategy = None;
startup_timeout = 60.0;
auto_remove = false;
name = None;
}
Wait Strategy Defaults
(* Default timeout for wait strategies *)
let default_timeout = 60.0 (* seconds *)
(* Default poll interval *)
let default_poll_interval = 0.1 (* seconds, 100ms *)
Module Defaults
PostgreSQL
let defaults = {
image = "postgres:16-alpine";
database = "test";
username = "test";
password = "test";
port = 5432;
}
MySQL
let defaults = {
image = "mysql:8";
database = "test";
username = "test";
password = "test";
root_password = "root";
port = 3306;
}
MongoDB
let defaults = {
image = "mongo:7";
username = None; (* No auth by default *)
password = None;
port = 27017;
}
Redis
let defaults = {
image = "redis:7-alpine";
port = 6379;
}
RabbitMQ
let defaults = {
image = "rabbitmq:3-management-alpine";
username = "guest";
password = "guest";
vhost = "/";
amqp_port = 5672;
management_port = 15672;
}
Timeouts
Startup Timeout
Maximum time to wait for a container to be ready:
Container_request.with_startup_timeout 120.0 (* 2 minutes *)
Wait Strategy Timeout
Maximum time for a wait strategy to succeed:
Wait_strategy.with_timeout 30.0 (* 30 seconds *)
Poll Interval
How often to check wait conditions:
Wait_strategy.with_poll_interval 0.5 (* 500ms *)
Stop Timeout
How long to wait for graceful shutdown:
Container.stop ~timeout:10.0 container (* 10 seconds *)
Docker API
API Version
let api_version = "v1.43"
Socket Path
let docker_socket = "/var/run/docker.sock"
Programmatic Configuration
Reading Environment
let get_timeout () =
match Sys.getenv_opt "CONTAINER_TIMEOUT" with
| Some t -> (try float_of_string t with _ -> 60.0)
| None -> 60.0
let skip_integration () =
match Sys.getenv_opt "SKIP_INTEGRATION_TESTS" with
| Some "1" | Some "true" -> true
| _ -> false
let is_ci () =
Sys.getenv_opt "CI" = Some "true"
Dynamic Configuration
let create_request () =
let timeout = get_timeout () in
let image =
match Sys.getenv_opt "POSTGRES_IMAGE" with
| Some img -> img
| None -> "postgres:16-alpine"
in
Container_request.create image
|> Container_request.with_startup_timeout timeout
Common Configuration Patterns
Development vs CI
let config =
if is_ci () then
(* CI: longer timeouts, specific images *)
Container_request.create "postgres:16-alpine"
|> Container_request.with_startup_timeout 120.0
else
(* Dev: faster startup, latest images okay *)
Container_request.create "postgres:16"
|> Container_request.with_startup_timeout 60.0
Configurable Test Suites
let integration_tests =
if skip_integration () then [] else [
test_database;
test_cache;
test_queue;
]
Image Override
let postgres_image =
Sys.getenv_opt "TEST_POSTGRES_IMAGE"
|> Option.value ~default:"postgres:16-alpine"
let config =
Postgres_container.create ()
|> Postgres_container.with_image postgres_image
Docker Desktop Settings
macOS / Windows
Docker Desktop exposes the socket at the default location. No configuration needed.
Resource Limits
Configure in Docker Desktop preferences:
- Memory: Recommend at least 4GB for integration tests
- CPUs: 2+ recommended
- Disk: Ensure adequate space for images
WSL2 (Windows)
# In WSL2, Docker socket is available at default location
export DOCKER_HOST=unix:///var/run/docker.sock
Troubleshooting Configuration
Verify Docker Connection
let check_docker () =
Lwt_main.run (
let* ok = Docker_client.ping () in
if ok then
print_endline "Docker connection OK"
else
print_endline "Docker connection failed";
Lwt.return_unit
)
Check Environment
let print_config () =
Printf.printf "DOCKER_HOST: %s\n"
(Sys.getenv_opt "DOCKER_HOST" |> Option.value ~default:"(default)");
Printf.printf "CI: %s\n"
(Sys.getenv_opt "CI" |> Option.value ~default:"false");
Printf.printf "Skip integration: %b\n" (skip_integration ())
Debug Mode
let debug = Sys.getenv_opt "DEBUG" = Some "true"
let log msg =
if debug then Printf.printf "[DEBUG] %s\n" msg
let start_with_debug request =
log "Starting container...";
let* container = Container.start request in
log (Printf.sprintf "Container started: %s" (Container.id container));
Lwt.return container
Contributing
Thank you for your interest in contributing to Testcontainers OCaml! This guide will help you get started.
Getting Started
Prerequisites
- OCaml 5.0 or later
- opam 2.0 or later
- Docker
- dune build system
Setup
# Clone the repository
git clone https://github.com/benodiwal/testcontainers-ocaml.git
cd testcontainers-ocaml
# Install dependencies
opam install . --deps-only --with-test --with-doc
# Build
dune build
# Run tests
dune runtest
Project Structure
testcontainers-ocaml/
├── lib/ # Core library
│ ├── container.ml # Container lifecycle
│ ├── container_request.ml # Container configuration
│ ├── docker_client.ml # Docker API client
│ ├── wait_strategy.ml # Wait strategies
│ ├── network.ml # Docker networks
│ ├── port.ml # Port types
│ ├── volume.ml # Volume/mount types
│ ├── error.ml # Error types
│ └── testcontainers.ml # Main module
├── modules/ # Service-specific modules
│ ├── postgres/
│ ├── mysql/
│ ├── mongo/
│ ├── redis/
│ ├── rabbitmq/
│ ├── kafka/
│ ├── elasticsearch/
│ ├── localstack/
│ ├── memcached/
│ └── mockserver/
├── test/ # Test suite
├── examples/ # Example code
└── docs/ # Documentation (mdbook)
Development Workflow
Building
# Build everything
dune build
# Build and watch for changes
dune build --watch
Testing
# Run all tests
dune runtest
# Run specific test file
dune exec test/test_testcontainers.exe
# Run with verbose output
dune runtest --force
# Skip integration tests (unit tests only)
SKIP_INTEGRATION_TESTS=1 dune runtest
Formatting
# Check formatting
dune build @fmt
# Auto-format
dune fmt
Documentation
# Build API docs
dune build @doc
# Build mdbook documentation
cd docs && mdbook build
# Serve documentation locally
cd docs && mdbook serve
Making Changes
Creating a Branch
git checkout -b feature/my-feature
# or
git checkout -b fix/my-bugfix
Commit Messages
Follow conventional commits:
feat: add Kafka container module
fix: handle empty port bindings
docs: update PostgreSQL examples
test: add network integration tests
refactor: simplify wait strategy logic
chore: update dependencies
Pull Request Process
- Create a branch from
main - Make your changes
- Add tests for new functionality
- Ensure all tests pass
- Update documentation if needed
- Open a pull request
Adding a New Module
1. Create Module Directory
mkdir -p modules/myservice
2. Create dune File
; modules/myservice/dune
(library
(name testcontainers_myservice)
(public_name testcontainers-myservice)
(libraries testcontainers lwt lwt.unix)
(preprocess (pps lwt_ppx)))
3. Implement the Module
(* modules/myservice/myservice_container.ml *)
open Lwt.Syntax
open Testcontainers
let default_image = "myservice:latest"
let default_port = 8080
type config = {
image : string;
(* Add service-specific config *)
}
let create () = {
image = default_image;
}
let with_image image config = { config with image }
let start config =
let request =
Container_request.create config.image
|> Container_request.with_exposed_port (Port.tcp default_port)
|> Container_request.with_wait_strategy
(Wait_strategy.for_listening_port (Port.tcp default_port))
in
Container.start request
let with_myservice ?(config = Fun.id) f =
let cfg = config (create ()) in
(* ... implementation *)
4. Add Interface File
(* modules/myservice/myservice_container.mli *)
type config
val create : unit -> config
val with_image : string -> config -> config
val start : config -> Testcontainers.Container.t Lwt.t
val with_myservice : ?config:(config -> config) ->
(Testcontainers.Container.t -> string -> 'a Lwt.t) -> 'a Lwt.t
5. Update dune-project
(package
(name testcontainers-myservice)
(depends
(ocaml (>= 5.0))
(testcontainers (>= 1.0))
(lwt (>= 5.6))))
6. Add Tests
(* test/test_myservice.ml *)
let test_container _switch () =
Myservice_container.with_myservice (fun container url ->
(* Test implementation *)
Lwt.return_unit
)
let suite = [
Alcotest_lwt.test_case "container" `Slow test_container;
]
7. Add Example
(* examples/myservice_example.ml *)
let () = Lwt_main.run (
Myservice_container.with_myservice (fun container url ->
Printf.printf "MyService running at: %s\n" url;
Lwt.return_unit
)
)
8. Add Documentation
Create docs/src/modules/myservice.md following the existing module documentation pattern.
Code Style
General Guidelines
- Use descriptive names
- Keep functions focused and small
- Add doc comments for public APIs
- Handle errors explicitly
OCaml Conventions
(* Use labeled arguments for clarity *)
let copy_file_to container ~src ~dest = ...
(* Use optional arguments with defaults *)
let stop ?(timeout = 10.0) container = ...
(* Prefer Result/Option over exceptions for expected failures *)
let find_port ports key =
List.assoc_opt key ports
(* Use Lwt.t for async operations *)
val start : config -> Container.t Lwt.t
Documentation Comments
(** [with_postgres ?config f] starts a PostgreSQL container,
runs [f] with the container and connection string,
then terminates the container.
@param config Optional configuration function
@return Result of [f]
Example:
{[
with_postgres (fun container conn_str ->
(* use PostgreSQL *)
Lwt.return_unit)
]}
*)
val with_postgres : ?config:(config -> config) ->
(Container.t -> string -> 'a Lwt.t) -> 'a Lwt.t
Testing Guidelines
Unit Tests
- Test pure functions without Docker
- Fast execution
- No external dependencies
let test_port_parsing _switch () =
let port = Port.of_string "8080/tcp" in
Alcotest.(check int) "port" 8080 port.port;
Lwt.return_unit
Integration Tests
- Test actual Docker operations
- Mark as `Slow
- Clean up resources
let test_container_lifecycle _switch () =
Container.with_container request (fun container ->
let* running = Container.is_running container in
Alcotest.(check bool) "running" true running;
Lwt.return_unit
)
Getting Help
- Open an issue for bugs or feature requests
- Start a discussion for questions
- Check existing issues before creating new ones
License
By contributing, you agree that your contributions will be licensed under the Apache License 2.0.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- Initial release of Testcontainers OCaml
- Core container lifecycle management
Container.start,Container.stop,Container.terminateContainer.with_containerfor automatic cleanup- Container exec, logs, and state inspection
- Container request builder with fluent API
- Port exposure and mapping
- Environment variables
- Volume mounts (bind, named, tmpfs)
- Commands and entrypoints
- Labels and names
- Wait strategies
for_listening_port- Wait for TCP portfor_log- Wait for log messagefor_log_regex- Wait for log patternfor_http- Wait for HTTP endpointfor_exec- Wait for command successfor_health_check- Wait for Docker health checkallandanycombinators- Configurable timeouts and poll intervals
- Docker network support
Network.createandNetwork.removeNetwork.with_networkfor automatic cleanup
- File operations
Container.copy_file_to- Copy file to containerContainer.copy_file_from- Copy file from containerContainer.copy_content_to- Copy string content to container
- Pre-built modules
testcontainers-postgres- PostgreSQLtestcontainers-mysql- MySQLtestcontainers-mongo- MongoDBtestcontainers-redis- Redistestcontainers-rabbitmq- RabbitMQtestcontainers-kafka- Apache Kafka (KRaft mode)testcontainers-elasticsearch- Elasticsearchtestcontainers-localstack- LocalStack (AWS emulation)testcontainers-memcached- Memcachedtestcontainers-mockserver- MockServer (HTTP mocking)
- Container inspection features
Container.container_ip- Get container IP addressContainer.container_ips- Get all container IPs (multi-network)Container.network_aliases- Get network aliasesContainer.gateway- Get gateway addressContainer.inspect- Full container inspection
- Log streaming
Container.follow_logs- Stream logs with callback
- Directory copy
Container.copy_dir_to- Copy directory to container
- Comprehensive test suite (85 tests)
- Documentation with mdbook
Technical Details
- Pure OCaml implementation using Unix sockets
- Lwt-based async operations
- Direct Docker Engine API communication (no CLI dependency)
- Supports Docker API v1.43
Version History
v1.0.0 (Planned)
First stable release with:
- All core features
- Ten database/service modules
- Complete documentation
- CI/CD integration examples
Migration Guides
Upgrading to 1.0.0
This is the first release, no migration needed.
Compatibility
| OCaml Version | Status |
|---|---|
| 5.2.x | Supported |
| 5.1.x | Supported |
| 5.0.x | Supported |
| < 5.0 | Not supported |
| Docker Version | Status |
|---|---|
| 24.x | Tested |
| 23.x | Should work |
| 20.x+ | Should work |
| Platform | Status |
|---|---|
| Linux | Fully supported |
| macOS (Docker Desktop) | Fully supported |
| Windows (WSL2) | Should work |
| Windows (native) | Not tested |