Skip to main content

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, document and localStorage do not exist unless a test provides them. legacyGridState.test.ts needs storage and supplies a 10-line in-memory MemoryStorage class 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.

FileTestsSubject
src/ui/ag-grid/filterUtils.test.ts32extractTextFilter, extractNumberRange, extractDateRange
src/ui/ag-grid/legacyGridState.test.ts11migrateLegacyGridState
src/lib/payslip-template/templateRegistry.test.ts12Every entry in PAYSLIP_TEMPLATE_OPTIONS
src/constants/seo.test.ts5ROUTE_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, so describe, it, expect and beforeEach must be imported from vitest.

  • 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.ts and templateRegistry.test.ts are 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.each when 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 expect when 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.