Skip to content

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.tsx next to Foo.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 useState values, prop shapes received by a child, CSS classes, query keys, data-testid chains.
  • 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 — exports customRender(ui, options?) that wraps with the app's real providers: QueryClientProvider (fresh QueryClient per test, retry: false, gcTime: 0), RouterProvider (memory history), I18nextProvider. All component tests use customRender. Never call RTL's render directly.
  • src/test/setup.ts — registers @testing-library/jest-dom matchers, starts the MSW server (server.listen), resets handlers after each test (server.resetHandlers), closes after the file (server.close). Wired in vite.config.ts via test.setupFiles.

File placement and naming

  • Co-locate. ItemListPage.test.tsx sits next to ItemListPage.tsx. Never a top-level __tests__/ mirror.
  • Extension matches the subject: .test.tsx for component tests, .test.ts for pure-function / hook tests.
  • describe('Subject', ...) — Subject is the exact symbol name under test (component, hook, or function), as a string literal. One top-level describe per file.
  • it('does X when Y') — present tense, behavioural. No should prefix.
  • Nested describe only 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-cuttingsrc/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 409 CONCURRENT_MODIFICATION body, the idempotency-replay header behaviour. Touch only via a foundation ticket.
  • Per-featuresrc/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 via server.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.ts exports an aggregate handler factory. Orval names it after the OpenAPI info.title, so it is the same symbol (getGrinsystemApiMock) in every module file — alias it on import to avoid collisions.
  • src/test/msw/browser.ts composes them into one setupWorker(...). 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.tsx starts the worker before React mounts, gated on import.meta.env.DEV && import.meta.env.VITE_MSW === 'true'. The DEV guard is statically false in vite build, so the dynamic import('@/test/msw/browser') (and with it msw/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.js is the committed, version-agnostic worker script, regenerated by pnpm exec msw init public/ --save when 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* or waitFor. Never setTimeout to wait for query refetches, MSW responses, or animations.
  • Prefer await screen.findByText('Saved') over await 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 the QueryClient defaults in customRender instead (retry: false, gcTime: 0).

What every feature ticket must include

These are the baselines reviewers enforce. No fixed coverage percentage.

  1. Happy path — render with seed data via MSW, assert visible labels, exercise one primary interaction.
  2. At least one failure path — empty state, network error, or a BE field error rendered on the form (use the error-envelope handler).
  3. 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).
  4. Edit forms — one test that a 409 CONCURRENT_MODIFICATION triggers refresh-and-retry UX (toast + invalidation, no auto-resubmit).
  5. 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 testcustomRender, 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-testid chains 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 / arbitrary await 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.md at 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.