Sign in

    moonbit_test_containers

    Test containers implementation for MoonBit

    containers
    docker
    test
    Download zip
    Author
    Version
    0.0.5
    License
    Apache-2.0
    Last updated
    6 months ago
    Downloads
    495

    Dependencies

    #Testcontainers for MoonBit

    A MoonBit implementation of Testcontainers, a library for creating and managing Docker containers for integration testing.

    #Features

    • Simple Container Management: Start, stop, and remove Docker containers programmatically
    • Port Mapping: Automatically map container ports to random host ports
    • Environment Variables: Configure containers with custom environment variables
    • Volume Mounting: Mount host files and directories into containers
    • Fluent API: Builder pattern for intuitive container configuration
    • Async Support: Built on MoonBit's async primitives for efficient I/O
    • Wait Strategies: TCP port, HTTP endpoint, log message, and simple delay strategies
    • Container Logs: Retrieve and stream container logs in real-time
    • Multi-Backend Support: Works with both Native (LLVM) and JavaScript (Node.js) backends
    • Helpful Error Messages: Provides clear installation instructions if Docker CLI is not found

    #Requirements

    • MoonBit: Latest version with multi-backend support
    • Docker: Docker must be installed and running on your system
      • The library will provide helpful installation instructions if Docker CLI is not found
    • For Native Backend: Linux or macOS platform
    • For JavaScript Backend: Node.js v14 or later

    #Backend Support

    This library supports both Native and JavaScript backends, allowing you to run tests in different environments.

    #Native Backend (LLVM)

    The Native backend uses native process execution via @process for optimal performance.

    Advantages:
    • Best performance
    • Direct system calls
    • Recommended for production use and CI/CD

    Usage:
    moon test --target native

    #JavaScript Backend (Node.js)

    The JavaScript backend uses Node.js child_process module via FFI (Foreign Function Interface).

    Advantages:
    • Works in Node.js environments
    • Same API and test compatibility as Native backend
    • Useful for JavaScript-focused development workflows

    Requirements:
    • Node.js v14 or later
    • Note: Browser environments are not supported (requires Node.js child_process module)

    Usage:
    moon test --target js

    Performance: The JavaScript backend is typically 1.2-1.5x slower than the Native backend due to Node.js overhead, but this is acceptable for integration testing scenarios.

    #Backend Implementation Details

    The Docker CLI execution is implemented separately for each backend using MoonBit's Configuration Attribute feature:

    • Native: docker_native.mbt uses @process.collect_output_merged
    • JavaScript: docker_js.mbt uses Node.js child_process.execSync via FFI

    Both implementations provide the same interface and are automatically selected based on the build target. All integration tests work identically on both backends.

    #Installation

    Add to your moon.mod.json:

    { "deps": { "ryota0624/moonbit_test_containers": "0.1.0", "moonbitlang/async": "0.16.5" } }

    And in your moon.pkg.json:

    { "import": [ "moonbitlang/async", "moonbitlang/async/process", "moonbitlang/async/http" ] }

    #Usage

    #Basic Example

    // Create and start a Redis container
    let container = ContainerImage::new("redis", "7.2.4")
    .with_exposed_port(6379)
    .with_env_var("REDIS_PORT", "6379")
    .start()

    match container {
    Ok(c) => {
    // Get the host port
    match c.get_host_port(6379) {
    Some(port) => println("Redis running on port: \{port}")
    None => println("Port not mapped")
    }

    // Cleanup when done
    let _ = c.cleanup()
    }
    Err(e) => println("Error: \{e.to_string()}")
    }

    #Advanced Configuration

    let container = ContainerImage::new("postgres", "16")
    .with_exposed_port(5432)
    .with_env_var("POSTGRES_USER", "testuser")
    .with_env_var("POSTGRES_PASSWORD", "testpass")
    .with_env_var("POSTGRES_DB", "testdb")
    .start()

    match container {
    Ok(c) => {
    println("PostgreSQL container: \{c.get_id()}")

    // Use the container in your tests
    // ...

    // Stop and remove separately
    let _ = c.stop()
    let _ = c.remove()
    }
    Err(e) => println("Failed to start: \{e.to_string()}")
    }

    #Volume Mounting

    Mount host files and directories into containers for configuration, initialization scripts, or data sharing.

    #Basic File Mount

    ///|
    let container = ContainerImage::new("nginx", "1.25-alpine")
    .with_exposed_port(80)
    .with_volume("./nginx.conf", "/etc/nginx/nginx.conf")
    .start()

    #Read-Only Mount

    ///|
    let container = ContainerImage::new("myapp", "latest")
    .with_volume_ro("./config", "/app/config")
    .start()

    #Multiple Volumes

    ///|
    let container = ContainerImage::new("myapp", "latest")
    .with_volume_ro("./config", "/app/config")
    .with_volume("./data", "/app/data")
    .with_volume("./logs", "/app/logs")
    .start()

    #PostgreSQL Initialization Script

    Mount SQL scripts to initialize your database:

    let container = ContainerImage::new("postgres", "16")
    .with_exposed_port(5432)
    .with_env_var("POSTGRES_USER", "test")
    .with_env_var("POSTGRES_PASSWORD", "test")
    .with_env_var("POSTGRES_DB", "testdb")
    .with_volume("./init.sql", "/docker-entrypoint-initdb.d/init.sql")
    .with_wait_for(WaitStrategy::TcpPort(5432))
    .with_wait_timeout(60)
    .start()

    match container {
    Ok(c) => {
    // PostgreSQL is now ready with your schema initialized
    match c.get_host_port(5432) {
    Some(port) => println("PostgreSQL ready on port: \{port}")
    None => println("Port not mapped")
    }
    let _ = c.cleanup()
    }
    Err(e) => println("Failed: \{e.to_string()}")
    }

    #Wait Strategies

    Wait strategies ensure that containers are fully ready before your tests begin. This is essential for services that take time to initialize (like databases, web servers, and APIs).

    #TCP Port Wait

    Wait for a TCP port to become available:

    ///|
    let container = ContainerImage::new("redis", "7.2.4")
    .with_exposed_port(6379)
    .with_wait_for(WaitStrategy::TcpPort(6379))
    .with_wait_timeout(30) // optional, defaults to 60 seconds
    .start()

    #HTTP Endpoint Wait

    Wait for an HTTP endpoint to return a specific status code:

    let container = ContainerImage::new("nginx", "1.25-alpine")
    .with_exposed_port(80)
    .with_wait_for(WaitStrategy::HttpGet("/", 200))
    .with_wait_timeout(30)
    .start()

    match container {
    Ok(c) => {
    // Nginx is now ready and responding to HTTP requests
    match c.get_host_port(80) {
    Some(port) => println("Nginx ready on port: \{port}")
    None => println("Port not mapped")
    }
    let _ = c.cleanup()
    }
    Err(e) => println("Failed: \{e.to_string()}")
    }

    You can also wait for custom paths and status codes:

    // Wait for a health check endpoint to return 200

    ///|
    let container = ContainerImage::new("myapp", "latest")
    .with_exposed_port(8080)
    .with_wait_for(WaitStrategy::HttpGet("/health", 200))
    .with_wait_timeout(60)
    .start()

    #Simple Delay

    Wait for a fixed number of seconds:

    ///|
    let container = ContainerImage::new("alpine", "3.19")
    .with_cmd(["sleep", "10"])
    .with_wait_for(WaitStrategy::Seconds(5))
    .start()

    #Log Message Wait

    Wait for a specific message to appear in container logs:

    ///|
    let container = ContainerImage::new("postgres", "15-alpine")
    .with_exposed_port(5432)
    .with_env_var("POSTGRES_PASSWORD", "password")
    .with_wait_for(
    WaitStrategy::LogMessage("database system is ready to accept connections"),
    )
    .with_wait_timeout(60)
    .start()

    This is particularly useful for databases and services that take time to initialize. The strategy polls container logs every 500ms and performs a substring match.

    #PostgreSQL Example

    PostgreSQL takes several seconds to start. You can use either TCP port wait or log message wait:

    Using TCP Port Wait:

    let container = ContainerImage::new("postgres", "16")
    .with_exposed_port(5432)
    .with_env_var("POSTGRES_USER", "test")
    .with_env_var("POSTGRES_PASSWORD", "test")
    .with_env_var("POSTGRES_DB", "test")
    .with_wait_for(WaitStrategy::TcpPort(5432))
    .with_wait_timeout(60)
    .start()

    match container {
    Ok(c) => {
    // PostgreSQL is now ready to accept connections
    match c.get_host_port(5432) {
    Some(port) => println("PostgreSQL ready on port: \{port}")
    None => println("Port not mapped")
    }
    let _ = c.cleanup()
    }
    Err(e) => println("Failed: \{e.to_string()}")
    }

    Using Log Message Wait (recommended for databases):

    let container = ContainerImage::new("postgres", "15-alpine")
    .with_exposed_port(5432)
    .with_env_var("POSTGRES_PASSWORD", "password")
    .with_wait_for(
    WaitStrategy::LogMessage("database system is ready to accept connections")
    )
    .with_wait_timeout(60)
    .start()

    match container {
    Ok(c) => {
    // PostgreSQL is fully initialized and ready
    match c.get_host_port(5432) {
    Some(port) => println("PostgreSQL ready on port: \{port}")
    None => println("Port not mapped")
    }
    let _ = c.cleanup()
    }
    Err(e) => println("Failed: \{e.to_string()}")
    }

    Log message wait is more reliable for databases as it waits for the actual initialization message rather than just port availability.

    #Other Services with Log Message Wait

    Redis:

    ///|
    let container = ContainerImage::new("redis", "7.2.4")
    .with_exposed_port(6379)
    .with_wait_for(WaitStrategy::LogMessage("Ready to accept connections"))
    .with_wait_timeout(30)
    .start()

    Nginx:

    ///|
    let container = ContainerImage::new("nginx", "1.25-alpine")
    .with_exposed_port(80)
    .with_wait_for(WaitStrategy::LogMessage("start worker processes"))
    .with_wait_timeout(30)
    .start()

    #Container Logs

    Retrieve and stream container logs for debugging and monitoring.

    #Basic Log Retrieval

    Get all logs from a container:

    match container.get_logs() {
    Ok(logs) => println("Container logs:\n\{logs}")
    Err(e) => println("Failed to get logs: \{e.to_string()}")
    }

    #Log Options

    Customize log retrieval with options:

    // Get last 100 lines with timestamps
    let options = LogOptions::default()
    .with_tail(100)
    .with_timestamps(true)

    match container.get_logs_with_options(options) {
    Ok(logs) => println("Recent logs:\n\{logs}")
    Err(e) => println("Failed to get logs: \{e.to_string()}")
    }

    Available options:
    • .with_tail(lines: Int): Get only the last N lines
    • .with_timestamps(enabled: Bool): Include timestamps
    • .with_since(timestamp: String): Get logs since a specific time
    • .with_until(timestamp: String): Get logs until a specific time

    #Log Streaming

    Stream logs in real-time with a callback:

    // Stream logs line by line
    let callback = fn(line: String) {
    println("Log: \{line}")
    }

    match container.stream_logs(callback) {
    Ok(_) => println("Streaming complete")
    Err(e) => println("Failed to stream: \{e.to_string()}")
    }

    Stream with options:

    // Stream only recent logs
    let options = LogOptions::default().with_tail(50)

    match container.stream_logs(callback, options=options) {
    Ok(_) => println("Streaming complete")
    Err(e) => println("Failed to stream: \{e.to_string()}")
    }

    #Debugging Failed Containers

    let container = ContainerImage::new("myapp", "latest")
    .with_exposed_port(8080)
    .start()

    match container {
    Ok(c) => {
    // If something goes wrong, check the logs
    match c.get_logs() {
    Ok(logs) => {
    if logs.contains("ERROR") {
    println("Container error detected:\n\{logs}")
    }
    }
    Err(e) => println("Failed to get logs: \{e.to_string()}")
    }
    let _ = c.cleanup()
    }
    Err(e) => println("Failed to start: \{e.to_string()}")
    }

    #API Reference

    #ContainerImage

    Create and configure a container image:

    • ContainerImage::new(name: String, tag: String) -> ContainerImage
    • .with_exposed_port(port: Int) -> ContainerImage
    • .with_env_var(key: String, value: String) -> ContainerImage
    • .with_cmd(cmd: Array[String]) -> ContainerImage
    • .with_volume(host_path: String, container_path: String) -> ContainerImage - Mount a volume (read-write)
    • .with_volume_ro(host_path: String, container_path: String) -> ContainerImage - Mount a volume as read-only
    • .with_wait_for(strategy: WaitStrategy) -> ContainerImage
    • .with_wait_timeout(timeout: Int) -> ContainerImage - timeout in seconds (default: 60)
    • .start() -> Result[Container, TestContainerError] (async)

    #WaitStrategy

    Strategies for waiting until a container is ready:

    • WaitStrategy::TcpPort(port: Int) - Wait for TCP port to be open
    • WaitStrategy::HttpGet(path: String, expected_status: Int) - Wait for HTTP endpoint to return expected status code
    • WaitStrategy::LogMessage(message: String) - Wait for a specific message to appear in container logs (polls every 500ms, checks last 100 lines)
    • WaitStrategy::Seconds(seconds: Int) - Wait for a fixed duration

    #Container

    Manage running containers:

    • .get_id() -> String
    • .get_host_port(container_port: Int) -> Option[Int]
    • .stop() -> Result[Unit, TestContainerError] (async)
    • .remove() -> Result[Unit, TestContainerError] (async)
    • .cleanup() -> Result[Unit, TestContainerError] (async) - stops and removes the container
    • .get_logs() -> Result[String, TestContainerError] (async)
    • .get_logs_with_options(options: LogOptions) -> Result[String, TestContainerError] (async)
    • .stream_logs(callback: (String) -> Unit, options?: LogOptions) -> Result[Unit, TestContainerError] (async)

    #LogOptions

    Configure log retrieval:

    • LogOptions::default() -> LogOptions
    • .with_tail(lines: Int) -> LogOptions - Get last N lines
    • .with_timestamps(enabled: Bool) -> LogOptions - Include timestamps
    • .with_since(timestamp: String) -> LogOptions - Get logs since timestamp
    • .with_until(timestamp: String) -> LogOptions - Get logs until timestamp
    • .with_follow(enabled: Bool) -> LogOptions - Follow log output (only for stream_logs)

    Note: The .with_follow() option cannot be used with get_logs_with_options(). Use stream_logs() for following logs.

    #Building and Testing

    Build the project:

    # Native backend moon check --target native # JavaScript backend moon check --target js

    Run tests (requires Docker):

    # Native backend (recommended) moon test --target native # JavaScript backend (requires Node.js) moon test --target js # Both backends moon test --target native && moon test --target js

    #Current Limitations

    • JavaScript Backend: Requires Node.js environment; browser environments are not supported
    • Wait Strategies: HTTP endpoint wait uses the first exposed port
    • HTTP Support: Only HTTP (not HTTPS), GET method only, status code checking only
    • Network Management: Custom networks are not supported yet
    • Volume Mounting: Only bind mounts are supported; named volumes and tmpfs are not yet supported
    • Platform: Native backend supports Linux and macOS only (Windows support requires investigation)

    #Future Enhancements

    #License

    Apache-2.0

    #Contributing

    Contributions are welcome! Please feel free to submit issues and pull requests.

    #Acknowledgments

    This project is inspired by the Testcontainers project, with implementations in multiple languages including testcontainers-rs (Rust) and testcontainers-go (Go).

    Container

    pub struct Container {
    id : String
    image : ContainerImage
    port_mappings : Array[(Int, Int)]
    }

    Running container instance

    Container::cleanup

    async fn Container::cleanup(self : Container) -> Result[Unit, TestContainerError]

    Stop and remove the container (cleanup)

    Container::get_host_port

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

    Get the host port mapped to a container port

    Container::get_id

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

    Get the container ID

    Container::get_logs

    async fn Container::get_logs(self : Container) -> Result[String, TestContainerError] noraise

    Get all logs from the container

    Container::get_logs_with_options

    async fn Container::get_logs_with_options(self : Container, options : LogOptions) -> Result[String, TestContainerError]

    Get logs with specified options

    Container::remove

    async fn Container::remove(self : Container) -> Result[Unit, TestContainerError]

    Remove the container

    Container::stop

    async fn Container::stop(self : Container) -> Result[Unit, TestContainerError]

    Stop the container

    Container::stream_logs

    async fn Container::stream_logs(self : Container, callback : (String) -> Unit, options? : LogOptions) -> Result[Unit, TestContainerError]

    Stream logs line by line with a callback Note: This implementation collects all output first, then processes it line by line. It does not support true streaming for follow=true mode.

    ContainerImage

    pub struct ContainerImage {
    name : String
    tag : String
    exposed_ports : Array[Int]
    env_vars : Array[(String, String)]
    cmd : Array[String]
    wait_strategy : WaitStrategy?
    wait_timeout : Int
    volumes : Array[VolumeMount]
    }

    Container image configuration

    ContainerImage::new

    fn ContainerImage::new(name : String, tag : String) -> ContainerImage

    Create a new container image configuration

    ContainerImage::start

    async fn ContainerImage::start(self : ContainerImage) -> Result[Container, TestContainerError] noraise

    Start a container based on the image configuration

    ContainerImage::with_cmd

    fn ContainerImage::with_cmd(self : ContainerImage, cmd : Array[String]) -> ContainerImage

    Set the command to run in the container

    ContainerImage::with_env_var

    fn ContainerImage::with_env_var(self : ContainerImage, key : String, value : String) -> ContainerImage

    Add an environment variable to the container configuration

    ContainerImage::with_exposed_port

    fn ContainerImage::with_exposed_port(self : ContainerImage, port : Int) -> ContainerImage

    Add an exposed port to the container configuration

    ContainerImage::with_volume

    fn ContainerImage::with_volume(self : ContainerImage, host_path : String, container_path : String) -> ContainerImage

    Add a volume mount to the container configuration

    ContainerImage::with_volume_ro

    fn ContainerImage::with_volume_ro(self : ContainerImage, host_path : String, container_path : String) -> ContainerImage

    Add a read-only volume mount to the container configuration

    ContainerImage::with_wait_for

    fn ContainerImage::with_wait_for(self : ContainerImage, strategy : WaitStrategy) -> ContainerImage

    Set the wait strategy for container readiness

    ContainerImage::with_wait_timeout

    fn ContainerImage::with_wait_timeout(self : ContainerImage, timeout : Int) -> ContainerImage

    Set the wait timeout in seconds (default: 60)

    LogOptions

    pub struct LogOptions {
    follow : Bool
    timestamps : Bool
    tail : Int?
    since : String?
    until : String?
    }

    Log options for container logs

    LogOptions::default

    fn LogOptions::default() -> LogOptions

    Create default LogOptions

    LogOptions::with_follow

    fn LogOptions::with_follow(self : LogOptions, follow : Bool) -> LogOptions

    Set follow option

    LogOptions::with_since

    fn LogOptions::with_since(self : LogOptions, timestamp : String) -> LogOptions

    Set since option

    LogOptions::with_tail

    fn LogOptions::with_tail(self : LogOptions, lines : Int) -> LogOptions

    Set tail option

    LogOptions::with_timestamps

    fn LogOptions::with_timestamps(self : LogOptions, timestamps : Bool) -> LogOptions

    Set timestamps option

    LogOptions::with_until

    fn LogOptions::with_until(self : LogOptions, timestamp : String) -> LogOptions

    Set until option

    TestContainerError

    pub(all) enum TestContainerError {
    DockerCommandFailed(String)
    ContainerNotFound(String)
    PortMappingFailed(String)
    InvalidConfiguration(String)
    WaitStrategyTimeout(String)
    } derive(Eq, Show)

    Error type for testcontainers operations

    TestContainerError::to_string

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

    Convert error to string for display

    VolumeMount

    pub struct VolumeMount {
    host_path : String
    container_path : String
    read_only : Bool
    }

    Volume mount configuration

    WaitStrategy

    pub(all) enum WaitStrategy {
    TcpPort(Int)
    Seconds(Int)
    HttpGet(String, Int)
    LogMessage(String)
    } derive(Eq, Show)

    Wait strategy for container readiness

    exec_docker

    async fn exec_docker(args : Array[String]) -> Result[String, TestContainerError] noraise

    Execute docker command using native process execution

    parse_port_mapping

    fn parse_port_mapping(output : String) -> Int?

    Parse port mapping output from docker port command Expected format: "0.0.0.0:32768" or "[::]:32768"

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io