test(e2e): pause scenarios — paused is a container state, ExitCode locks error paths

Migrate the four pause tests to the Scenario DSL: the invariant is the
container state (paused/running), not an HTTP timeout, so the scenarios
observe state and drop the port-mapping helpers and the pause fixture.

ExitCode joins the check vocabulary for error-path steps, paired with
MayFail. TestPauseServiceDoesNotExist now locks the current behavior —
pause of an unknown service is rejected with 'no such service' — which
resolves the TODO the legacy test carried: it asserted exit 0 while
wishing for an error, and the CLI has since started validating selected
services.

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 b2e3deb18e
commit 12288a7fff
7 changed files with 72 additions and 142 deletions
+15
View File
@@ -85,6 +85,21 @@ func OutputNotContains(sub string) Check {
}
}
// ExitCode expects the step's command to have exited with the given code.
// Only meaningful on a MayFail action: without MayFail, any non-zero exit
// already fails the step before checks run.
func ExitCode(code int) Check {
return Check{
name: fmt.Sprintf("command exits with code %d", code),
fn: func(ctx *CheckContext) error {
if ctx.result.ExitCode != code {
return fmt.Errorf("exit code is %d", ctx.result.ExitCode)
}
return nil
},
}
}
// Eventually retries a state-based check until it holds or the timeout
// expires, re-observing the project state between attempts. Output-based
// checks are not meaningful here: the step's output never changes.
-17
View File
@@ -1,17 +0,0 @@
services:
a:
image: nginx:alpine
ports: [80]
healthcheck:
test: wget --spider -S -T1 http://localhost:80
interval: 1s
timeout: 1s
b:
image: nginx:alpine
ports: [80]
depends_on:
- a
healthcheck:
test: wget --spider -S -T1 http://localhost:80
interval: 1s
timeout: 1s
+33 -125
View File
@@ -17,143 +17,51 @@
package e2e
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"testing"
"time"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
)
func TestPause(t *testing.T) {
cli := NewParallelCLI(t, WithEnv(
"COMPOSE_PROJECT_NAME=e2e-pause",
"COMPOSE_FILE=./fixtures/pause/compose.yaml"))
cleanup := func() {
cli.RunDockerComposeCmd(t, "down", "-v", "--remove-orphans", "-t", "0")
}
cleanup()
t.Cleanup(cleanup)
// launch both services and verify that they are accessible
cli.RunDockerComposeCmd(t, "up", "-d")
urls := map[string]string{
"a": urlForService(t, cli, "a", 80),
"b": urlForService(t, cli, "b", 80),
}
for _, url := range urls {
HTTPGetWithRetry(t, url, http.StatusOK, 50*time.Millisecond, 20*time.Second)
}
// pause a and verify that it can no longer be hit but b still can
cli.RunDockerComposeCmd(t, "pause", "a")
httpClient := http.Client{Timeout: 250 * time.Millisecond}
resp, err := httpClient.Get(urls["a"])
if resp != nil {
_ = resp.Body.Close()
}
assert.Assert(t, err != nil, "a should no longer respond")
var netErr net.Error
assert.ErrorType(t, err, &netErr, "expected a network error")
errors.As(err, &netErr)
assert.Assert(t, netErr.Timeout(), "Error should have indicated a timeout")
HTTPGetWithRetry(t, urls["b"], http.StatusOK, 50*time.Millisecond, 5*time.Second)
// unpause a and verify that both containers work again
cli.RunDockerComposeCmd(t, "unpause", "a")
for _, url := range urls {
HTTPGetWithRetry(t, url, http.StatusOK, 50*time.Millisecond, 5*time.Second)
}
NewScenario(t, "pause must freeze only the targeted service, unpause must resume it in place").
Step("up starts both services",
ComposeCmd("up", "-d"),
ServiceState("a", "running"),
ServiceState("b", "running")).
Step("pause freezes the targeted service, the other keeps running",
ComposeCmd("pause", "a"),
ServiceState("a", "paused"),
ServiceState("b", "running")).
Step("unpause resumes the paused service without recreating anything",
ComposeCmd("unpause", "a"),
ServiceState("a", "running"),
ServiceState("b", "running"),
NotRecreated("a", "b"))
}
func TestPauseServiceNotRunning(t *testing.T) {
cli := NewParallelCLI(t, WithEnv(
"COMPOSE_PROJECT_NAME=e2e-pause-svc-not-running",
"COMPOSE_FILE=./fixtures/pause/compose.yaml"))
cleanup := func() {
cli.RunDockerComposeCmd(t, "down", "-v", "--remove-orphans", "-t", "0")
}
cleanup()
t.Cleanup(cleanup)
// pause a and verify that it can no longer be hit but b still can
res := cli.RunDockerComposeCmdNoCheck(t, "pause", "a")
// TODO: `docker pause` errors in this case, should Compose be consistent?
res.Assert(t, icmd.Expected{ExitCode: 0})
NewScenario(t, "pause of a service with no container must succeed as a no-op").
Step("pause without any container is accepted",
ComposeCmd("pause", "a"))
}
func TestPauseServiceAlreadyPaused(t *testing.T) {
cli := NewParallelCLI(t, WithEnv(
"COMPOSE_PROJECT_NAME=e2e-pause-svc-already-paused",
"COMPOSE_FILE=./fixtures/pause/compose.yaml"))
cleanup := func() {
cli.RunDockerComposeCmd(t, "down", "-v", "--remove-orphans", "-t", "0")
}
cleanup()
t.Cleanup(cleanup)
// launch a and wait for it to come up
cli.RunDockerComposeCmd(t, "up", "--menu=false", "--wait", "a")
HTTPGetWithRetry(t, urlForService(t, cli, "a", 80), http.StatusOK, 50*time.Millisecond, 10*time.Second)
// pause a twice - first time should pass, second time fail
cli.RunDockerComposeCmd(t, "pause", "a")
res := cli.RunDockerComposeCmdNoCheck(t, "pause", "a")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: "already paused"})
NewScenario(t, "pausing an already-paused service must fail").
Step("up starts the service",
ComposeCmd("up", "-d"),
ServiceState("a", "running")).
Step("a first pause freezes the service",
ComposeCmd("pause", "a"),
ServiceState("a", "paused")).
Step("a second pause is rejected",
ComposeCmd("pause", "a").MayFail(),
ExitCode(1),
OutputContains("already paused"))
}
func TestPauseServiceDoesNotExist(t *testing.T) {
cli := NewParallelCLI(t, WithEnv(
"COMPOSE_PROJECT_NAME=e2e-pause-svc-not-exist",
"COMPOSE_FILE=./fixtures/pause/compose.yaml"))
cleanup := func() {
cli.RunDockerComposeCmd(t, "down", "-v", "--remove-orphans", "-t", "0")
}
cleanup()
t.Cleanup(cleanup)
// pause a and verify that it can no longer be hit but b still can
res := cli.RunDockerComposeCmdNoCheck(t, "pause", "does_not_exist")
// TODO: `compose down does_not_exist` and similar error, this should too
res.Assert(t, icmd.Expected{ExitCode: 0})
}
func urlForService(t testing.TB, cli *CLI, service string, targetPort int) string {
t.Helper()
return fmt.Sprintf(
"http://localhost:%d",
publishedPortForService(t, cli, service, targetPort),
)
}
func publishedPortForService(t testing.TB, cli *CLI, service string, targetPort int) int {
t.Helper()
res := cli.RunDockerComposeCmd(t, "ps", "--format=json", service)
var svc struct {
Publishers []struct {
TargetPort int
PublishedPort int
}
}
assert.NilError(t, json.Unmarshal([]byte(res.Stdout()), &svc),
"Failed to parse `%s` output", res.Cmd.String())
var found bool
var port int
for _, pp := range svc.Publishers {
if pp.TargetPort == targetPort {
found = true
port = pp.PublishedPort
}
}
assert.Assert(t, found, "No published port for target port %d\nService: %s", targetPort, res.Combined())
return port
NewScenario(t, "pause of a service unknown to the model must be rejected").
Step("pause of an unknown service is rejected",
ComposeCmd("pause", "does_not_exist").MayFail(),
ExitCode(1),
OutputContains("no such service: does_not_exist"))
}
+9
View File
@@ -0,0 +1,9 @@
services:
a:
image: alpine
init: true
command: sleep infinity
b:
image: alpine
init: true
command: sleep infinity
@@ -0,0 +1,5 @@
services:
a:
image: alpine
init: true
command: sleep infinity
@@ -0,0 +1,5 @@
services:
a:
image: alpine
init: true
command: sleep infinity
@@ -0,0 +1,5 @@
services:
a:
image: alpine
init: true
command: sleep infinity