Sign in

    testcontainers

    Throwaway Docker containers for MoonBit integration tests

    docker
    testcontainers
    testing
    integration-testing
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    6 hours ago
    Downloads
    2

    Dependencies

    #Testcontainers for MoonBit

    CI Coverage Status

    Testcontainers for MoonBit — throwaway Docker containers for integration tests. Same services as production instead of mocks, fresh instance per test:

    ///|
    async test "postgres stores rows" {
    let pg = @postgres.run()
    defer pg.cleanup()
    let dsn = pg.connection_string(ssl_mode="disable")
    // ... connect to dsn and run queries ...
    }

    #Prerequisites

    • Docker daemon: running locally — macOS and Windows work through Docker Desktop or OrbStack

    #Installation

    Only supports the native target for now. Containers are started through the docker CLI.

    moon add xziheng/testcontainers

    Import the packages in moon.pkg only for tests:

    // your-project/moon.pkg import { "xziheng/testcontainers/postgres", "xziheng/testcontainers/mysql", "xziheng/testcontainers/s3mock", } for "test" import { "moonbitlang/async", } for "test"

    A package importing testcontainers needs supported_targets = "native" in its moon.pkg. To keep a package multi-target, put the container tests in a dedicated package:

    tests/ ├── moon.pkg # import testcontainers for "test"; supported_targets = "native" └── integration_test.mbt

    #Usage

    #Any image

    ///|
    async test "redis" {
    let container = @testcontainers.ContainerRequest::new("redis:7-alpine")
    .with_port(6379)
    .with_wait_for(@testcontainers.WaitStrategy::TcpPort(6379))
    .with_wait_timeout(60)
    .start()
    defer container.cleanup()
    let port = container.host_port(6379)
    let endpoint = container.endpoint(6379) // localhost:49155
    }

    Ready-made modules for common services live in their own packages: postgres, mysql and s3mock.

    #Scoped helper

    MoonBit tests have no automatic teardown callback, so either defercontainer.cleanup() or let with_container do it for you — cleanup also runs when the body fails or the test is cancelled:

    ///|
    async test "nginx" {
    @testcontainers.with_container(
    @testcontainers.ContainerRequest::new("nginx:1.27-alpine").with_port(80),
    container => {
    let url = "http://" + container.endpoint(80)
    // ...
    },
    )
    }

    #Reusable containers (experimental)

    A container marked with_reuse keeps running after the test — the next moontest, even in another process, re-attaches to it instead of starting a new one:

    ///|
    async test "postgres stores rows" {
    let request = @testcontainers.ContainerRequest::new("postgres:16-alpine")
    .with_port(5432)
    .with_env("POSTGRES_PASSWORD", "secret")
    .with_reuse()
    // ...
    }

    Reuse is a local-development opt-in and only activates when TESTCONTAINERS_REUSE_ENABLE=true is set; otherwise the flag is ignored and tests behave exactly as before. The container to reuse is chosen by a hash of the whole configuration, so changing any with_* option starts a fresh container. cleanup() intentionally leaves it running — use container.remove() to delete it, and reset service state yourself between tests.

    #Wait strategies

    with_wait_for controls when a started container counts as ready:

    StrategyReady when
    NoWaitImmediately (the default)
    TcpPort(port)port listens inside the container
    HttpGet(port, path, status)GET path on the mapped port returns status
    LogMessage(text)The container logs contain text
    Exec(cmd)cmd exits 0 inside the container
    HealthyThe image's HEALTHCHECK is healthy
    Exit(code)The container exited with code
    Seconds(n)n seconds passed
    All(...) / Any(...)All / at least one strategy holds

    #What you get back

    container.host_port(5432) // mapped host port
    container.endpoint(5432) // "localhost:49153"
    container.logs() // stdout + stderr
    container.exec(["psql", "-c", "SELECT 1"]) // code, stdout, stderr
    container.copy_to("schema.sql", "/tmp/schema.sql")
    container.copy_from("/tmp/dump.sql", "./dump.sql")
    container.stop() // docker stop
    container.remove() // docker rm --force --volumes
    container.cleanup() // best effort, never raises

    #Caveats

    • docker CLI is the backend. Commands run without a shell, but values passed with --env are briefly visible in the host process list.
    • A Ryuk sidecar reaps containers of killed test processes, like in the official implementations. Disable with TESTCONTAINERS_RYUK_DISABLED=true; tune with TESTCONTAINERS_RYUK_PRIVILEGED, RYUK_CONTAINER_IMAGE, RYUK_RECONNECTION_TIMEOUT. Reusable containers are exempt. Leftovers carry org.testcontainers.mbt=true: docker rm -f $(docker ps -aq --filter label=org.testcontainers.mbt=true).
    • Named volumes are not removed by cleanup(); anonymous volumes are.
    • Host networking, UDP ports and HTTPS wait strategies are not supported.
    • Default wait timeout is 60 s (90 s for MySQL, whose first start runs a full initialization).

    #Credits

    The first MoonBit implementation of testcontainers was ryota0624/moonbit_test_containers. This project started from that code and was updated for current MoonBit and moonbitlang/async. Behaviour follows testcontainers-go.

    Licensed under Apache-2.0. See NOTICE for attribution.

    TestContainerError

    pub(all) suberror TestContainerError {
    DockerNotAvailable(String)
    DockerCommandFailed(command~ : String, code~ : Int, stderr~ : String)
    ImagePullFailed(image~ : String, stderr~ : String)
    WaitTimeout(strategy~ : String, timeout_ms~ : Int, logs~ : String)
    PortNotMapped(container_port~ : Int, container_id~ : String)
    InvalidRequest(String)
    ContainerNotRunning(container_id~ : String, status~ : String)
    UnexpectedOutput(String)
    IoFailed(String)
    } derive(
    Debug
    )

    Every fallible testcontainers operation raises TestContainerError.

    The payloads carry enough context to debug a failed test without re-running it: the exact docker command, the exit status, the container's output and, for wait timeouts, the container logs.

    TestContainerError::output

    fn TestContainerError::output(self : TestContainerError, logger : &Logger) -> Unit

    Promote the trait methods to inherent methods so err.to_string() works without importing the traits.

    TestContainerError::to_string

    fn TestContainerError::to_string(self : TestContainerError) -> String

    Promote the trait methods to inherent methods so err.to_string() works without importing the traits.

    CommandOutput

    pub(all) struct CommandOutput {
    code : Int
    stdout : String
    stderr : String
    }

    Result of running a docker CLI command.

    stdout and stderr are kept separate on purpose: docker run -d prints the container id on stdout while image pull progress goes to stderr. Mixing them (as a 2>&1 shell would) is the classic source of "container id is not hex" bugs.

    Container

    pub struct Container {
    id : String
    request : ContainerRequest
    ports : Array[(Int, Int)]
    host : String
    reusable : Bool
    }

    A running container.

    Containers are started by ContainerRequest::start (or run) and removed again by cleanup. Because MoonBit tests have no automatic teardown hook, prefer the scoped helper:

    async test "nginx" {
    @testcontainers.with_container(request, container => {
    // ... use container ...
    })
    }

    Container::cleanup

    async fn Container::cleanup(self : Container) -> Unit noraise

    Best-effort cleanup: force-remove the container, ignoring errors.

    This is what with_container uses and what you should put in a defer block. Failure to clean up must not turn a passing test into a failing one, so problems are reported on stdout as a warning instead of being raised.

    Container::copy_from

    async fn Container::copy_from(self : Container, container_path : String, host_path : String) -> Unit raise TestContainerError

    Copy a file or directory out of the container (docker cp), e.g. a log or a database dump produced during the test.

    Container::copy_to

    async fn Container::copy_to(self : Container, host_path : String, container_path : String) -> Unit raise TestContainerError

    Copy a file or directory from the host into the container (docker cp). Useful to load fixtures or SQL dumps before running a test.

    Container::endpoint

    fn Container::endpoint(self : Container, container_port : Int) -> String raise TestContainerError

    host:port for a published container port, ready to be used in a connection string. IPv6 hosts are bracketed.

    Container::exec

    async fn Container::exec(self : Container, cmd : ArrayView[String]) -> ExecOutput raise TestContainerError

    Run a command inside the container.

    A non-zero exit status is returned in ExecOutput::code rather than raised, so this is safe to use for probing.

    Container::exec_checked

    async fn Container::exec_checked(self : Container, cmd : ArrayView[String]) -> String raise TestContainerError

    Run a command inside the container and fail unless it exits with status 0. Returns stdout.

    Container::exists

    async fn Container::exists(self : Container) -> Bool noraise

    Does the container still exist (running or stopped)? Never raises, so it is safe to use in assertions after cleanup.

    Container::host

    fn Container::host(self : Container) -> String

    Host name or IP that reaches the container's published ports.

    Normally localhost; override with the TESTCONTAINERS_HOST_OVERRIDE environment variable when Docker runs on another machine.

    Container::host_port

    fn Container::host_port(self : Container, container_port : Int) -> Int raise TestContainerError

    Host port mapped to container_port.

    Container::host_port_opt

    fn Container::host_port_opt(self : Container, container_port : Int) -> Int?

    Host port mapped to container_port, or None when the port was not published.

    Container::id

    fn Container::id(self : Container) -> String

    The container id, as printed by docker run -d (64 hex characters).

    Container::is_running

    async fn Container::is_running(self : Container) -> Bool raise TestContainerError

    Is the container still running?

    Container::logs

    async fn Container::logs(self : Container, options? : LogOptions) -> String raise TestContainerError

    Container logs, stdout and stderr combined.

    Docker keeps the two streams apart; they are concatenated here (stdout first). Use logs_split when the distinction matters. For readiness checks this is fine because application logs usually go to stderr.

    Container::logs_split

    async fn Container::logs_split(self : Container, options? : LogOptions) -> LogOutput raise TestContainerError

    Container logs with stdout and stderr kept apart.

    Container::remove

    async fn Container::remove(self : Container) -> Unit raise TestContainerError

    Force-remove the container and its anonymous volumes.

    Container::request

    fn Container::request(self : Container) -> ContainerRequest

    The request this container was started from.

    Container::status

    async fn Container::status(self : Container) -> String raise TestContainerError

    Container state as reported by docker (running, exited, ...).

    Container::stop

    async fn Container::stop(self : Container, timeout_seconds? : Int) -> Unit raise TestContainerError

    Ask docker to stop the container. Waits up to timeout_seconds for a clean shutdown.

    Container::stream_logs

    async fn Container::stream_logs(self : Container, f : (String) -> Unit raise) -> Unit

    Stream docker logs --follow line by line until the container exits or f raises.

    The callback receives each line without its trailing newline.

    Container::wait_for

    async fn Container::wait_for(self : Container, strategy : WaitStrategy, timeout_ms? : Int) -> Unit raise TestContainerError

    Wait for another readiness condition, using the request's timeout by default.

    Useful for multi-phase startup: wait for the port first, then for a migration to finish, and so on.

    Container::wait_ready

    async fn Container::wait_ready(self : Container) -> Unit raise TestContainerError

    Wait for the request's wait strategy. Called by ContainerRequest::start.

    ContainerRequest

    pub(all) struct ContainerRequest {
    image : String
    ports : Array[Int]
    env : Array[(String, String)]
    cmd : Array[String]
    entrypoint : Array[String]
    mounts : Array[Mount]
    labels : Array[(String, String)]
    wait : WaitStrategy
    wait_timeout_ms : Int
    auto_remove : Bool
    name : String?
    user : String?
    network : String?
    hostname : String?
    pull : PullPolicy
    reuse : Bool
    } derive(Eq,
    Debug
    )

    Everything needed to start one container.

    Build it with ContainerRequest::new and the with_* methods:

    let request = @testcontainers.ContainerRequest::new("postgres:16-alpine")
    .with_port(5432)
    .with_env("POSTGRES_PASSWORD", "secret")
    .with_wait_for(@testcontainers.WaitStrategy::TcpPort(5432))

    ContainerRequest::equal

    ContainerRequest::image_name

    fn ContainerRequest::image_name(self : ContainerRequest) -> String

    Repository part of the image reference (nginx for nginx:1.27).

    ContainerRequest::image_tag

    fn ContainerRequest::image_tag(self : ContainerRequest) -> String

    Tag part of the image reference (1.27 for nginx:1.27). Empty for digest references.

    ContainerRequest::new

    fn ContainerRequest::new(image : String) -> ContainerRequest

    A new request for image.

    image is a normal docker reference: nginx, postgres:16-alpine, ghcr.io/org/app:1.2. A missing tag means latest.

    ContainerRequest::not_equal

    fn ContainerRequest::not_equal(x : ContainerRequest, y : ContainerRequest) -> Bool

    ContainerRequest::start

    Start a container for this request and wait until it is ready.

    The caller owns the container and must call cleanup() eventually; use with_container to have that happen automatically.

    ContainerRequest::with_auto_remove

    fn ContainerRequest::with_auto_remove(self : ContainerRequest, auto_remove : Bool) -> ContainerRequest

    Ask docker to remove the container automatically when it exits.

    ContainerRequest::with_bind

    fn ContainerRequest::with_bind(self : ContainerRequest, host_path : String, container_path : String) -> ContainerRequest

    Bind-mount a host path into the container. host_path must be absolute.

    ContainerRequest::with_bind_read_only

    fn ContainerRequest::with_bind_read_only(self : ContainerRequest, host_path : String, container_path : String) -> ContainerRequest

    Like with_bind, but mounted read-only.

    ContainerRequest::with_cmd

    fn ContainerRequest::with_cmd(self : ContainerRequest, cmd : ArrayView[String]) -> ContainerRequest

    Set the command arguments passed after the image.

    ContainerRequest::with_entrypoint

    fn ContainerRequest::with_entrypoint(self : ContainerRequest, entrypoint : ArrayView[String]) -> ContainerRequest

    Set the entrypoint. The first element is passed to --entrypoint, the remaining elements are prepended to the command.

    ContainerRequest::with_env

    fn ContainerRequest::with_env(self : ContainerRequest, key : String, value : String) -> ContainerRequest

    Add one environment variable.

    ContainerRequest::with_envs

    fn ContainerRequest::with_envs(self : ContainerRequest, env : ArrayView[(String, String)]) -> ContainerRequest

    Add several environment variables.

    ContainerRequest::with_hostname

    fn ContainerRequest::with_hostname(self : ContainerRequest, hostname : String) -> ContainerRequest

    Set the container hostname.

    ContainerRequest::with_image

    fn ContainerRequest::with_image(self : ContainerRequest, image : String) -> ContainerRequest

    Replace the image reference.

    ContainerRequest::with_label

    fn ContainerRequest::with_label(self : ContainerRequest, key : String, value : String) -> ContainerRequest

    Add a container label.

    ContainerRequest::with_mount

    fn ContainerRequest::with_mount(self : ContainerRequest, mount : Mount) -> ContainerRequest

    Add an arbitrary mount.

    ContainerRequest::with_name

    fn ContainerRequest::with_name(self : ContainerRequest, name : String) -> ContainerRequest

    Set an explicit container name.

    ContainerRequest::with_network

    fn ContainerRequest::with_network(self : ContainerRequest, network : String) -> ContainerRequest

    Attach the container to a docker network.

    ContainerRequest::with_port

    fn ContainerRequest::with_port(self : ContainerRequest, port : Int) -> ContainerRequest

    Publish port on a random host port. Call it once per port.

    ContainerRequest::with_ports

    fn ContainerRequest::with_ports(self : ContainerRequest, ports : ArrayView[Int]) -> ContainerRequest

    Publish several ports on random host ports.

    ContainerRequest::with_pull_policy

    fn ContainerRequest::with_pull_policy(self : ContainerRequest, pull : PullPolicy) -> ContainerRequest

    Choose when the image is pulled.

    ContainerRequest::with_reuse

    fn ContainerRequest::with_reuse(self : ContainerRequest, reuse? : Bool) -> ContainerRequest

    Mark the container as reusable (experimental).

    A reusable container is looked up by a hash of its configuration before it is created: when a container with the same configuration already exists — running or stopped — it is re-attached instead of starting a new one. The container is not removed by cleanup() or when the test process exits; call Container::remove explicitly to delete it.

    Reuse only takes effect when TESTCONTAINERS_REUSE_ENABLE=true is set in the environment. Without it the flag is ignored, matching the other testcontainers implementations that treat reuse as a local-development opt-in which must not leak into CI.

    Caveat: a reused container keeps all of its state (rows, topics, keys). Tests that depend on a clean service must reset that state themselves.

    ContainerRequest::with_tmpfs

    fn ContainerRequest::with_tmpfs(self : ContainerRequest, container_path : String) -> ContainerRequest

    Mount an in-memory tmpfs at container_path.

    ContainerRequest::with_user

    fn ContainerRequest::with_user(self : ContainerRequest, user : String) -> ContainerRequest

    Set the user the container runs as.

    ContainerRequest::with_volume

    fn ContainerRequest::with_volume(self : ContainerRequest, volume_name : String, container_path : String) -> ContainerRequest

    Mount a docker named volume into the container.

    ContainerRequest::with_wait_for

    fn ContainerRequest::with_wait_for(self : ContainerRequest, wait : WaitStrategy) -> ContainerRequest

    Set the readiness condition.

    ContainerRequest::with_wait_timeout

    fn ContainerRequest::with_wait_timeout(self : ContainerRequest, timeout_seconds : Int) -> ContainerRequest

    Set the readiness timeout in seconds.

    ContainerRequest::with_wait_timeout_ms

    fn ContainerRequest::with_wait_timeout_ms(self : ContainerRequest, timeout_ms : Int) -> ContainerRequest

    Set the readiness timeout in milliseconds.

    ExecOutput

    pub(all) struct ExecOutput {
    code : Int
    stdout : String
    stderr : String
    } derive(Eq,
    Debug
    )

    Result of docker exec.

    ExecOutput::equal

    fn ExecOutput::equal(ExecOutput, ExecOutput) -> Bool

    ExecOutput::not_equal

    fn ExecOutput::not_equal(x : ExecOutput, y : ExecOutput) -> Bool

    LogOptions

    pub(all) struct LogOptions {
    tail : Int?
    timestamps : Bool
    since : String?
    until : String?
    } derive(Eq,
    Debug
    )

    Options for docker logs.

    LogOptions::equal

    fn LogOptions::equal(LogOptions, LogOptions) -> Bool

    LogOptions::new

    fn LogOptions::new(tail? : Int, timestamps? : Bool, since? : String, until? : String) -> LogOptions

    Default options: the whole log, no timestamps. Override any field with a labeled argument, e.g. LogOptions::new(tail=50, timestamps=true).

    LogOptions::not_equal

    fn LogOptions::not_equal(x : LogOptions, y : LogOptions) -> Bool

    LogOutput

    pub(all) struct LogOutput {
    stdout : String
    stderr : String
    } derive(Eq,
    Debug
    )

    LogOutput::equal

    fn LogOutput::equal(LogOutput, LogOutput) -> Bool

    LogOutput::not_equal

    fn LogOutput::not_equal(x : LogOutput, y : LogOutput) -> Bool

    Mount

    pub(all) struct Mount {
    source : MountSource
    target : String
    read_only : Bool
    } derive(Eq,
    Debug
    )

    A mount inside the container.

    Mount::equal

    fn Mount::equal(Mount, Mount) -> Bool

    Mount::not_equal

    fn Mount::not_equal(x : Mount, y : Mount) -> Bool

    Mount::to_repr

    MountSource

    pub(all) enum MountSource {
    Bind(String)
    Volume(String)
    Tmpfs
    } derive(Eq,
    Debug
    )

    Where a mount gets its content from.

    MountSource::equal

    fn MountSource::equal(MountSource, MountSource) -> Bool

    Promote trait methods to inherent methods (the compiler warns about implicit promotion otherwise).

    MountSource::not_equal

    fn MountSource::not_equal(x : MountSource, y : MountSource) -> Bool

    Promote trait methods to inherent methods (the compiler warns about implicit promotion otherwise).

    PullPolicy

    pub(all) enum PullPolicy {
    Missing
    Always
    Never
    } derive(Eq,
    Debug
    )

    Whether the image should be pulled before the container starts.

    PullPolicy::equal

    fn PullPolicy::equal(PullPolicy, PullPolicy) -> Bool

    PullPolicy::not_equal

    fn PullPolicy::not_equal(x : PullPolicy, y : PullPolicy) -> Bool

    WaitStrategy

    pub(all) enum WaitStrategy {
    NoWait
    Seconds(Int)
    TcpPort(Int)
    HttpGet(Int, String, Int)
    LogMessage(String)
    Exec(Array[String])
    Healthy
    Exit(Int)
    All(Array[WaitStrategy])
    Any(Array[WaitStrategy])
    } derive(Eq,
    Debug
    )

    A condition that must hold before a started container is considered ready.

    Every strategy except NoWait and Seconds is polled until it holds or the request's wait_timeout_ms (default: 60 seconds) expires.

    WaitStrategy::describe

    fn WaitStrategy::describe(self : WaitStrategy) -> String

    Human readable description, used in timeout messages.

    WaitStrategy::equal

    WaitStrategy::not_equal

    fn WaitStrategy::not_equal(x : WaitStrategy, y : WaitStrategy) -> Bool

    command_error_text

    fn command_error_text(output : CommandOutput) -> String

    Pick the most useful stream for an error message. docker is inconsistent: most failures go to stderr, but some (docker port on an unknown container on old versions, for example) go to stdout.

    docker

    async fn docker(args : ArrayView[String]) -> String raise TestContainerError

    Run a docker command and fail with DockerCommandFailed on a non-zero exit code. Returns stdout.

    docker_command

    async fn docker_command(args : ArrayView[String]) -> CommandOutput raise TestContainerError

    Run the docker CLI. Arguments are passed directly to the process, never through a shell, so spaces and quotes in env values and commands are safe.

    Returns the exit code and both output streams; a non-zero exit code is not turned into an error here because some callers (wait strategies, exec) treat it as data.

    image_exists

    async fn image_exists(image : String) -> Bool raise TestContainerError

    docker inspect for an image. Returns false when the image is not present locally.

    inspect_container

    async fn inspect_container(id : String) -> Json raise TestContainerError

    docker inspect for a container, parsed as JSON.

    join_host_port

    fn join_host_port(host : String, port : Int) -> String

    Join a host and a port, bracketing IPv6 addresses.

    pull_image

    async fn pull_image(image : String) -> Unit raise TestContainerError

    Pull image, unless it is already present locally and the request allows reusing it.

    render_command

    fn render_command(program : String, args : ArrayView[String]) -> String

    Render a command line for error messages.

    run

    async fn run(request : ContainerRequest) -> Container raise TestContainerError

    Start a container for request and wait until it is ready.

    short_id

    fn short_id(id : String) -> String

    Abbreviate a container id the way docker does (first 12 characters).

    split_image_ref

    fn split_image_ref(image : String) -> (String, String)

    Split a docker image reference into (repository, tag).

    Tag detection must only consider colons after the last slash, otherwise a registry with a port (localhost:5000/app) would be parsed as tag 5000/app. Digest references (repo@sha256:...) keep the whole reference as repository and report an empty tag.

    with_container

    async fn[T] with_container(request : ContainerRequest, f : async (Container) -> T) -> T

    Run f with a freshly started container and clean the container up afterwards, whether f succeeds, fails or is cancelled.