Frontend Testing Guide¶
How the admin portal (and any future frontend app) tests itself. Read this before writing a new component or touching the SDK wiring.
TL;DR¶
| Layer | Tool | What it catches | Speed | Cost |
|---|---|---|---|---|
| 1. Unit / component | Vitest + React Testing Library | Pure function bugs, form validation, conditional rendering | ms per test | near zero |
| 1b. Hook integration | Vitest + RTL + MSW | React Query hooks, mutations, error states | tens of ms per test | low |
| 2. Contract | Vitest + zod.gen.ts |
Backend/SDK/frontend contract drift before a request fires | ms per test | near zero |
| 3. End-to-end (future) | Playwright + Testcontainers backend | Full user journeys | seconds per test | high |
Layers 1 and 2 are wired up today. Layer 3 (Playwright) is deferred — see the strategy discussion in CHANGELOG entries for why.
Running tests¶
From ui-clients/frontend-web-apps/apps/admin-portal/:
npm test # one-shot run, exits non-zero on failure (use this in CI)
npm run test:watch # interactive watcher that reruns on file change
From the workspace root (ui-clients/):
npm run test --workspace=smartsapp-admin-portal-v3
How it's wired¶
vitest.config.ts¶
Merges with the app's vite.config.ts so path aliases (@/ and @smartsapp/sdk) work in tests exactly as they do in runtime code. No duplicate config. Sets:
environment: jsdom— browser-like DOM for React componentssetupFiles: ["./src/test/setup.ts"]— runs once per test fileinclude: ["src/**/*.{test,spec}.{ts,tsx}"]— tests live next to the code they cover
src/test/setup.ts¶
- Imports
@testing-library/jest-dom/vitest(extendsexpectwith DOM matchers liketoBeInTheDocument,toHaveAttribute) - Starts MSW with
onUnhandledRequest: "error"— any fetch call that doesn't match a registered handler fails the test. This is deliberate: it prevents components from silently talking to the real backend in CI. - Resets MSW handlers after every test so per-test
server.use(...)overrides don't leak.
src/test/msw-server.ts¶
- Sets up a Node-side MSW server and exports it.
- Ships a minimal default handler for
POST /api/canteen/menus(most tests override it locally). - Exports
stubMenuResponse— a hand-maintained payload that matches theMenuResponseOpenAPI shape. Keep in sync withzod.gen.tswhen the contract evolves.
src/test/test-utils.tsx¶
createTestQueryClient()— a zero-retry, zero-cache QueryClient. Prevents flakes and keeps tests deterministic.renderWithProviders(ui, opts)— drop-in replacement for RTL'srender()that wraps the tree inQueryClientProvider+MemoryRouter. Returns{ queryClient, ...rtlResult }so tests can assert cache state.
Writing tests¶
Rule 1 — pure functions first¶
Every piece of logic that takes data in and returns data out should have a unit test. They're the cheapest and most stable tests in the project. See menu-planner.utils.test.ts for the pattern.
Don't spin up providers or mock networks for a function that just maps data.
Rule 2 — hooks with renderHook¶
React Query hooks are stateful, so test them with renderHook + a QueryClientProvider wrapper. MSW handles the fetch layer. See menu.mutations.test.tsx.
Key patterns:
- Use
createTestQueryClient(), not the productionqueryClientfrom@/app/providers/QueryProvider. Production has retries, background refetch, stale windows — all noise in tests. - Override MSW handlers per test via
server.use(http.post(...)). The test file knows what shape it's asserting; the global default is only a smoke fallback. - Capture request bodies for contract-style assertions:
let received: unknown; server.use( http.post("*/api/canteen/menus", async ({ request }) => { received = await request.json(); return HttpResponse.json(stubMenuResponse, { status: 201 }); }), ); // ... run the hook ... expect(received).toMatchObject({ visibilityDays: 14 });
Rule 3 — components via renderWithProviders¶
For real component tests (form submit, button click, toast appearance) use renderWithProviders(<Form />) and interact via @testing-library/user-event.
import { userEvent } from "@testing-library/user-event";
import { renderWithProviders } from "@/test/test-utils";
const user = userEvent.setup();
renderWithProviders(<MyForm />);
await user.click(screen.getByRole("button", { name: /save/i }));
Query by role / label, not by test-id or class name. RTL's whole philosophy is "test what the user sees". Brittle selectors are a smell.
Rule 4 — contract tests catch drift before the request fires¶
This is the killer use case for src/test/contract.test.ts: import the z*Request schemas from @smartsapp/sdk/canteen-staff/zod.gen, build a sample payload, and assert it passes. When the backend adds a required field and you regenerate the SDK, the affected contract test fails until the frontend catches up.
Add a contract test every time you:
- Add or remove a field on a backend DTO
- Rename an enum value
- Change a validation rule (@NotNull, @Min, @Max)
One negative test per known-incident is worth its weight: write a test that asserts the regression doesn't come back. See rejects a payload missing visibilityDays — contract drift guard for an example.
The golden path — TDD a new feature¶
- Start with the contract test. Add/update a Zod schema assertion for the new DTO field.
- Write a unit test for any pure logic (mapping, formatting, validation).
- Write a hook test for the React Query mutation/query wiring.
- Write a component test — only if there's meaningful UI logic beyond dumb rendering.
- Implement the feature.
- Run
npm test— everything should be green.
If you find yourself writing a test with more setup than assertion, you're probably in the wrong layer — drop down a level.
When MSW surprises you¶
"Error: Unhandled request: POST http://localhost/api/..."¶
A component fired a request to an endpoint that has no MSW handler. Two fixes:
- Add a per-test handler via
server.use(http.post("*/api/your/endpoint", ...)). Preferred — keeps intent local. - Add a default handler in
msw-server.ts. Reserve this for endpoints that every test needs silenced (e.g. auth refresh polling).
Don't silence unhandled requests globally by setting onUnhandledRequest: "warn" or "bypass" — that hides real bugs.
"TypeError: Cannot read properties of undefined (reading 'then')"¶
Usually means MSW hasn't booted yet. Confirm beforeAll(() => server.listen()) is running. Check setupFiles in vitest.config.ts.
MSW won't intercept a request from @smartsapp/sdk¶
The SDK uses @hey-api/client-fetch, which uses the global fetch. MSW's Node interceptor patches global fetch. If a test still sees real network activity, something in the test pulled in a version of fetch that's not the global. Check imports — don't import { fetch } from "...some polyfill...".
What NOT to test¶
- Storybook stories. Stories are for humans, not robots. Visual regression is Chromatic's job, if/when we adopt it.
- Third-party components (radix-ui, recharts, etc.). Assume they work; test your integration with them.
- Pass-through props. If a component just forwards props to a child, that's not a test case.
- Exact DOM structure. Query by role/label/text, not
classNameor tag hierarchy. - Every possible error path. Focus on the user-visible behaviours. 80% of real bugs live in 20% of the code.
Adding the SDK regen step¶
When you change a backend @Entity, DTO, or controller:
cd ui-clients/sdk
npm run sync # regenerates types.gen.ts and zod.gen.ts
cd ../frontend-web-apps/apps/admin-portal
npm test # contract tests fail fast on drift
If a contract test goes red, update the frontend code that builds that payload (usually a form, an API helper, or a mutation hook). Never "fix" the contract test by loosening its assertion — that defeats the whole point.
Future: Layer 3 (Playwright)¶
When we add Playwright, it'll sit on top of Layers 1–2 as a small suite (~5–10 tests) of smoke-level happy paths: login, create menu, create item, place an order. It won't replace these layers; it'll complement them. See the Flyway migration guide for the analogous "start small, layer up" approach we took for DB migrations.
Not adopted yet because: Layers 1–2 catch ~80% of the bugs at ~10% of the maintenance cost, and we want that in place first.