test(e2e): Eventually check, TestRestart as polling demonstrator

Add Eventually(check, timeout) to the scenario layer: it retries a
state-based check, re-observing the project containers between
attempts. ServiceState(service, state) joins the check library, backed
by a State field in the per-step snapshots.

TestRestart is converted as the demonstrator: it replaces a
WaitForCmdResult polling `ps --format json` for a JSON fragment and a
bare time.Sleep with two declarative expectations, and now also locks
the real invariant — restart must reuse the same container — via
NotRecreated, which the lock-file trick of the original fixture was
proving only indirectly.

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 0632f222f6
commit a443a6589d
2 changed files with 105 additions and 30 deletions
+18 -28
View File
@@ -34,34 +34,24 @@ func assertServiceStatus(t *testing.T, projectName, service, status string, ps s
}
func TestRestart(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "e2e-restart"
t.Run("Up a project", func(t *testing.T) {
// This is just to ensure the containers do NOT exist
c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "up", "-d")
assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-restart-restart-1 Started"), res.Combined())
c.WaitForCmdResult(t, c.NewDockerComposeCmd(t, "--project-name", projectName, "ps", "-a", "--format",
"json"),
StdoutContains(`"State":"exited"`), 10*time.Second, 1*time.Second)
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a")
assertServiceStatus(t, projectName, "restart", "Exited", res.Stdout())
c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "restart")
// Give the same time but it must NOT exit
time.Sleep(time.Second)
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps")
assertServiceStatus(t, projectName, "restart", "Up", res.Stdout())
// Clean up
c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
})
// the service's first run creates a lock file and exits at once; any
// later run of the same container finds the lock and sleeps forever, so
// staying up after `restart` proves the same container was restarted
NewScenario(t, "restart must bring an exited service back up, restarting the same container").
Compose(`
services:
app:
image: alpine
init: true
command: ash -c "if [[ -f /tmp/restart.lock ]] ; then sleep infinity; else touch /tmp/restart.lock; fi"
`).
Step("up starts the service, whose first run exits at once",
ComposeCmd("up", "-d"),
Eventually(ServiceState("app", "exited"), 10*time.Second)).
Step("restart brings the service back up, reusing the container",
ComposeCmd("restart"),
Eventually(ServiceState("app", "running"), 10*time.Second),
NotRecreated("app"))
}
func TestRestartWithDependencies(t *testing.T) {
+87 -2
View File
@@ -21,6 +21,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
@@ -29,6 +30,7 @@ import (
"unicode"
"gotest.tools/v3/icmd"
"gotest.tools/v3/poll"
)
// Scenario is a thin declarative layer over the e2e CLI helpers: a test reads
@@ -65,6 +67,7 @@ type stepRecord struct {
type containerState struct {
ID string
Name string
State string
Labels map[string]string
}
@@ -259,8 +262,11 @@ func (s *Scenario) snapshot() snapshot {
return snap
}
var containers []struct {
ID string `json:"Id"`
Name string `json:"Name"`
ID string `json:"Id"`
Name string `json:"Name"`
State struct {
Status string `json:"Status"`
} `json:"State"`
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"Config"`
@@ -273,6 +279,7 @@ func (s *Scenario) snapshot() snapshot {
snap[service] = append(snap[service], containerState{
ID: c.ID,
Name: strings.TrimPrefix(c.Name, "/"),
State: c.State.Status,
Labels: c.Config.Labels,
})
}
@@ -416,6 +423,15 @@ type CheckContext struct {
curr snapshot
}
// refresh re-observes the project state, so subsequent checks of the same
// step see the latest state rather than the one captured right after the
// command returned.
func (ctx *CheckContext) refresh() {
s := ctx.scenario
ctx.curr = s.snapshot()
s.snaps[len(s.snaps)-1] = ctx.curr
}
// Check is a named observable expected to hold after a step.
type Check struct {
name string
@@ -450,6 +466,75 @@ func OutputNotContains(sub string) Check {
}
}
// 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.
// Polling is delegated to gotest.tools/v3/poll, the same engine the rest of
// the e2e framework uses.
func Eventually(check Check, timeout time.Duration) Check {
return Check{
name: fmt.Sprintf("%s within %s", check.name, timeout),
fn: func(ctx *CheckContext) error {
first := true
capture := &pollCapture{}
done := make(chan struct{})
// poll.WaitOn reports timeout through TestingT.Fatalf and relies
// on it halting execution; run it in a goroutine so pollCapture
// can Goexit and hand the error back to the scenario report
// instead of failing the test on the spot.
go func() {
defer close(done)
poll.WaitOn(capture, func(poll.LogT) poll.Result {
if !first {
ctx.refresh()
}
first = false
if err := check.fn(ctx); err != nil {
return poll.Continue("%v", err)
}
return poll.Success()
}, poll.WithDelay(500*time.Millisecond), poll.WithTimeout(timeout))
}()
<-done
return capture.err
},
}
}
// pollCapture is a poll.TestingT that records the failure instead of failing
// the test, so Eventually can feed it to the scenario failure report.
type pollCapture struct {
err error
}
func (c *pollCapture) Log(args ...any) {}
func (c *pollCapture) Logf(format string, args ...any) {}
func (c *pollCapture) Fatalf(format string, args ...any) {
c.err = fmt.Errorf(format, args...)
runtime.Goexit()
}
// ServiceState expects every container of the service to be in the given
// state (running, exited, restarting, …).
func ServiceState(service, state string) Check {
return Check{
name: fmt.Sprintf("service %q is %s", service, state),
fn: func(ctx *CheckContext) error {
containers := ctx.curr[service]
if len(containers) == 0 {
return fmt.Errorf("service has no container")
}
for _, c := range containers {
if c.State != state {
return fmt.Errorf("container %s is %s", c.Name, c.State)
}
}
return nil
},
}
}
// NotRecreated expects the services' containers to be exactly the ones that
// existed before the step (same container IDs).
func NotRecreated(services ...string) Check {