How the frontend test suite works, and the rules that keep it honest. This is not a general introduction to frontend testing — it is what actually matters in this repository, and what has gone wrong when contributors skipped it.
From the repository root:
npm test # vitest run
npm run lint # eslint, including React Compiler rules
npm run build # tsc -b && vite build
npm run test:coverage # the suite with coverage, as CI runs it
npm run test:design-system # tests the page-CSS token guardCI fails if coverage falls below the floors in vite.config.ts; see
Test coverage in CONTRIBUTING.md.
Page CSS is checked in CI for newly added hard-coded hex colours and spacing
lengths that are not on the 4px scale. The check is diff-aware so existing
page CSS can be migrated deliberately without making this issue a restyling
task. Use var(--token) for normal values.
For a genuine one-off exception, put /* design-system-guard: allow color */
or /* design-system-guard: allow spacing */ on the declaration or on the
immediately preceding line. Use allow all only when both exceptions are
unavoidable, and explain the reason in the surrounding CSS comment.
Vitest runs in jsdom with the setup in src/test/setup.ts and
restoreMocks: true (see vite.config.ts), so every mock is reset between
tests and cleanup() unmounts what each test rendered.
Tests must never reach the network. The contract clients are mocked in every test; a test that performs a real call will fail intermittently rather than honestly — it depends on RPC latency and testnet state, which are exactly the things a unit test must not depend on. A test that needs data gives its mock a value; it never asks a real contract for one.
This is why the suite exists at all: the tests are what prove the read and
write hooks race-safe without a network. restoreMocks will not save you from
yourself — it only restores vi.fn state, it does not replace a call that was
never mocked.
The generated clients from @milepost/* are plain objects, so a test supplies its
own response with a mock. A worked example from
useContractRead.test.ts —
a read returning a value:
const call = vi.fn().mockResolvedValue({ result: 42 });
const { result } = renderHook(() => useContractRead(call, []));
await waitFor(() => expect(result.current.data).toBe(42));A read the bindings wrap in a Result uses useContractResult, so the mock
must supply the unwrap:
const call = vi.fn().mockResolvedValue({
result: { unwrap: () => 'inside' },
});For a write, useTransaction is the only path, and its mock supplies a
signAndSend:
await act(async () => {
await result.current.send(async () => ({
signAndSend: vi.fn().mockResolvedValue({ result: 'award-1' }),
}));
});To control when a promise settles — the pattern every race test needs — use a deferred:
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => { resolve = res; });
return { promise, resolve };
}To mock a module the component imports (wallet, router), use vi.hoisted so
the mock exists before the import runs:
const { useWallet } = vi.hoisted(() => ({ useWallet: vi.fn() }));
vi.mock('../context/useWallet', () => ({ useWallet }));The wallet mock in useTransaction.test.ts is the worked example of the full
pattern, including the connected/declined cases.
src/test/reachable.test.ts walks the import graph from main.tsx and fails
on any module nothing imports. If this test names your file, it is not a bug in
the test — it is telling you the component is unreachable: it compiles,
lints and passes its own tests while the bundler drops it and no user can reach
it. That has happened repeatedly here: three pages merged unreachable, then
four policy and admin components, then one more.
To satisfy it properly, add a route in App.tsx or render the component from
something already reachable. Do not add your file to ALLOWED_UNREACHABLE —
every entry there is a module the check can no longer protect.
The behaviours the suite is built to lock down are the ones that are invisible until they break — not "does the screen render", which the reachability check and the type-checker already cover:
- Stale responses. A slow read landing after a newer one must not
overwrite it.
useContractRead.test.tsresolves the second call first and asserts the older response is ignored. - Double submission. A duplicated contribution is real money.
useTransaction.test.tsfires a secondsendwhile the first is in flight and asserts the build ran once. - Declined signatures. A signer saying no is a choice, not a failure.
The same file asserts the phase returns to
idlerather than showing an error banner.
Page tests (src/pages/*.test.tsx) show the component-level patterns — how to
render a page with mocked hooks and assert on the visible result.
Contract testing is a separate guide, in milepost (testing guide). End-to-end browser testing does not exist here, and there is no intention of adding it — the no-network rule is what makes the suite deterministic, and e2e tests would reintroduce exactly the flakiness it removes.