Frontend tests (Vitest)
The unit suite for the React SPA. It is small on purpose and it is honest about being small: 4 test files, 60 tests, all green, running in well under a second. Everything else about the frontend is covered by the browser suite (e2e-playwright.md) or by the type checker.
This page covers what is tested and why those four modules were picked. The
other two frontend gates — tsc -b and ESLint — are at the bottom, because
"the frontend tests pass" is not the same as "the frontend is green".
Running
All commands run from frontend/. Nothing else needs to be running: there is
no database, no backend, and no browser involved.
cd frontend
npm test # vitest run — single pass, 60 tests, ~1s
npm run test:watch # vitest in watch mode
npx vitest run filterUtils # one file, by substring of its path
npx vitest run -t 'sitemap' # tests whose name matches a pattern
npm test is vitest run (the run matters — bare vitest watches and never
exits, which will hang a script or a CI step). There is no coverage tool
configured; npx vitest run --coverage prompts to install a provider.
The configuration is three lines
frontend/vitest.config.ts sets exactly one
option:
export default defineConfig({
test: {
environment: 'node',
},
})
That single line is the most important fact about this suite. The tests run in
Node, not in a DOM. There is no jsdom or happy-dom, no
@testing-library/react, and no setup file, so:
- No component renders. You cannot mount a React component, fire a click, or assert on rendered output. Nothing in the suite tries.
- There are no browser globals.
window,documentandlocalStoragedo not exist unless a test provides them.legacyGridState.test.tsneeds storage and supplies a 10-line in-memoryMemoryStorageclass rather than pulling in jsdom for one module — follow that pattern before you reach for a DOM. - It is fast, which is why it can sit in front of every pull request without anyone minding.
Note also that this config is separate from
vite.config.ts and does not inherit its
@ → src/ path alias. Every existing test imports its subject by relative
path (./filterUtils, ../components/marketing/solutions-data). Keep doing
that, or the import will resolve in your editor and fail under Vitest.
What is tested, and why these four
The selection rule visible in the suite is consistent: pure functions whose failure is silent. None of these modules would throw a visible error when they break — they would quietly return the wrong thing, and the damage would show up as a wrong grid, a de-indexed page, or a payslip missing a number.
| File | Tests | Subject |
|---|---|---|
src/ui/ag-grid/filterUtils.test.ts | 32 | extractTextFilter, extractNumberRange, extractDateRange |
src/ui/ag-grid/legacyGridState.test.ts | 11 | migrateLegacyGridState |
src/lib/payslip-template/templateRegistry.test.ts | 12 | Every entry in PAYSLIP_TEMPLATE_OPTIONS |
src/constants/seo.test.ts | 5 | ROUTE_SEO and its agreement with sitemap.xml |
filterUtils — the most branching logic in the app
filterUtils.ts translates AG
Grid's filter model into the query parameters the backend's Specification
builders expect (../frontend/ag-grid.md). Every table
page in the product goes through it, and it has a case for each AG Grid filter
type — contains, startsWith, endsWith, equals, notEqual,
notContains, blank, notBlank, the numeric comparisons, inRange, and the
compound AND/OR shape. That is a lot of branches behind one function, each one
reachable only by a specific click path in the UI, which makes it precisely the
code that unit tests are good at and e2e tests are expensive at.
The tests also pin the edge cases that produced real bugs: a null filter model
returns null rather than throwing, a compound filter resolves to its first
condition, and blank/notBlank produce a filter type with no value.
legacyGridState — a migration that runs exactly once
migrateLegacyGridState moves a user's saved column layout from the old
storage key to the per-tab keys the current grid uses. A migration that runs
against real users' localStorage gets one attempt, so the tests lock down the
properties that make it safe: it is idempotent, it never overwrites state the
user already has on the new key, it deletes the legacy key so it cannot run
twice, it strips sort/sortIndex (so a column that is no longer sortable
cannot be restored into a broken state), it preserves the filter model verbatim,
and it swallows an unparseable blob instead of throwing.
templateRegistry — contract guards for payslip PDFs
The four ready-made payslip templates
(../frontend/README.md lists @pdfme in the stack) are
data, not code, and a missing field in one of them means a generated payslip
silently drops a number. The file uses describe.each over
PAYSLIP_TEMPLATE_OPTIONS, so every template gets the same three assertions
and a newly added template is covered the moment it is registered: it contains
every required functional field, it keeps the dynamic earnings/deductions region
empty (rows are injected there at generation time), and its conditional
decorations gate only on known gates.
seo — a gap here costs search traffic
ROUTE_SEO drives both the client-side head and the static HTML written by
scripts/generate-route-html.ts at build time. The file's own comment explains
the stake: a route missing from it falls back to index.html's canonical URL,
which tells search engines the page is a duplicate of the homepage and drops it
from the index. The tests assert every solution detail page has an entry, there
are no duplicate paths, every route has a non-empty title and description, paths
are root-relative, and the set matches public/sitemap.xml exactly — that
last one is the assertion that catches a new page added in one place and
forgotten in the other.
Adding a test
Put the file next to its subject as <subject>.test.ts — there is no __tests__
directory and no separate test tree. Vitest's default include picks up
**/*.{test,spec}.?(c|m)[jt]s?(x) anywhere under the project.
import { describe, expect, it } from 'vitest';
import { thingUnderTest } from './thing';
describe('thingUnderTest', () => {
it('describes the rule being enforced', () => {
expect(thingUnderTest(input)).toEqual(expected);
});
});
Conventions the existing files share, worth keeping:
-
Import the Vitest helpers explicitly. There is no
globals: true, sodescribe,it,expectandbeforeEachmust be imported fromvitest. -
A file-level comment says why the module is worth testing — the invariant, the bug it locks down, or the cost of getting it wrong.
seo.test.tsandtemplateRegistry.test.tsare the models. -
Test names read as the rule, not as the mechanics: "never overwrites state the user already has on the new key", not "test migrate 3".
-
Use
describe.eachwhen the same contract applies to a set of things, as the payslip templates do. It means new members are covered automatically. -
Pass a message to
expectwhen the assertion loops, so a failure names the offending item rather than just reporting "expected defined":expect(seoForPath(path), `no SEO entry for ${path}`).toBeDefined();
Good candidates are pure modules under src/utils/, src/constants/ and
src/lib/ — formatters, mappers, registries and validators. For anything that
needs rendering, a real API, or permissions, write a Playwright spec instead
(e2e-playwright.md); the node environment cannot help you
there.
The other two frontend gates
npm test alone is a weak signal, because 60 tests over four files cannot say
much about an application this size. Two other checks carry more weight
day to day.
Type checking — npm run type-check
tsc -b across the project references in
tsconfig.json (tsconfig.app.json +
tsconfig.node.json). This is the same check npm run build runs first, so a
green type-check means the production image build will not fail on types either.
In practice this is the gate that catches the most real breakage — most notably
whenever a backend DTO changes shape and the api/ modules go stale.
It is blocking in CI.
Linting — npm run lint
ESLint via the flat config in
eslint.config.js (typescript-eslint plus
react-hooks v7).
It is not blocking in CI. The job in
tests.yml runs it with
continue-on-error: true because it currently reports pre-existing problems on
code that predates the gate, and failing every deploy on day one was judged
worse than advisory output while the count is worked down. The comment in the
workflow states the exit condition plainly: delete continue-on-error and add
--max-warnings 0 the day it reaches zero. Until then, read the output rather
than trusting the green check.
What CI runs
The Frontend (typecheck, lint, vitest) job in
tests.yml runs npm ci, then
npm run type-check, then npm run lint (advisory), then npm test. It fires
on every pull request to main/master, and deploy.yml calls the same
workflow before it builds an image — so a broken type-check or a red Vitest run
stops a deploy. Full picture in ci-quality-gates.md.