|
| 1 | +# PoC: Controller-Contract Testing |
| 2 | + |
| 3 | +A working demo of three layers that catch controller/component integration |
| 4 | +bugs in jest, instead of pushing them out to e2e. |
| 5 | + |
| 6 | +## Strategy at a glance |
| 7 | + |
| 8 | + |
| 9 | + |
| 10 | +The architecture stack runs top-to-bottom on the left: external controller |
| 11 | +package → messenger / app glue → engine state → selectors → component → |
| 12 | +native runtime. Each test type on the right exercises a vertical slice of |
| 13 | +that stack. A blue cell means "this test type runs real code at this |
| 14 | +layer"; an empty cell means "mocked or out of scope." |
| 15 | + |
| 16 | +The three new layers introduced by this PoC — fixture verification, |
| 17 | +CV + contract, and integration — collectively cover everything except the |
| 18 | +native runtime, in milliseconds. The e2e column is the only one that |
| 19 | +pays the device/simulator/network cost, and it shrinks to "things that |
| 20 | +genuinely need a device" (native modules, real Reanimated, real keychain). |
| 21 | + |
| 22 | +## Why this complements CV tests rather than replacing them |
| 23 | + |
| 24 | +This pattern is designed to **sit alongside** existing component-view tests, |
| 25 | +not displace them. Each layer addresses something CV tests can't. |
| 26 | + |
| 27 | +- **CV tests own UI behaviour under arbitrary state.** Reaching "loading=true |
| 28 | + with 3 cached widgets and a stale sync timestamp" takes 4 lines as a |
| 29 | + state literal; producing it via real action sequences takes pages of |
| 30 | + setup. CV tests give you cheap, isolated coverage of every visual |
| 31 | + variant — empty, loading, populated, error, partial. Integration tests |
| 32 | + can't substitute for that economically. |
| 33 | +- **Failure isolation.** When a CV test fails, the bug is in the component |
| 34 | + or its selector. When an integration test fails, the bug could be |
| 35 | + anywhere in the controller → messenger → reducer → selector → render |
| 36 | + chain. Keeping CV tests preserves a sharp bisect for UI regressions. |
| 37 | +- **CV tests today silently consume stale shapes.** The contract layer |
| 38 | + fixes the one real failure mode of CV tests — that mocks drift from |
| 39 | + reality. After this PoC ships, CV tests are *more* reliable, not less |
| 40 | + necessary. |
| 41 | +- **Integration tests catch what CV tests structurally cannot:** |
| 42 | + transition bugs (loading flag flips in the wrong order), cycle bugs |
| 43 | + (button click → action → state change → re-render), and inter-controller |
| 44 | + interactions. CV-with-fixture covers static state; integration covers |
| 45 | + dynamic flow. |
| 46 | +- **The fixture is the bridge.** A single committed JSON file is consumed |
| 47 | + by both the controller's own test (which produces it) and every |
| 48 | + downstream CV test. That shared artifact is what stops the controller |
| 49 | + team and the UI team from drifting against each other in the first |
| 50 | + place. |
| 51 | + |
| 52 | +The pragmatic split: fixture + contract on top of every existing CV test |
| 53 | +(cheap, 60–70% of the value), integration tests added selectively on the |
| 54 | +high-risk surfaces where e2e currently catches bugs, CV tests untouched |
| 55 | +elsewhere for everything UI-only. |
| 56 | + |
| 57 | +## Run it |
| 58 | + |
| 59 | +```bash |
| 60 | +cd poc/controller-contract-testing |
| 61 | +../../node_modules/.bin/jest --config jest.config.js |
| 62 | +``` |
| 63 | + |
| 64 | +7 tests pass across 3 suites. To regenerate fixtures after an intentional |
| 65 | +controller change: `UPDATE_FIXTURES=1 npm test`. |
| 66 | + |
| 67 | +## What's in here |
| 68 | + |
| 69 | +``` |
| 70 | +src/ |
| 71 | + WidgetController.ts Real BaseController, has state + one async action + I/O dep |
| 72 | + widgetSelectors.ts Selectors UI consumes |
| 73 | +test/ |
| 74 | + WidgetController.test.ts Layer 1: behaviour + fixture verification (THE source of truth) |
| 75 | + widgetStateContract.ts Layer 2: runtime contract + mockWidgetState() helper |
| 76 | + widgetView.view.test.ts The "after" CV test — fixture + contract checked |
| 77 | + widgetView.integration.test.ts Layer 3: real controller, mocked I/O |
| 78 | + __fixtures__/twoWidgetsAdded.json Committed fixture (single source of truth) |
| 79 | +``` |
| 80 | + |
| 81 | +## What each layer catches |
| 82 | + |
| 83 | +| Bug class | Caught by | Speed | |
| 84 | +|---------------------------------------------------|-----------------------------------------|-------| |
| 85 | +| Controller logic bug (e.g. forgot to set field) | Controller unit test | ms | |
| 86 | +| Controller emits a different shape than before | Fixture-verification test | ms | |
| 87 | +| Component-test mock drifted from real shape | `mockWidgetState()` contract check | ms | |
| 88 | +| Bug only appears when controller + selector + UI run together | Integration test (real ctrl, mocked I/O) | ms | |
| 89 | +| Native module / device / RN runtime issue | e2e (still needed, but for less) | s–min | |
| 90 | + |
| 91 | +## Drift demo (already verified) |
| 92 | + |
| 93 | +Comment out `priceCents: Math.round(fetched.price * 100)` in |
| 94 | +`src/WidgetController.ts` and run the suite. Three tests fail, in the order |
| 95 | +you'd want them: |
| 96 | + |
| 97 | +1. `WidgetController — behaviour > adds a widget and derives priceCents from price` |
| 98 | + — the cheapest signal, controller-level. |
| 99 | +2. `WidgetController — fixtures > emits the documented state for "two widgets added"` |
| 100 | + — tells you the contract changed; every downstream component test is |
| 101 | + now operating against stale assumptions. |
| 102 | +3. `WidgetSummary integration > updates the total when a widget is added` |
| 103 | + — `selectWidgetTotalCents` returns `NaN`. This is the e2e-class bug, |
| 104 | + caught in jest in milliseconds. |
| 105 | + |
| 106 | +Restore the line; all 7 tests pass. |
| 107 | + |
| 108 | +--- |
| 109 | + |
| 110 | +## How to apply this to a real mobile controller |
| 111 | + |
| 112 | +Pick CardController as a worked example (similarly applies to RewardsController, |
| 113 | +PerpsController, etc.). Files are in `app/core/Engine/controllers/card-controller/`. |
| 114 | + |
| 115 | +### Layer 1 — Shared fixtures |
| 116 | + |
| 117 | +In `CardController.test.ts`, add a `describe("fixtures")` block that, after |
| 118 | +running a representative scenario (e.g. "user authenticated, two cards |
| 119 | +loaded"), serializes `controller.state` to |
| 120 | +`app/core/Engine/controllers/card-controller/__fixtures__/twoCardsLoaded.json` |
| 121 | +using the same `UPDATE_FIXTURES` pattern as the PoC. Commit those JSON files. |
| 122 | + |
| 123 | +In existing card-related component tests |
| 124 | +(`app/components/UI/Card/Views/CardHome/CardHome.test.tsx` and friends), |
| 125 | +replace ad-hoc state literals like |
| 126 | + |
| 127 | +```ts |
| 128 | +{ engine: { backgroundState: { CardController: { ...handRolled } } } } |
| 129 | +``` |
| 130 | + |
| 131 | +with a fixture loader: |
| 132 | + |
| 133 | +```ts |
| 134 | +import twoCardsLoaded from '../../../../core/Engine/controllers/card-controller/__fixtures__/twoCardsLoaded.json'; |
| 135 | + |
| 136 | +renderWithProvider(<CardHome />, { |
| 137 | + state: { engine: { backgroundState: { CardController: twoCardsLoaded } } }, |
| 138 | +}); |
| 139 | +``` |
| 140 | + |
| 141 | +### Layer 2 — Contract in `renderWithProvider` |
| 142 | + |
| 143 | +Add a contract module next to each controller, e.g. |
| 144 | +`app/core/Engine/controllers/card-controller/state-contract.ts`, exporting |
| 145 | +an `assertCardControllerState` function. Either hand-roll it (~30 lines per |
| 146 | +controller, as in this PoC) or adopt `zod` and derive it from the real |
| 147 | +state type. |
| 148 | + |
| 149 | +Then patch `app/util/test/renderWithProvider.tsx` so that, in test mode, |
| 150 | +each known controller slice in `providerValues.state.engine.backgroundState` |
| 151 | +is run through its `assert*State` function before the store is built. A |
| 152 | +mock with the wrong shape fails on render, not on first selector hit. |
| 153 | + |
| 154 | +### Layer 3 — Integration tests |
| 155 | + |
| 156 | +A new convention: `*.integration.test.ts` colocated with each controller, |
| 157 | +or under `app/core/Engine/integration/`. Pattern: |
| 158 | + |
| 159 | +```ts |
| 160 | +const messenger = new Messenger({ namespace: 'CardController' }); |
| 161 | +const controller = new CardController({ |
| 162 | + messenger, |
| 163 | + cardSdk: mockCardSdk, // mock only the SDK / network boundary |
| 164 | + storage: mockStorage, |
| 165 | +}); |
| 166 | + |
| 167 | +await controller.authenticate(...); |
| 168 | + |
| 169 | +const view = renderWithProvider(<CardHome />, { |
| 170 | + state: { engine: { backgroundState: { CardController: controller.state } } }, |
| 171 | +}); |
| 172 | +expect(view.getByText('Authenticated')).toBeTruthy(); |
| 173 | +``` |
| 174 | + |
| 175 | +These are still jest tests — no Detox, no simulator — but the controller's |
| 176 | +real reducer / actions / state transitions all run. A bug like the perps |
| 177 | +one your e2e found would fail here in ~50ms. |
| 178 | + |
| 179 | +## Effort estimate to roll out across mobile |
| 180 | + |
| 181 | +| Layer | Per-controller cost | Per-component-test cost | Notes | |
| 182 | +|-------|--------------------:|------------------------:|-------| |
| 183 | +| 1: fixtures | ~30 min (one fixture-gen test, 2-3 scenarios) | ~5 min (swap literal for import) | Fixtures are committed JSON, reviewed in PRs | |
| 184 | +| 2: contract | ~1-2 h (write `assert*State` once, plus `renderWithProvider` patch — done once for whole repo) | 0 (transparent) | Or ~half the time with zod/io-ts | |
| 185 | +| 3: integration | ~2-4 h per controller for first 3 scenarios, ~1 h per scenario after | n/a | Highest-leverage: targets the bugs e2e currently owns | |
| 186 | + |
| 187 | +Suggested rollout: pilot on one controller (Card or Rewards) end-to-end, |
| 188 | +measure how many bugs the integration tests catch over a sprint, then |
| 189 | +prioritise the rest by how often e2e catches integration bugs there today. |
0 commit comments