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.
This commit is contained in:
pj committed 2026-08-22 21:20:27 +05:30
1 parent 970dc9a6f5
commit 22296c5316
4 files changed
+36 -14

No files matched your search

+27 -5
View File
@@ -3,6 +3,7 @@ package mock
import ( import (
"context" "context"
"slices"
"sync" "sync"
"time" "time"
@@ -49,19 +50,31 @@ type Action struct {
Idle time.Duration 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. // Driver is an in-memory Driver implementation for unit tests.
// Tests can program HierarchyJSON, ImageData, HealthInfo, and per-method // Tests can program HierarchyJSON, ImageData, HealthInfo, and per-method
// Failures, and read back Actions to assert what the runner asked for. // Failures, and read back Actions to assert what the runner asked for.
type Driver struct { type Driver struct {
mutex sync.Mutex mutex sync.Mutex
actions []Action actions []Action
callCounts map[ActionKind]int
HierarchyJSON string HierarchyJSON string
ImageData driver.Image ImageData driver.Image
HealthInfo driver.Health HealthInfo driver.Health
LogEntries []driver.LogEntry LogEntries []driver.LogEntry
MetricsData driver.Metrics MetricsData driver.Metrics
Failures map[ActionKind]error Failures map[ActionKind]FailurePlan
// ReplacesText makes the mock assert the TextReplacer capability, so // ReplacesText makes the mock assert the TextReplacer capability, so
// tests cover both the erase-before-type and replace-on-input paths. // tests cover both the erase-before-type and replace-on-input paths.
@@ -90,7 +103,8 @@ type Driver struct {
func New() *Driver { func New() *Driver {
return &Driver{ return &Driver{
Failures: map[ActionKind]error{}, Failures: map[ActionKind]FailurePlan{},
callCounts: map[ActionKind]int{},
HealthInfo: driver.Health{ HealthInfo: driver.Health{
Ready: true, Ready: true,
Version: "mock", Version: "mock",
@@ -116,7 +130,15 @@ func (d *Driver) record(action Action) {
func (d *Driver) failure(kind ActionKind) error { func (d *Driver) failure(kind ActionKind) error {
d.mutex.Lock() d.mutex.Lock()
defer d.mutex.Unlock() 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 { func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ map[string]string) error {
+1 -1
View File
@@ -100,7 +100,7 @@ func TestProgrammableScreenshotIsReturned(t *testing.T) {
func TestFailureInjection(t *testing.T) { func TestFailureInjection(t *testing.T) {
boom := errors.New("boom") boom := errors.New("boom")
mock := New() mock := New()
mock.Failures[ActionTap] = boom mock.Failures[ActionTap] = FailurePlan{Err: boom}
if err := mock.Tap(context.Background(), 0, 0); !errors.Is(err, boom) { if err := mock.Tap(context.Background(), 0, 0); !errors.Is(err, boom) {
t.Fatalf("expected boom, got %v", err) t.Fatalf("expected boom, got %v", err)
+7 -7
View File
@@ -365,9 +365,9 @@ func TestRenderSummary_CountsTheStepsNothingJudged(t *testing.T) {
// green iOS summary is indistinguishable from one over a silent app. // green iOS summary is indistinguishable from one over a silent app.
func TestRunner_ReadsTheDriverCannotMakeAreNotPassedChecks(t *testing.T) { func TestRunner_ReadsTheDriverCannotMakeAreNotPassedChecks(t *testing.T) {
state := newHarnessWithSpec(t, logErrorSpec) state := newHarnessWithSpec(t, logErrorSpec)
state.mock.Failures[mockdriver.ActionRecentLogs] = driver.ErrNotSupported state.mock.Failures[mockdriver.ActionRecentLogs] = mockdriver.FailurePlan{Err: driver.ErrNotSupported}
state.mock.Failures[mockdriver.ActionMetrics] = driver.ErrNotSupported state.mock.Failures[mockdriver.ActionMetrics] = mockdriver.FailurePlan{Err: driver.ErrNotSupported}
state.mock.Failures[mockdriver.ActionHealth] = driver.ErrNotSupported state.mock.Failures[mockdriver.ActionHealth] = mockdriver.FailurePlan{Err: driver.ErrNotSupported}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -877,7 +877,7 @@ func TestTraceActionFor_RecordsKindSpecificFields(t *testing.T) {
func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) { func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
state := newHarness(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 var logBuf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) 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) { func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
t.Run("selector focus tap fails", func(t *testing.T) { t.Run("selector focus tap fails", func(t *testing.T) {
driverMock := mockdriver.New() 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"} action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
_, err := applyAction(context.Background(), driverMock, action, nil) _, 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) { t.Run("coordinate focus tap fails", func(t *testing.T) {
driverMock := mockdriver.New() 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"} action := verifier.Action{Kind: verifier.ActionKindInputText, X: 10, Y: 20, Text: "alice"}
_, err := applyAction(context.Background(), driverMock, action, nil) _, 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. // here has an action to offer, and no step of this run gets to hear it.
func TestRunner_AHeldStepIsNotRecordedAsASourceThatDeclined(t *testing.T) { func TestRunner_AHeldStepIsNotRecordedAsASourceThatDeclined(t *testing.T) {
state := newHarness(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) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
+1 -1
View File
@@ -130,7 +130,7 @@ func TestRunner_WebInstallsTheStepsLogsInThePage(t *testing.T) {
// be dropped in silence, under a comment claiming it was warned about. // be dropped in silence, under a comment claiming it was warned about.
func TestRunner_ReportsALogFetchItCouldNotMake(t *testing.T) { func TestRunner_ReportsALogFetchItCouldNotMake(t *testing.T) {
state := newHarnessWithSpec(t, lastActionSpec) 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 var buffer bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn})) logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))