Skip to content

AlveElde/testworld-go

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

testworld-go

testworld-go is a system test framework based on testcontainers-go. Testcontainers is very flexible, but needs a lot of boilerplate for every test. Testworld cuts down on the boilerplate with an opinionated approach.

Features

  • Async with transparent await: Containers are always created in the background. The test only waits for the container being ready right before it is needed.
  • Dependencies: Declare ordering between containers with systemd-like semantics (Requires and After).
  • Automatic TLS: Every container receives a TLS certificate signed by a per-world CA and the CA is installed into the system trust store, enabling HTTPS between containers without extra configuration.
  • Automatic DNS: Every container gets a DNS name, with support for additional aliases and subdomains.
  • Network isolation: Block a container's internet access while keeping intra-world communication intact.
  • Replicas: Create groups of identical containers that share a DNS name via round-robin and can be addressed individually.
  • Log collection: Collect stdout/stderr and arbitrary files from all containers into a combined log.
  • Event timeline: Generates an ASCII Gantt chart showing the timing of every operation during the test.

Installation

go get github.com/AlveElde/testworld-go

Quick Start

package mytest

import (
    "testing"

    "github.com/AlveElde/testworld-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestWebCluster(t *testing.T) {
    w := testworld.New(t, "")
    defer w.Destroy()

    // Spin up 3 web servers and a client — all 4 containers are created in parallel
    servers := w.NewContainer(testworld.ContainerSpec{
        Image:      "caddy:latest",
        Replicas:   3,
        WaitingFor: wait.ForHTTP("/").WithPort("80/tcp"),
    })

    client := w.NewContainer(testworld.ContainerSpec{
        Image:     "alpine:latest",
        KeepAlive: true,
        After:     []testworld.WorldContainer{servers},
    })

    // The group name resolves to all 3 server IPs via DNS round-robin.
    // After ensures servers are ready before Exec runs.
    client.Exec([]string{"wget", "-q", "-O", "/dev/null", "http://" + servers.Name}, 0)
}

API Reference

World

// Create a new World. Pass a directory path to enable logging, or "" to disable.
w := testworld.New(t, "/path/to/logs")
defer w.Destroy()

ContainerSpec

spec := testworld.ContainerSpec{
    // Image to use (e.g., "alpine:latest")
    Image: "alpine:latest",

    // Or build from Dockerfile
    FromDockerfile: testcontainers.FromDockerfile{
        Context: "./docker/myapp",
    },

    // Create multiple identical containers as a group (default: 1)
    Replicas: 3,

    // Keep the container running indefinitely (uses "sleep infinity" when no Cmd is set)
    KeepAlive: true,

    // Override entrypoint
    Entrypoint: []string{"/entrypoint.sh"},

    // Command to run (overrides KeepAlive)
    Cmd: []string{"myapp", "--config", "/etc/myapp.conf"},

    // Environment variables
    Env: map[string]string{"DEBUG": "true"},

    // Exposed ports
    ExposedPorts: []string{"8080/tcp"},

    // Files to copy into the container
    Files: []testcontainers.ContainerFile{...},

    // Tmpfs mounts
    Tmpfs: map[string]string{"/tmp": ""},

    // Wait strategy for readiness
    WaitingFor: wait.ForHTTP("/health"),

    // Extra DNS aliases
    Aliases: []string{"db", "primary"},

    // Extra subdomain aliases (creates "tenant1.db", "tenant2.db", etc.)
    Subdomains: []string{"tenant1", "tenant2"},

    // Block creation until dependencies are ready (see Dependencies below)
    Requires: []testworld.WorldContainer{db},

    // Create in parallel, but block methods until dependencies are ready
    After: []testworld.WorldContainer{db},

    // Block internet access (see Network Isolation below)
    Isolated: true,

    // Advanced: modify container config
    ConfigModifier: func(c *container.Config) { ... },

    // Advanced: modify host config (mounts, privileged, etc.)
    HostConfigModifier: func(hc *container.HostConfig) { ... },

    // Optional: callback when container is destroyed
    OnDestroy: func(c testworld.WorldContainer) {
        // Collect log files from the container
        c.LogFile("/var/log/app.log")
    },
}

