Testing¶
Authoritative testing conventions for the GrinSystem frontend. Read once, follow every ticket.
TL;DR¶
- Every feature ticket ships happy path + at least one failure path in the same branch.
- Stack: Vitest + React Testing Library + MSW, jsdom env, providers via a shared
customRender. - Tests are co-located with the file under test (
Foo.test.tsxnext toFoo.tsx). - No snapshot tests. No tests on
src/generated/. No mocking of Orval hooks — mock the network with MSW.
What we test¶
| Layer | How | What to assert |
|---|---|---|
| Pure functions (parsers, mappers, formatters) | Vitest only | Inputs → outputs, edge cases, error messages |
| Zustand stores | Vitest, call actions on a fresh store | Resulting state, selector outputs |
| Custom hooks | renderHook from @testing-library/react |
Returned values, side effects after act |
| Components / pages | RTL via customRender |
User-visible behaviour — text, form values, navigation |
| Mutation hooks (with side effects) | RTL component that uses them | The side effect — toast appears, list refreshes, route changes |
What we don't test¶
src/generated/— Orval output, regenerated on every spec change. Trust the generator.- Implementation details — internal
useStatevalues, prop shapes received by a child, CSS classes, query keys,data-testidchains. - Third-party libraries — TanStack Query, RHF, Radix. Test our usage of them, not them.
- Snapshot tests — too brittle for an evolving ERP UI. Reviewers reject PRs that introduce them.
Tooling¶
| Tool | Role |
|---|---|
vitest |
Test runner, watch mode, coverage |
jsdom |
DOM environment |
@testing-library/react |
Render + query API for component tests |
@testing-library/user-event |
Realistic user interactions (clicks, typing) — prefer over fireEvent |
@testing-library/jest-dom |
DOM matchers (toBeInTheDocument, toHaveValue, etc.) |
msw |
Network mocking — the only way we intercept HTTP in tests |
Two shared files Foundation ships in src/test/:
src/test/render.tsx— exportscustomRender(ui, options?)that wraps with the app's real providers:QueryClientProvider(freshQueryClientper test,retry: false,gcTime: 0),RouterProvider(memory history),I18nextProvider. All component tests usecustomRender. Never call RTL'srenderdirectly.src/test/setup.ts— registers@testing-library/jest-dommatchers, starts the MSW server (server.listen), resets handlers after each test (server.resetHandlers), closes after the file (server.close). Wired invite.config.tsviatest.setupFiles.
File placement and naming¶
- Co-locate.
ItemListPage.test.tsxsits next toItemListPage.tsx. Never a top-level__tests__/mirror. - Extension matches the subject:
.test.tsxfor component tests,.test.tsfor pure-function / hook tests. describe('Subject', ...)— Subject is the exact symbol name under test (component, hook, or function), as a string literal. One top-leveldescribeper file.it('does X when Y')— present tense, behavioural. Noshouldprefix.- Nested
describeonly when grouping a clearly distinct scenario (e.g.describe('when read-only')inside a component file).
describe('parseEnv', () => {
it('returns the typed object when all required keys are valid', () => { /* ... */ })
it('throws naming the offending field when a URL is malformed', () => { /* ... */ })
})
MSW handler ownership¶
Handlers are split by ownership so feature work never edits the shared file:
- Cross-cutting —
src/test/msw/handlers.ts. Owned by Foundation. Contains the defaults the test server starts with: auth ping (when identity ships), the BE error-envelope shape for 400/404/409/500, the 409CONCURRENT_MODIFICATIONbody, the idempotency-replay header behaviour. Touch only via a foundation ticket. - Per-feature —
src/features/<module>/__msw__/handlers.ts. Each feature owns its own request handlers (list, detail, create, update, delete). Import them into the test file that needs them and register viaserver.use(...handlers). They override the cross-cutting defaults for the scope of one test.
import { server } from '@/test/msw/server'
import { itemListHandler } from '@/features/item-catalog/__msw__/handlers'
beforeEach(() => server.use(itemListHandler([{ id: '1', name: 'Tomato' }])))
Browser mode for dev preview (pnpm dev:msw)¶
pnpm dev:msw boots Vite with MSW intercepting every /api/* request in the browser, so a page renders against mock data with no backend running — useful before the BE has data, or before the endpoint exists at all. pnpm dev is unchanged: it talks to the real BE via the axios baseURL.
The browser handlers are Orval-generated (mock: true in orval.config.ts), faker-seeded from the OpenAPI spec. This is a different source from the Node-mode test handlers, and the two never mix:
| Use case | Handler source |
|---|---|
Browser dev preview (pnpm dev:msw) |
Orval-generated <module>.msw.ts (any plausible data to fill the page) |
| Vitest scenario tests | hand-written __msw__/handlers.ts via server.use(...) (specific 404 / 409 / validation-envelope assertions) |
How it's wired
- Each module's generated
src/generated/<module>.msw.tsexports an aggregate handler factory. Orval names it after the OpenAPIinfo.title, so it is the same symbol (getGrinsystemApiMock) in every module file — alias it on import to avoid collisions. src/test/msw/browser.tscomposes them into onesetupWorker(...). Append one spread per module as each lands:
import { setupWorker } from 'msw/browser'
import { getGrinsystemApiMock as itemCatalogHandlers } from '@/generated/item-catalog.msw'
// import { getGrinsystemApiMock as manufacturingHandlers } from '@/generated/manufacturing.msw'
export const worker = setupWorker(...itemCatalogHandlers())
src/main.tsxstarts the worker before React mounts, gated onimport.meta.env.DEV && import.meta.env.VITE_MSW === 'true'. TheDEVguard is staticallyfalseinvite build, so the dynamicimport('@/test/msw/browser')(and with itmsw/browser, the generated mocks, and faker) is tree-shaken out of every production bundle.- The worker starts with
onUnhandledRequest: 'bypass', so a partially-mocked surface still falls through to the real BE rather than erroring. public/mockServiceWorker.jsis the committed, version-agnostic worker script, regenerated bypnpm exec msw init public/ --savewhen the MSW dependency is bumped.
Need a specific shape for a one-off preview (a 409, an empty list)? Layer a server.use(...)-style override in browser.ts — but for repeatable assertions, that belongs in a Vitest test, not the browser worker.
Async patterns¶
- Always use
findBy*orwaitFor. NeversetTimeoutto wait for query refetches, MSW responses, or animations. - Prefer
await screen.findByText('Saved')overawait waitFor(() => expect(...))when you can assert on a specific element appearing — clearer failures. - For mutations, assert on the observable side effect (toast text, navigation, the row updated in place), not on the mutation function being called.
- Never
vi.useFakeTimers()to short-circuit query timing — change theQueryClientdefaults incustomRenderinstead (retry: false,gcTime: 0).
What every feature ticket must include¶
These are the baselines reviewers enforce. No fixed coverage percentage.
- Happy path — render with seed data via MSW, assert visible labels, exercise one primary interaction.
- At least one failure path — empty state, network error, or a BE field error rendered on the form (use the error-envelope handler).
- List pages with filters — one test that committing a filter triggers a refetch with the new query params (assert on the request URL the MSW handler sees).
- Edit forms — one test that a 409
CONCURRENT_MODIFICATIONtriggers refresh-and-retry UX (toast + invalidation, no auto-resubmit). - Mutation hooks — exercised through a component test, not in isolation, unless the hook is genuinely reusable.
A PR without these baselines is incomplete and gets sent back.
Examples¶
Pure function — co-located, no providers needed.
import { describe, expect, it } from 'vitest'
import { itemFormSchema } from './schema'
describe('itemFormSchema', () => {
it('rejects an empty name', () => {
const result = itemFormSchema.safeParse({ name: '', item_type: 'raw_material' })
expect(result.success).toBe(false)
expect(result.error?.issues[0]?.path).toEqual(['name'])
})
})
Component test — customRender, MSW-driven list, filter interaction.
import { describe, expect, it, beforeEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { screen } from '@testing-library/react'
import { customRender } from '@/test/render'
import { server } from '@/test/msw/server'
import { itemListHandler } from './__msw__/handlers'
import { ItemListPage } from './ItemListPage'
describe('ItemListPage', () => {
beforeEach(() => {
server.use(itemListHandler([{ id: '1', name: 'Tomato', item_type: 'raw_material' }]))
})
it('renders the items returned by the API', async () => {
customRender(<ItemListPage />)
expect(await screen.findByText('Tomato')).toBeInTheDocument()
})
it('refetches with the type filter when the user commits it', async () => {
customRender(<ItemListPage />)
await screen.findByText('Tomato')
await userEvent.selectOptions(screen.getByLabelText(/type/i), 'finished_good')
await userEvent.click(screen.getByRole('button', { name: /apply/i }))
expect(await screen.findByText(/no items/i)).toBeInTheDocument()
})
})
Failure path — BE field error rendered on the form.
import { describe, expect, it } from 'vitest'
import userEvent from '@testing-library/user-event'
import { screen } from '@testing-library/react'
import { customRender } from '@/test/render'
import { server } from '@/test/msw/server'
import { itemCreateFieldErrorHandler } from './__msw__/handlers'
import { ItemCreatePage } from './ItemCreatePage'
describe('ItemCreatePage', () => {
it('renders a field-level error from the BE error envelope', async () => {
server.use(itemCreateFieldErrorHandler({ field: 'name', message: 'Name already exists' }))
customRender(<ItemCreatePage />)
await userEvent.type(screen.getByLabelText(/name/i), 'Tomato')
await userEvent.click(screen.getByRole('button', { name: /save/i }))
expect(await screen.findByText('Name already exists')).toBeInTheDocument()
expect(screen.queryByText(/saved/i)).not.toBeInTheDocument()
})
})
Anti-patterns¶
- Asserting on CSS classes, internal state, or
data-testidchains instead of user-visible content. - Mocking Orval-generated hooks (
vi.mock('@/generated/...')) — mock the network with MSW. - Permanently stubbed queries (
useGetItems.mockReturnValue(...)) — they bypass the data layer the user actually exercises. vi.useFakeTimers()to dodge query refetch timing.setTimeout/ arbitraryawait new Promise(r => setTimeout(r, 50))waits.- Snapshot tests.
- Tests against
src/generated/. - Calling
act()manually — RTL wraps it for you.
Where this fits¶
- Project overview, layered architecture, key rules:
CLAUDE.mdat the repo root. - Hook-extraction criteria — "business rule independently testable" is one reason to extract a hook; testability drives shape: docs/standards.md § Hooks.
- Foundation roadmap (M5 wires the harness this doc describes): docs/modules/foundation/fe-plan.md.