Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

FeatureDescription
Container LifecycleAutomatic start, stop, and cleanup
Port MappingDynamic port allocation with easy access
Wait StrategiesPort, log, HTTP, exec, and health check waiting
NetworksIsolated Docker networks for multi-container tests
File OperationsCopy files to and from containers
Pre-built ModulesPostgreSQL, 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:

  1. OCaml 5.0+ and opam 2.0+
  2. Docker installed and running
  3. 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"

  1. Ensure Docker is running: docker ps
  2. Check socket permissions: ls -la /var/run/docker.sock
  3. 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:

  1. Starts a Redis container
  2. Connects and sets a value
  3. Retrieves and verifies the value
  4. 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?

  1. Container Request: with_redis created a container configuration
  2. Image Pull: Docker pulled redis:7-alpine (first run only)
  3. Container Start: A new Redis container started
  4. Wait Strategy: Library waited until Redis was ready to accept connections
  5. Port Mapping: Docker assigned a random available port
  6. Your Code: The callback received the container and connection URI
  7. Cleanup: Container was stopped and removed after the callback completed

Next Steps

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:

  1. Creating a user
  2. Retrieving a user by ID
  3. 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

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

FunctionDescription
create imageCreate request with Docker image
with_exposed_port portExpose a port
with_exposed_ports portsExpose multiple ports
with_env key valueSet environment variable
with_envs pairsSet multiple env vars
with_cmd argsOverride CMD
with_entrypoint argsOverride ENTRYPOINT
with_working_dir dirSet working directory
with_user userRun as specific user
with_name nameSet container name
with_label key valueAdd label
with_labels pairsAdd multiple labels
with_mount mountAdd volume mount
with_privileged boolRun in privileged mode
with_wait_strategy strategySet readiness check
with_startup_timeout secondsMax wait time
with_auto_remove boolRemove 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

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

ServiceRecommended Strategy
PostgreSQLfor_log "ready to accept connections"
MySQLfor_log ~occurrence:2 "ready for connections"
MongoDBfor_log "Waiting for connections"
Redisfor_listening_port (Port.tcp 6379)
RabbitMQfor_log "Started" or HTTP to management port
Elasticsearchfor_http "/_cluster/health"
Nginxfor_listening_port (Port.tcp 80)
Custom Appfor_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:

  1. Check container logs:

    let* logs = Container.logs container in
    print_endline logs;
    
  2. Increase timeout:

    Wait_strategy.with_timeout 180.0  (* 3 minutes *)
    
  3. Try a simpler strategy first:

    Wait_strategy.for_listening_port (Port.tcp 5432)
    
  4. 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 network
  • host - Use host's network stack
  • none - 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):

  1. No network aliases - Containers are reachable by name only
  2. No custom subnets - Uses Docker's default subnet allocation
  3. No IPv6 - IPv4 only
  4. 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

FunctionDefaultDescription
with_imagepostgres:16-alpineDocker image
with_databasetestDatabase name
with_usernametestUsername
with_passwordtestPassword

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:

  1. Ensure you're using with_postgres (handles waiting automatically)
  2. Check the wait strategy completed successfully
  3. 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

FunctionDefaultDescription
with_imagemysql:8Docker image
with_databasetestDatabase name
with_usernametestUsername
with_passwordtestPassword
with_root_passwordrootRoot 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:

  1. First for the temporary server during initialization
  2. 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

FunctionDefaultDescription
with_imagemongo:7Docker image
with_usernameNoneAdmin username (optional)
with_passwordNoneAdmin 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

FunctionDefaultDescription
with_imageredis:7-alpineDocker 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:

  1. Network latency (shouldn't be an issue with local Docker)
  2. Large data volumes
  3. 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

FunctionDefaultDescription
with_imagerabbitmq:3-management-alpineDocker image
with_usernameguestUsername
with_passwordguestPassword
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

PortDescription
5672AMQP
15672Management UI (HTTP)
15692Prometheus 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

FunctionDefaultDescription
with_imageapache/kafka:3.7.0Docker 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

FunctionDefaultDescription
with_imageelasticsearch:8.12.0Docker image
with_passwordchangemeElastic 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

FunctionDefaultDescription
with_imagelocalstack/localstack:3.0Docker image
with_services["s3"]AWS services to enable
with_regionus-east-1AWS 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:

ServiceDescription
S3Object storage
SQSMessage queuing
SNSPub/sub messaging
DynamoDBNoSQL database
LambdaServerless functions
API GatewayREST APIs
CloudWatchMonitoring
IAMIdentity management
KMSKey management
Secrets ManagerSecrets 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

FunctionDefaultDescription
with_imagememcached:1.6-alpineDocker image
with_memory_mb64Memory 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

CommandDescription
setStore a value
getRetrieve a value
deleteRemove a value
incrIncrement numeric value
decrDecrement numeric value
flush_allClear all data
statsGet 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

FunctionDefaultDescription
with_imagemockserver/mockserver:5.15.0Docker image
with_log_levelINFOLog level (INFO, DEBUG, TRACE, WARN, ERROR)
with_max_expectationsNoneMaximum 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)
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))
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

VariableDefaultDescription
DOCKER_HOSTunix:///var/run/docker.sockDocker daemon socket
DOCKER_TLS_VERIFY-Enable TLS verification
DOCKER_CERT_PATH-Path to TLS certificates

Test Configuration

VariableDefaultDescription
SKIP_INTEGRATION_TESTSfalseSkip all integration tests
CONTAINER_TIMEOUT60Default startup timeout (seconds)
TESTCONTAINERS_RYUK_DISABLEDfalseDisable resource cleanup (not yet implemented)

CI Detection

VariableDescription
CISet to true in most CI environments
GITHUB_ACTIONSSet in GitHub Actions
GITLAB_CISet in GitLab CI
CIRCLECISet 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

  1. Create a branch from main
  2. Make your changes
  3. Add tests for new functionality
  4. Ensure all tests pass
  5. Update documentation if needed
  6. 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.terminate
    • Container.with_container for 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 port
    • for_log - Wait for log message
    • for_log_regex - Wait for log pattern
    • for_http - Wait for HTTP endpoint
    • for_exec - Wait for command success
    • for_health_check - Wait for Docker health check
    • all and any combinators
    • Configurable timeouts and poll intervals
  • Docker network support
    • Network.create and Network.remove
    • Network.with_network for automatic cleanup
  • File operations
    • Container.copy_file_to - Copy file to container
    • Container.copy_file_from - Copy file from container
    • Container.copy_content_to - Copy string content to container
  • Pre-built modules
    • testcontainers-postgres - PostgreSQL
    • testcontainers-mysql - MySQL
    • testcontainers-mongo - MongoDB
    • testcontainers-redis - Redis
    • testcontainers-rabbitmq - RabbitMQ
    • testcontainers-kafka - Apache Kafka (KRaft mode)
    • testcontainers-elasticsearch - Elasticsearch
    • testcontainers-localstack - LocalStack (AWS emulation)
    • testcontainers-memcached - Memcached
    • testcontainers-mockserver - MockServer (HTTP mocking)
  • Container inspection features
    • Container.container_ip - Get container IP address
    • Container.container_ips - Get all container IPs (multi-network)
    • Container.network_aliases - Get network aliases
    • Container.gateway - Get gateway address
    • Container.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 VersionStatus
5.2.xSupported
5.1.xSupported
5.0.xSupported
< 5.0Not supported
Docker VersionStatus
24.xTested
23.xShould work
20.x+Should work
PlatformStatus
LinuxFully supported
macOS (Docker Desktop)Fully supported
Windows (WSL2)Should work
Windows (native)Not tested