WorldContainer

Containers are created asynchronously. All methods on WorldContainer transparently wait for the container to be ready before proceeding:

// These return immediately — both containers are created in parallel
db := w.NewContainer(dbSpec)
app := w.NewContainer(appSpec)

// First method call on each container blocks until it is ready
db.Wait(wait.ForLog("database system is ready to accept connections"))
app.Wait(wait.ForHTTP("/healthz").WithPort("8080/tcp"))

// Execute a command (fails test if exit code doesn't match)
app.Exec([]string{"curl", "-sf", "http://localhost:8080/healthz"}, 0)

// Block until ready without performing any action
app.Await()

// Copy a file from container to the world log
app.LogFile("/var/log/app.log")

Replicas

Set Replicas to create a group of identical containers. All methods on the WorldContainer execute across every replica. The group name resolves to all replica IPs via Docker DNS round-robin:

servers := w.NewContainer(testworld.ContainerSpec{
    Image:      "caddy:latest",
    Replicas:   3,
    WaitingFor: wait.ForHTTP("/").WithPort("80/tcp"),
})

client := w.NewContainer(testworld.ContainerSpec{
    Image:     "alpine:latest",
    KeepAlive: true,
    After:     []testworld.WorldContainer{servers},
})

// The group name resolves to all 3 server IPs.
// After ensures servers are ready before Exec runs.
client.Exec([]string{"wget", "-q", "-O", "/dev/null", "http://" + servers.Name}, 0)

Each replica also gets its own unique name (servers.Name + "-1", -2, etc.) for individual addressing.

Dependencies

Use Requires and After to declare ordering between containers:

Field Creation Methods (Exec, Await, ...)
Requires Waits for deps Waits for deps
After Runs in parallel Waits for deps

After is the common choice — containers are created in parallel for speed, but methods block until dependencies are ready:

db := w.NewContainer(testworld.ContainerSpec{
    Image:      "postgres:latest",
    WaitingFor: wait.ForLog("ready to accept connections"),
})

app := w.NewContainer(testworld.ContainerSpec{
    Image: "myapp:latest",
    After: []testworld.WorldContainer{db},
})

// Both containers are created in parallel.
// Exec blocks until db is ready.
app.Exec([]string{"curl", "-sf", "http://localhost:8080/healthz"}, 0)

Requires delays creation itself, useful when a container can't be built without the dependency (e.g., pulling from a registry that another container provides):

registry := w.NewContainer(registrySpec)

app := w.NewContainer(testworld.ContainerSpec{
    Image:    "myregistry:5000/myapp:latest",
    Requires: []testworld.WorldContainer{registry},
})

If any dependency fails, containers that depend on it also fail.

Network Isolation

Set Isolated: true on a ContainerSpec to block that container's access to the internet while keeping intra-world communication intact.

Internally, testworld maintains two Docker bridge networks:

Network Internet Intra-world
External (regular bridge) yes yes
Internal (--internal bridge) no yes

Every container joins the internal network. Non-isolated containers also join the external network, gaining internet access via its gateway. Isolated containers join only the internal network — Docker omits the default gateway for --internal networks, so any attempt to reach an external address fails immediately with "Network unreachable".

// A mock server that should never call out to the real internet
mock := w.NewContainer(testworld.ContainerSpec{
    Image:    "my-mock-server:latest",
    Isolated: true,
})

// A regular client that can reach both the internet and the mock server
client := w.NewContainer(testworld.ContainerSpec{
    Image:     "alpine:latest",
    KeepAlive: true,
    After:     []testworld.WorldContainer{mock},
})

// The client can reach the mock server by name
client.Exec([]string{"wget", "-q", "-O", "/dev/null", "http://" + mock.Name + ":8080/"}, 0)

