From 22296c5316fc20ed05efea0b3ccff5fd8ddc1240 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:20:27 +0530 Subject: [PATCH] test(mock): one failure plan replaces the per-call wrapper drivers mock.Failures already made a method fail on every call. FailurePlan adds the call numbers, which is the shape of nearly every device fault the runner has to survive: the first read times out, the next one works. Expressing that by embedding the mock in a one-off wrapper put a different seven-line method in every test that needed one. --- internal/driver/mock/mock.go | 32 +++++++++++++++++++++---- internal/driver/mock/mock_test.go | 2 +- internal/runner/runner_test.go | 14 +++++------ internal/runner/web_last_action_test.go | 2 +- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index f438a31..80899c0 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -3,6 +3,7 @@ package mock import ( "context" + "slices" "sync" "time" @@ -49,19 +50,31 @@ type Action struct { Idle time.Duration } +// FailurePlan makes one method fail. OnCalls names the 1-based calls that fail +// and every other call succeeds; an empty OnCalls fails every call. It exists +// because "the first read times out and the next one works" is the shape of +// nearly every device fault the runner has to survive, and expressing it by +// embedding the mock in a one-off wrapper puts a different seven-line method in +// every test that needs one. +type FailurePlan struct { + Err error + OnCalls []int +} + // Driver is an in-memory Driver implementation for unit tests. // Tests can program HierarchyJSON, ImageData, HealthInfo, and per-method // Failures, and read back Actions to assert what the runner asked for. type Driver struct { - mutex sync.Mutex - actions []Action + mutex sync.Mutex + actions []Action + callCounts map[ActionKind]int HierarchyJSON string ImageData driver.Image HealthInfo driver.Health LogEntries []driver.LogEntry MetricsData driver.Metrics - Failures map[ActionKind]error + Failures map[ActionKind]FailurePlan // ReplacesText makes the mock assert the TextReplacer capability, so // tests cover both the erase-before-type and replace-on-input paths. @@ -90,7 +103,8 @@ type Driver struct { func New() *Driver { return &Driver{ - Failures: map[ActionKind]error{}, + Failures: map[ActionKind]FailurePlan{}, + callCounts: map[ActionKind]int{}, HealthInfo: driver.Health{ Ready: true, Version: "mock", @@ -116,7 +130,15 @@ func (d *Driver) record(action Action) { func (d *Driver) failure(kind ActionKind) error { d.mutex.Lock() defer d.mutex.Unlock() - return d.Failures[kind] + d.callCounts[kind]++ + plan, planned := d.Failures[kind] + if !planned { + return nil + } + if len(plan.OnCalls) == 0 || slices.Contains(plan.OnCalls, d.callCounts[kind]) { + return plan.Err + } + return nil } func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ map[string]string) error { diff --git a/internal/driver/mock/mock_test.go b/internal/driver/mock/mock_test.go index 488403d..36e7ae5 100644 --- a/internal/driver/mock/mock_test.go +++ b/internal/driver/mock/mock_test.go @@ -100,7 +100,7 @@ func TestProgrammableScreenshotIsReturned(t *testing.T) { func TestFailureInjection(t *testing.T) { boom := errors.New("boom") mock := New() - mock.Failures[ActionTap] = boom + mock.Failures[ActionTap] = FailurePlan{Err: boom} if err := mock.Tap(context.Background(), 0, 0); !errors.Is(err, boom) { t.Fatalf("expected boom, got %v", err) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 0976234..dd3a1d3 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -365,9 +365,9 @@ func TestRenderSummary_CountsTheStepsNothingJudged(t *testing.T) { // green iOS summary is indistinguishable from one over a silent app. func TestRunner_ReadsTheDriverCannotMakeAreNotPassedChecks(t *testing.T) { state := newHarnessWithSpec(t, logErrorSpec) - state.mock.Failures[mockdriver.ActionRecentLogs] = driver.ErrNotSupported - state.mock.Failures[mockdriver.ActionMetrics] = driver.ErrNotSupported - state.mock.Failures[mockdriver.ActionHealth] = driver.ErrNotSupported + state.mock.Failures[mockdriver.ActionRecentLogs] = mockdriver.FailurePlan{Err: driver.ErrNotSupported} + state.mock.Failures[mockdriver.ActionMetrics] = mockdriver.FailurePlan{Err: driver.ErrNotSupported} + state.mock.Failures[mockdriver.ActionHealth] = mockdriver.FailurePlan{Err: driver.ErrNotSupported} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -877,7 +877,7 @@ func TestTraceActionFor_RecordsKindSpecificFields(t *testing.T) { func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) { state := newHarness(t) - state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream") + state.mock.Failures[mockdriver.ActionWaitForIdle] = mockdriver.FailurePlan{Err: errors.New("sidecar lost gRPC stream")} var logBuf bytes.Buffer logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) @@ -976,7 +976,7 @@ func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) { func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { t.Run("selector focus tap fails", func(t *testing.T) { driverMock := mockdriver.New() - driverMock.Failures[mockdriver.ActionTapSelector] = errors.New("adb unreachable") + driverMock.Failures[mockdriver.ActionTapSelector] = mockdriver.FailurePlan{Err: errors.New("adb unreachable")} action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} _, err := applyAction(context.Background(), driverMock, action, nil) @@ -989,7 +989,7 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { }) t.Run("coordinate focus tap fails", func(t *testing.T) { driverMock := mockdriver.New() - driverMock.Failures[mockdriver.ActionTap] = errors.New("tap driver error") + driverMock.Failures[mockdriver.ActionTap] = mockdriver.FailurePlan{Err: errors.New("tap driver error")} action := verifier.Action{Kind: verifier.ActionKindInputText, X: 10, Y: 20, Text: "alice"} _, err := applyAction(context.Background(), driverMock, action, nil) @@ -1639,7 +1639,7 @@ func TestRunner_ASourceAskedAndHandedNothingSaysSo(t *testing.T) { // here has an action to offer, and no step of this run gets to hear it. func TestRunner_AHeldStepIsNotRecordedAsASourceThatDeclined(t *testing.T) { state := newHarness(t) - state.mock.Failures[mockdriver.ActionSnapshot] = errors.New("adb: device offline") + state.mock.Failures[mockdriver.ActionSnapshot] = mockdriver.FailurePlan{Err: errors.New("adb: device offline")} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/internal/runner/web_last_action_test.go b/internal/runner/web_last_action_test.go index 6f66304..02d9617 100644 --- a/internal/runner/web_last_action_test.go +++ b/internal/runner/web_last_action_test.go @@ -130,7 +130,7 @@ func TestRunner_WebInstallsTheStepsLogsInThePage(t *testing.T) { // be dropped in silence, under a comment claiming it was warned about. func TestRunner_ReportsALogFetchItCouldNotMake(t *testing.T) { state := newHarnessWithSpec(t, lastActionSpec) - state.mock.Failures[mockdriver.ActionRecentLogs] = errors.New("adb: device offline") + state.mock.Failures[mockdriver.ActionRecentLogs] = mockdriver.FailurePlan{Err: errors.New("adb: device offline")} var buffer bytes.Buffer logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))