test(e2e): configs, commit, export and logs scenarios

ImageExists and FileExists join the vocabulary, giving commit and
export a real observable: the legacy tests only asserted the commands
exited 0, the scenarios verify the committed image and the exported
archive actually exist. Both also drop their fixed image tags and
cwd-relative tar outputs for project-scoped tags and temp files, so
parallel runs cannot collide and nothing is left behind in the repo.

TestLocalComposeLogs waits for the echo service to exit before reading
logs (Eventually), closing the race the legacy test tolerated. The
follow and large-logs tests stay legacy: both drive a long-running
command in the background. ps_test.go stays legacy as well — it
verifies ps table/JSON formatting, which is inherently output-shaped.

Fixtures configs/, commit/ and export/ are removed.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-18 16:49:56 +02:00
committed by Nicolas De loof
parent 3923931b8c
commit cd1c0ca6ca
12 changed files with 147 additions and 164 deletions
+34
View File
@@ -24,6 +24,7 @@ package e2e
import (
"fmt"
"os"
"runtime"
"slices"
"strings"
@@ -376,6 +377,39 @@ func OneOffsRemoved(service string) Check {
}
}
// ImageExists expects an image with the given reference to be present in the
// local store.
func ImageExists(ref string) Check {
return Check{
name: fmt.Sprintf("image %q exists", ref),
fn: func(ctx *CheckContext) error {
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "image", "inspect", "--format", "{{.Id}}", ref))
if res.ExitCode != 0 {
return fmt.Errorf("not found: %s", strings.TrimSpace(res.Combined()))
}
return nil
},
}
}
// FileExists expects a non-empty file at the given host path, e.g. the output
// of an export command.
func FileExists(path string) Check {
return Check{
name: fmt.Sprintf("file %q exists", path),
fn: func(ctx *CheckContext) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() == 0 {
return fmt.Errorf("file is empty")
}
return nil
},
}
}
// LabelSet expects every container of the service to carry a non-empty label.
func LabelSet(service, key string) Check {
return Check{
+27 -66
View File
@@ -21,73 +21,34 @@ import (
)
func TestCommit(t *testing.T) {
const projectName = "e2e-commit-service"
c := NewParallelCLI(t)
cleanup := func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans")
}
t.Cleanup(cleanup)
cleanup()
c.RunDockerComposeCmd(t, "-f", "./fixtures/commit/compose.yaml", "--project-name", projectName, "up", "-d", "service")
c.RunDockerComposeCmd(
t,
"--project-name",
projectName,
"commit",
"-a",
"John Hannibal Smith <[email protected]>",
"-c",
"ENV DEBUG=true",
"-m",
"sample commit",
"service",
"service:latest",
)
s := NewScenario(t, "commit must produce an image from the service's container")
image := s.Project() + ":latest"
s.Defer(DockerCmd("image", "rm", "-f", image).MayFail()).
Step("up starts the service",
ComposeCmd("up", "-d", "service"),
ServiceState("service", "running")).
Step("commit turns the container into a tagged image",
ComposeCmd("commit",
"-a", "John Hannibal Smith <[email protected]>",
"-c", "ENV DEBUG=true",
"-m", "sample commit",
"service", image),
ImageExists(image))
}
func TestCommitWithReplicas(t *testing.T) {
const projectName = "e2e-commit-service-with-replicas"
c := NewParallelCLI(t)
cleanup := func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans")
}
t.Cleanup(cleanup)
cleanup()
c.RunDockerComposeCmd(t, "-f", "./fixtures/commit/compose.yaml", "--project-name", projectName, "up", "-d", "service-with-replicas")
c.RunDockerComposeCmd(
t,
"--project-name",
projectName,
"commit",
"-a",
"John Hannibal Smith <[email protected]>",
"-c",
"ENV DEBUG=true",
"-m",
"sample commit",
"--index=1",
"service-with-replicas",
"service-with-replicas:1",
)
c.RunDockerComposeCmd(
t,
"--project-name",
projectName,
"commit",
"-a",
"John Hannibal Smith <[email protected]>",
"-c",
"ENV DEBUG=true",
"-m",
"sample commit",
"--index=2",
"service-with-replicas",
"service-with-replicas:2",
)
s := NewScenario(t, "commit --index must pick the requested replica of a scaled service")
image1, image2 := s.Project()+":1", s.Project()+":2"
s.Defer(
DockerCmd("image", "rm", "-f", image1).MayFail(),
DockerCmd("image", "rm", "-f", image2).MayFail()).
Step("up starts the replicas",
ComposeCmd("up", "-d", "service-with-replicas"),
ServiceScale("service-with-replicas", 3)).
Step("commit --index=1 images the first replica",
ComposeCmd("commit", "-m", "sample commit", "--index=1", "service-with-replicas", image1),
ImageExists(image1)).
Step("commit --index=2 images the second replica",
ComposeCmd("commit", "-m", "sample commit", "--index=2", "service-with-replicas", image2),
ImageExists(image2))
}
+14 -33
View File
@@ -18,40 +18,21 @@ package e2e
import (
"testing"
"gotest.tools/v3/icmd"
)
func TestConfigFromEnv(t *testing.T) {
c := NewParallelCLI(t)
defer c.cleanupWithDown(t, "configs")
t.Run("config from file", func(t *testing.T) {
res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/configs/compose.yaml", "run", "from_file"))
res.Assert(t, icmd.Expected{Out: "This is my config file"})
})
t.Run("config from env", func(t *testing.T) {
res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/configs/compose.yaml", "run", "from_env"),
func(cmd *icmd.Cmd) {
cmd.Env = append(cmd.Env, "CONFIG=config")
})
res.Assert(t, icmd.Expected{Out: "config"})
})
t.Run("config inlined", func(t *testing.T) {
res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/configs/compose.yaml", "run", "inlined"),
func(cmd *icmd.Cmd) {
cmd.Env = append(cmd.Env, "CONFIG=config")
})
res.Assert(t, icmd.Expected{Out: "This is my config"})
})
t.Run("custom target", func(t *testing.T) {
res := icmd.RunCmd(c.NewDockerComposeCmd(t, "-f", "./fixtures/configs/compose.yaml", "run", "target"),
func(cmd *icmd.Cmd) {
cmd.Env = append(cmd.Env, "CONFIG=config")
})
res.Assert(t, icmd.Expected{Out: "This is my config"})
})
NewScenario(t, "each config source — file, environment, inline content — must mount with the right content").
Env("CONFIG=config").
Step("a file-sourced config mounts its file's content",
ComposeCmd("run", "from_file"),
OutputContains("This is my config file")).
Step("an environment-sourced config mounts the variable's value",
ComposeCmd("run", "from_env"),
OutputContains("config")).
Step("an inline config mounts its interpolated content",
ComposeCmd("run", "inlined"),
OutputContains("This is my config")).
Step("a custom target mounts the config at the requested path",
ComposeCmd("run", "target"),
OutputContains("This is my config"))
}
+21 -23
View File
@@ -17,34 +17,32 @@
package e2e
import (
"path/filepath"
"testing"
)
func TestExport(t *testing.T) {
const projectName = "e2e-export-service"
c := NewParallelCLI(t)
cleanup := func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans")
}
t.Cleanup(cleanup)
cleanup()
c.RunDockerComposeCmd(t, "-f", "./fixtures/export/compose.yaml", "--project-name", projectName, "up", "-d", "service")
c.RunDockerComposeCmd(t, "--project-name", projectName, "export", "-o", "service.tar", "service")
out := filepath.Join(t.TempDir(), "service.tar")
NewScenario(t, "export must write the service container's filesystem as a tar archive").
Step("up starts the service",
ComposeCmd("up", "-d", "service"),
ServiceState("service", "running")).
Step("export writes the container filesystem to the output file",
ComposeCmd("export", "-o", out, "service"),
FileExists(out))
}
func TestExportWithReplicas(t *testing.T) {
const projectName = "e2e-export-service-with-replicas"
c := NewParallelCLI(t)
cleanup := func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans")
}
t.Cleanup(cleanup)
cleanup()
c.RunDockerComposeCmd(t, "-f", "./fixtures/export/compose.yaml", "--project-name", projectName, "up", "-d", "service-with-replicas")
c.RunDockerComposeCmd(t, "--project-name", projectName, "export", "-o", "r1.tar", "--index=1", "service-with-replicas")
c.RunDockerComposeCmd(t, "--project-name", projectName, "export", "-o", "r2.tar", "--index=2", "service-with-replicas")
dir := t.TempDir()
r1, r2 := filepath.Join(dir, "r1.tar"), filepath.Join(dir, "r2.tar")
NewScenario(t, "export --index must pick the requested replica of a scaled service").
Step("up starts the replicas",
ComposeCmd("up", "-d", "service-with-replicas"),
ServiceScale("service-with-replicas", 3)).
Step("export --index=1 writes the first replica's filesystem",
ComposeCmd("export", "-o", r1, "--index=1", "service-with-replicas"),
FileExists(r1)).
Step("export --index=2 writes the second replica's filesystem",
ComposeCmd("export", "-o", r2, "--index=2", "service-with-replicas"),
FileExists(r2))
}
+20 -40
View File
@@ -31,46 +31,26 @@ import (
)
func TestLocalComposeLogs(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "compose-e2e-logs"
t.Run("up", func(t *testing.T) {
c.RunDockerComposeCmd(t, "-f", "./fixtures/logs-test/compose.yaml", "--project-name", projectName, "up", "-d")
})
t.Run("logs", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "--project-name", projectName, "logs")
res.Assert(t, icmd.Expected{Out: `PING localhost`})
res.Assert(t, icmd.Expected{Out: `hello`})
})
t.Run("logs ping", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "--project-name", projectName, "logs", "ping")
res.Assert(t, icmd.Expected{Out: `PING localhost`})
assert.Assert(t, !strings.Contains(res.Stdout(), "hello"))
})
t.Run("logs hello", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "--project-name", projectName, "logs", "hello", "ping")
res.Assert(t, icmd.Expected{Out: `PING localhost`})
res.Assert(t, icmd.Expected{Out: `hello`})
})
t.Run("logs hello index", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "--project-name", projectName, "logs", "--index", "2", "hello")
// docker-compose logs hello
// logs-test-hello-2 | hello
// logs-test-hello-1 | hello
t.Log(res.Stdout())
assert.Assert(t, !strings.Contains(res.Stdout(), "hello-1"))
assert.Assert(t, strings.Contains(res.Stdout(), "hello-2"))
})
t.Run("down", func(t *testing.T) {
_ = c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
})
NewScenario(t, "logs must aggregate the requested services' output, and --index must select one replica").
Step("up starts the services",
ComposeCmd("up", "-d"),
Eventually(ServiceState("hello", "exited"), 10*time.Second)).
Step("logs aggregates every service's output",
ComposeCmd("logs"),
OutputContains("PING localhost"),
OutputContains("hello")).
Step("logs on one service filters the others out",
ComposeCmd("logs", "ping"),
OutputContains("PING localhost"),
OutputNotContains("hello")).
Step("logs accepts several services",
ComposeCmd("logs", "hello", "ping"),
OutputContains("PING localhost"),
OutputContains("hello")).
Step("logs --index selects a single replica",
ComposeCmd("logs", "--index", "2", "hello"),
OutputContains("hello-2"),
OutputNotContains("hello-1"))
}
func TestLocalComposeLogsFollow(t *testing.T) {
@@ -6,4 +6,4 @@ services:
image: alpine
command: sleep infinity
deploy:
replicas: 3
replicas: 3
@@ -6,4 +6,4 @@ services:
image: alpine
command: sleep infinity
deploy:
replicas: 3
replicas: 3
@@ -1 +1,2 @@
This is my config file
+9
View File
@@ -0,0 +1,9 @@
services:
service:
image: alpine
command: sleep infinity
service-with-replicas:
image: alpine
command: sleep infinity
deploy:
replicas: 3
+9
View File
@@ -0,0 +1,9 @@
services:
service:
image: alpine
command: sleep infinity
service-with-replicas:
image: alpine
command: sleep infinity
deploy:
replicas: 3
+10
View File
@@ -0,0 +1,10 @@
services:
ping:
image: alpine
init: true
command: ping localhost -c 1
hello:
image: alpine
command: echo hello
deploy:
replicas: 2