Skip to content

Commit 5df6a27

Browse files
committed
test: add controller-contract-testing PoC
1 parent 576ce80 commit 5df6a27

13 files changed

Lines changed: 698 additions & 0 deletions
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
![Test coverage matrix: which test type exercises which layer of the stack](docs/coverage.svg)
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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
presets: [
3+
['@babel/preset-env', { targets: { node: 'current' } }],
4+
'@babel/preset-typescript',
5+
],
6+
};
Lines changed: 77 additions & 0 deletions
Loading
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Standalone jest config so the PoC runs without the mobile app's RN setup.
3+
* It still resolves modules from the parent repo's node_modules, so we share
4+
* @metamask/base-controller, jest, etc. with the real app.
5+
*/
6+
const path = require('path');
7+
8+
module.exports = {
9+
rootDir: __dirname,
10+
testEnvironment: 'node',
11+
testMatch: ['<rootDir>/test/**/*.test.ts'],
12+
transform: {
13+
'^.+\\.[jt]sx?$': ['babel-jest', { configFile: path.resolve(__dirname, 'babel.config.js') }],
14+
'^.+\\.cjs$': ['babel-jest', { configFile: path.resolve(__dirname, 'babel.config.js') }],
15+
},
16+
transformIgnorePatterns: [
17+
'node_modules/(?!(@metamask|@noble))',
18+
],
19+
moduleDirectories: [
20+
'node_modules',
21+
path.resolve(__dirname, '../../node_modules'),
22+
],
23+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "poc-controller-contract-testing",
3+
"private": true,
4+
"version": "0.0.0",
5+
"scripts": {
6+
"test": "jest --config jest.config.js"
7+
}
8+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { BaseController } from '@metamask/base-controller';
2+
import { Messenger } from '@metamask/messenger';
3+
4+
/**
5+
* The remote I/O the controller depends on. In a real controller this would
6+
* be a SDK, an HTTP client, an RPC provider, etc. The integration test mocks
7+
* this — the controller logic itself runs for real.
8+
*/
9+
export interface WidgetService {
10+
fetchWidget(id: string): Promise<{ id: string; label: string; price: number }>;
11+
}
12+
13+
export type Widget = {
14+
id: string;
15+
label: string;
16+
price: number;
17+
/** Cents. Derived from `price` at insert time. */
18+
priceCents: number;
19+
};
20+
21+
export type WidgetControllerState = {
22+
widgets: Record<string, Widget>;
23+
loading: boolean;
24+
lastSyncedAt: number | null;
25+
};
26+
27+
const DEFAULT_STATE: WidgetControllerState = {
28+
widgets: {},
29+
loading: false,
30+
lastSyncedAt: null,
31+
};
32+
33+
const METADATA = {
34+
widgets: { persist: true, anonymous: false },
35+
loading: { persist: false, anonymous: true },
36+
lastSyncedAt: { persist: true, anonymous: true },
37+
};
38+
39+
export type WidgetControllerActions = never;
40+
export type WidgetControllerEvents = {
41+
type: `WidgetController:stateChange`;
42+
payload: [WidgetControllerState, []];
43+
};
44+
45+
export class WidgetController extends BaseController<
46+
'WidgetController',
47+
WidgetControllerState,
48+
Messenger<'WidgetController', WidgetControllerActions, WidgetControllerEvents, never>
49+
> {
50+
#service: WidgetService;
51+
#now: () => number;
52+
53+
constructor(opts: {
54+
messenger: Messenger<'WidgetController', WidgetControllerActions, WidgetControllerEvents, never>;
55+
service: WidgetService;
56+
state?: Partial<WidgetControllerState>;
57+
now?: () => number;
58+
}) {
59+
super({
60+
name: 'WidgetController',
61+
messenger: opts.messenger,
62+
metadata: METADATA,
63+
state: { ...DEFAULT_STATE, ...opts.state },
64+
});
65+
this.#service = opts.service;
66+
this.#now = opts.now ?? Date.now;
67+
}
68+
69+
async addWidget(id: string): Promise<void> {
70+
this.update((draft) => {
71+
draft.loading = true;
72+
});
73+
try {
74+
const fetched = await this.#service.fetchWidget(id);
75+
const widget: Widget = {
76+
...fetched,
77+
priceCents: Math.round(fetched.price * 100),
78+
};
79+
this.update((draft) => {
80+
draft.widgets[widget.id] = widget;
81+
draft.lastSyncedAt = this.#now();
82+
draft.loading = false;
83+
});
84+
} catch (err) {
85+
this.update((draft) => {
86+
draft.loading = false;
87+
});
88+
throw err;
89+
}
90+
}
91+
}

0 commit comments

Comments
 (0)