// The mock server cannot reach the internet
mock.Exec([]string{"ping", "-c", "1", "-W", "2", "8.8.8.8"}, 1)

TLS

Every world generates an ephemeral certificate authority. Each container receives a leaf certificate signed by that CA, and the CA is installed into the system trust store. This means containers can talk to each other over HTTPS without any extra configuration:

caddyfile := `{
    auto_https off
}
:8443 {
    tls /tls/cert.pem /tls/key.pem
    respond "Hello TLS"
}`

server := w.NewContainer(testworld.ContainerSpec{
    Image: "caddy:latest",
    Files: []testcontainers.ContainerFile{{
        Reader:            strings.NewReader(caddyfile),
        ContainerFilePath: "/etc/caddy/Caddyfile",
    }},
    WaitingFor: wait.ForLog("serving initial configuration"),
})

client := w.NewContainer(testworld.ContainerSpec{
    Image:     "alpine/curl:latest",
    KeepAlive: true,
    Requires:  []testworld.WorldContainer{server},
})

client.Exec([]string{"curl", "-sf", "https://" + server.Name + ":8443/"}, 0)

Certificates are mounted at well-known paths inside every container:

Path Description
/tls/ca.crt World CA certificate
/tls/cert.pem Container's leaf certificate
/tls/key.pem Container's private key

The environment variables TLS_CA_CERT, TLS_CERT, and TLS_KEY point to these paths. Leaf certificates include SANs for all of the container's DNS names (container name, replica names, and any extra aliases) plus localhost and 127.0.0.1.

World Log

When a log path is provided, the World creates:

  • An ASCII Gantt chart showing event timelines
  • A combined log file with all container outputs

Example output:

Event Timeline (Total: 3.284s):
ID  | Process Visualization
----|--------------------------------------------------------------------------------
000 |[################] (0.672s) World: Create
001 |                [#############################] (1.199s) World: add caddy container TestReplicaHTTPClients-caddy-1-1
002 |                [#############################] (1.210s) TestReplicaHTTPClients-caddy-1: await
003 |                [############################] (1.173s) World: add caddy container TestReplicaHTTPClients-caddy-1-2
004 |                [###########################] (1.121s) World: add curl container TestReplicaHTTPClients-curl-1-1
005 |                [#############################] (1.210s) World: add caddy container TestReplicaHTTPClients-caddy-1-3
006 |                [######################] (0.941s) World: add curl container TestReplicaHTTPClients-curl-1-2
007 |                [###################] (0.804s) World: add curl container TestReplicaHTTPClients-curl-1-3
008 |                                             [##] (0.102s) TestReplicaHTTPClients-curl-1-3: exec curl http://TestReplicaHTTPClients-caddy-1:80/
009 |                                             [##] (0.102s) TestReplicaHTTPClients-curl-1-1: exec curl http://TestReplicaHTTPClients-caddy-1:80/
010 |                                             [##] (0.102s) TestReplicaHTTPClients-curl-1-2: exec curl http://TestReplicaHTTPClients-caddy-1:80/
011 |                                                [#] (0.000s) TestReplicaHTTPClients-caddy-1: await
012 |                                                [#] (0.000s) TestReplicaHTTPClients-curl-1: await
013 |                                                [###############################] (1.300s) World: destroy
014 |                                                [#] (0.003s) TestReplicaHTTPClients-curl-1-3: logs
015 |                                                [#] (0.003s) TestReplicaHTTPClients-curl-1-1: logs
016 |                                                [#] (0.005s) TestReplicaHTTPClients-caddy-1-3: logs
017 |                                                [#] (0.005s) TestReplicaHTTPClients-curl-1-2: logs
018 |                                                [#] (0.006s) TestReplicaHTTPClients-caddy-1-1: logs
019 |                                                [#] (0.005s) TestReplicaHTTPClients-caddy-1-2: logs

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages