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