Skip to content

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 components
  • setupFiles: ["./src/test/setup.ts"] — runs once per test file
  • include: ["src/**/*.{test,spec}.{ts,tsx}"] — tests live next to the code they cover

src/test/setup.ts

  • Imports @testing-library/jest-dom/vitest (extends expect with DOM matchers like toBeInTheDocument, 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 the MenuResponse OpenAPI shape. Keep in sync with zod.gen.ts when 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's render() that wraps the tree in QueryClientProvider + 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 production queryClient from @/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

  1. Start with the contract test. Add/update a Zod schema assertion for the new DTO field.
  2. Write a unit test for any pure logic (mapping, formatting, validation).
  3. Write a hook test for the React Query mutation/query wiring.
  4. Write a component test — only if there's meaningful UI logic beyond dumb rendering.
  5. Implement the feature.
  6. 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:

  1. Add a per-test handler via server.use(http.post("*/api/your/endpoint", ...)). Preferred — keeps intent local.
  2. 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 className or 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.