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.
- 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 (
RequiresandAfter). - 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.
go get github.com/AlveElde/testworld-gopackage 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)
}// Create a new World. Pass a directory path to enable logging, or "" to disable.
w := testworld.New(t, "/path/to/logs")
defer w.Destroy()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")
},
}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")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.
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.
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)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.
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
MIT