5.5 KiB
Client testing, lint and types
What is tested and how, plus the two gate tools and their traps.
Last verified: 2026-07-30 against commit
d3ec723.
1. What is tested
Every shared component has a co-located test file. A component is "shared" if it is imported from more than one place — a page, a layout, or another component.
There are 125 test files across src/ (114 *.test.tsx, 11 *.test.ts). Location:
src/components/<Name>/<Name>.test.tsx, next to the component.
Coverage baseline for a shared component
- It renders without crashing.
- Every documented prop produces the correct HTML attribute or CSS class.
- User interactions (click, change) call the expected callbacks.
That is a floor, not a ceiling. Where a component encodes a rule, test the rule: BookingDetailView's test
proves the customer never fires the care-instructions query (the two-stage disclosure gate), and
matchActivePath's test proves a nested route lights up its parent tab and never a sibling. Those are the
tests worth writing.
The two rules about the test itself
- Wrap with
<ThemeProvider>if the component uses MUI theming. - Do NOT mock MUI components. Test against the rendered DOM. A test that mocks
Buttonproves nothing about what a user sees.
Before removing or renaming a shared component
Check whether any src/**/*.test.{ts,tsx} imports it. If so, update or delete those tests in the same
change. A dangling test import fails the suite, and a test left behind for a deleted component is dead code.
Deliberate coverage gaps
NeshanMap is not unit-tested — jsdom plus Leaflet is an integration problem, not a unit one — and it is
unreachable in CI anyway, because NEXT_PUBLIC_NESHAN_KEY is unset there so AddressMapPicker falls back to
the bounded-canvas stand-in. Both branches of that fork are tested; the map itself isn't.
2. Jest configuration
jest.config.ts replaces next/jest's transformIgnorePatterns array outright rather than appending to
it. This is deliberate and easy to undo by accident:
next/jest's default pattern already broadly matches all of node_modules (its negative-lookahead allowlist
carves out only a couple of Next-internal packages), and Jest's array semantics are OR-based — a file is
ignored if any pattern matches. So appending a more permissive pattern can never "un-ignore" a package an
earlier pattern already caught. The array has to be replaced, by post-processing the async config next/jest
returns.
What it lets through: next-intl, use-intl, @formatjs, intl-messageformat — all ESM-only builds.
That fixes real imports; it does not make the dependency free. See
components.md "Presentational purity" for why ErrorBoundary/ErrorState are caller-owned
and Money is the one sanctioned exception.
3. The gate
npm run check → npm run type && npm run lint && npm run lint:copy
Plus npm run test:ci when you touched a component with a co-located test.
Both gate tools are plain CLI tools. There is no next lint — it was removed in Next 16, and calling it
silently does nothing.
| Script | Runs |
|---|---|
type |
tsc --noEmit. tsconfig.json: strict on, noEmit, @/* → src/* |
lint |
eslint ., driven by flat config in eslint.config.mjs |
lint:copy |
node scripts/check-copy.mjs — see i18n.md §4 |
eslint.config.mjs spreads eslint-config-next (core-web-vitals + typescript + react + react-hooks +
jsx-a11y + import) and applies eslint-config-prettier last, so ESLint never fights Prettier on
formatting.
4. Lint rules for this project
- Flat config only. Do not add
.eslintrc*files — put any rule change ineslint.config.mjs. - ESLint owns correctness, Prettier owns formatting. Don't add stylistic ESLint rules.
- No unused variables or imports.
@typescript-eslint/no-unused-varsis raised from eslint-config-next's defaultwarntoerror, so dead code failsnpm run check. Delete it rather than disabling the rule; prefix a deliberately-unused binding with_(_event,catch (_err)) to opt out. - Prefer fixing code over silencing the linter. When a disable is genuinely correct — the real example
here is a deliberate browser-only read after mount that trips
react-hooks/set-state-in-effect— use a scoped// eslint-disable-next-line <rule>with a one-line reason. Never a file-wide disable.
Two pinned constraints
- ESLint is pinned to 9. ESLint 10 currently crashes with this Next 16 toolchain
(
scopeManager.addGlobals is not a function). Don't bump it as a housekeeping change. import/no-cycleis disabled — its TypeScript resolver has an interface mismatch here. The reason is noted ineslint.config.mjs; don't re-enable it without checking that note.
5. MUI v9 API only
The type gate catches most of this, but not all of it, and the failures are confusing when it doesn't.
- Use
sx={{ mb: 4 }}, notmb={4}as a direct prop. - Do not pass
flexWraporuseFlexGapas direct props toStack. Neither is a valid v9Stackprop; both cause a TypeScript overload error. Usesx={{ flexWrap: 'wrap' }}.useFlexGapwas a v5 opt-in and does not exist in v9. - No other v5/v6-era props:
storageWindow,InitColorSchemeScript. See theme.md for the color-scheme ones specifically, which fail silently rather than at compile time. - Avoid deprecated MUI APIs that throw at runtime.