name: frontend-unit-test description: Use when writing, modifying, or debugging unit tests in the orchestration cluster webapp at webapp/client/apps/orchestration-cluster-webapp/. Use when working with Vitest browser mode, MSW mocking, vitest-browser-react rendering, or any *.test.tsx file in the OC webapp's src/ directory. Trigger whenever someone needs to create, fix, or understand a frontend unit test in webapp/client/.
Frontend Unit Testing
Unit tests in @camunda/orchestration-cluster-webapp run in a real Chromium browser via Vitest Browser Mode — not jsdom or happy-dom. This matters because components render in an actual DOM with real layout, events, and browser APIs. MSW intercepts all HTTP at the service worker level.
This is fundamentally different from the @testing-library/react + vi.mock() approach used in the legacy Operate and Tasklist frontends. If you're familiar with those patterns, read the key rules carefully — several habits from the legacy apps will produce broken or flaky tests here.
Key rules
- Import
itfrom#/vitest-modules/test-extend, not fromvitest. The custom fixture auto-starts an MSW service worker before each test and resets/stops it after. Importing fromvitestdirectly means no MSW interception — HTTP calls will fail or hit real endpoints. - Use
render()fromvitest-browser-react, not from@testing-library/react. The vitest-browser-react renderer is designed for browser-mode Vitest and handles the real DOM lifecycle correctly. Do NOT importscreenfrom@testing-library/react— it does not exist in this setup. - For components that need routing context (pages, components using
<Link>,useNavigate, route hooks), userenderWithRouter(Component, {path, initialEntry?})from#/vitest-modules/render-with-routerinstead of barerender(). It mounts the component in a minimal isolated route tree backed by an in-memory history — no fullrouteTree.genis loaded, nobeforeLoadruns. - Both
render()andrenderWithRouter()return thescreenobject — you MUST capture the return value:const screen = await renderWithRouter(MyPage, {path: '/my-path'}). There is no globalscreenimport. - Use
expect.element()for DOM assertions — it retries automatically until the assertion passes or times out. This replaces thewaitFor/findBy*/screen.findByRolepatterns you may know from Testing Library. There is nowaitForhere. - Perform all UI interactions (clicks, typing, filling, selecting, tabbing, hovering) with
userEventimported fromvitest/browser— e.g.await userEvent.click(screen.getByRole('button', {name: /save/i})),await userEvent.fill(screen.getByLabelText('Name'), 'value'). It dispatches a realistic full browser event sequence (pointer, mouse, focus, input). Keep usingscreen.getBy*for queries andexpect.element()for assertions —userEventis only for interactions. Do NOT use locator.click()or.fill()methods directly. - Mock HTTP through the
workerfixture using endpoint mocks from#/shared-test-modules/mock-handlers. Each mock is an individually named export (e.g.,mockCurrentUserEndpoint,mockLoginEndpoint) created withcreateEndpointMockfrom#/shared-test-modules/mock-endpoint. Both unit and Playwright tests use the same definitions. All endpoint mocks must be defined inapps/orchestration-cluster-webapp/shared-test-modules/mock-handlers.ts— never createcreateEndpointMockcalls inline in test files. Never usevi.mock()for API calls; it couples tests to implementation details and breaks on refactors. - Prefer testing library selectors:
getByRole,getByLabelText,getByText. They enforce accessible markup and survive structural refactors. AvoidquerySelectorandgetByTestId— they test DOM structure, not behavior. - Co-locate test files with source: a test for
src/shared/foo/bar.tsxsits atsrc/shared/foo/bar.test.tsx; a test forsrc/operate/components/Foo.tsxsits next to it atsrc/operate/components/Foo.test.tsx. Pod areas follow their own conventions for test placement. - Prefix test names with
should(e.g.,it('should display an error on invalid credentials')). - Do not mock the router. Use
renderWithRouter(Component, {path})from#/vitest-modules/render-with-routerwhen the component needs routing context. It mounts the component in a fresh, isolated TanStack Router backed by an in-memory history — the component receives real route params, search params, and navigation. No full application route tree or global providers are loaded, which keeps tests fast and self-contained. Typed file-route hooks (Route.useParams()) will not resolve under the isolated router; useuseParams({from: '/your-path'})instead. - Avoid
vi.mock()in general. Prefer MSW and real implementations. Vitest mocks couple tests to internals and break on refactors. Reach for them only when there is no practical alternative, such as faking time withvi.useFakeTimers. - Do not use
// given / when / thencomments — that is a Java backend convention. Structure tests by visual grouping (blank lines between setup, action, and assertion).
MSW mocking
Endpoint mocks live in #/shared-test-modules/endpoints as a shared dictionary. Each entry is created with createEndpointMock from #/shared-test-modules/mock-endpoint, which builds a typed MSW handler factory for a given endpoint + HTTP method.
Two shapes:
- With Zod schema validation: pass
schema,successResponse, andfailureResponse. The handler validates the request body against the schema and returns the failure response if it doesn't match. - Without validation: pass only
successResponse. The handler always returns the success response.
The worker fixture is injected by the custom it and auto-resets between tests — no manual worker.resetHandlers() needed.
Test structure
Testing a standalone component (no routing context)
import {render} from 'vitest-browser-react';
import {it} from '#/vitest-modules/test-extend';
import {mockUsersEndpoint} from '#/shared-test-modules/mock-handlers';
import {describe, expect} from 'vitest';
import {HttpResponse} from 'msw';
import {UserList} from './UserList';
describe('<UserList />', () => {
it('should render users from the API', async ({worker}) => {
worker.use(
mockUsersEndpoint({
successResponse: HttpResponse.json([
{name: 'Alice'},
{name: 'Bob'},
]),
}),
);
const screen = await render(<UserList />);
await expect.element(screen.getByRole('cell', {name: 'Alice'})).toBeVisible();
await expect.element(screen.getByRole('cell', {name: 'Bob'})).toBeVisible();
});
});
Testing a page or component that needs routing context
Use renderWithRouter(Component, {path, initialEntry?}) — it mounts the component in a minimal isolated route tree with a fresh QueryClient and memory history. No beforeLoad, no global providers, no full route tree. Pass initialEntry when the path contains params (e.g. path: '/users/$id', initialEntry: '/users/42').
import {it} from '#/vitest-modules/test-extend';
import {renderWithRouter} from '#/vitest-modules/render-with-router';
import {describe, expect} from 'vitest';
import {userEvent} from 'vitest/browser';
import {LoginPage} from './LoginPage';
describe('<Login />', () => {
it('should not allow the form to be submitted with empty fields', async () => {
const screen = await renderWithRouter(LoginPage, {path: '/login'});
await userEvent.click(screen.getByRole('button', {name: /login/i}));
await expect.element(screen.getByLabelText(/username/i)).toBeInvalid();
await expect.element(screen.getByLabelText(/^password$/i)).toBeInvalid();
});
});
Assertion patterns
// Visibility
await expect.element(screen.getByRole('button', {name: /submit/i})).toBeVisible();
// Text content
await expect.element(screen.getByRole('heading')).toHaveTextContent('Dashboard');
// Attributes
await expect.element(screen.getByRole('link', {name: 'Docs'})).toHaveAttribute('href', '/docs');
// Absence — element should not be in the document
await expect.element(screen.getByText('Loading...')).not.toBeVisible();
expect.element() is always async and retries — no need to wrap in waitFor or use findBy*.
User interactions
All interactions use userEvent from vitest/browser. It accepts both Element and Locator (the return type of screen.getBy*).
import {userEvent} from 'vitest/browser';
// Click
await userEvent.click(screen.getByRole('button', {name: /submit/i}));
// Fill an input (clears existing value first)
await userEvent.fill(screen.getByLabelText('Name'), 'Alice');
// Type without clearing (appends to existing value)
await userEvent.type(screen.getByLabelText('Name'), ' Bob');
// Clear an input
await userEvent.clear(screen.getByLabelText('Name'));
// Select options in a <select>
await userEvent.selectOptions(screen.getByRole('combobox'), ['option-value']);
// Keyboard
await userEvent.keyboard('{Enter}');
// Tab
await userEvent.tab();
// Hover / unhover
await userEvent.hover(screen.getByText('Tooltip trigger'));
await userEvent.unhover(screen.getByText('Tooltip trigger'));
Common gotchas
- No
waitFororfindBy*:expect.element()handles async natively. Writingawait waitFor(() => ...)will error — it doesn't exist in this setup. - No
queryByText/queryByRole: these don't exist. UsegetByText/getByRolewithexpect.element(...).not.toBeVisible()for absence checks. userEventis not a fixture — import it directly:import {userEvent} from 'vitest/browser';. UseuserEvent.click(),userEvent.fill(),userEvent.type(), etc. for all interactions. Do not use locator.click()or.fill()methods — they bypass the realistic event sequence thatuserEventprovides.- Endpoint mocks are functions: always call them with a config object —
mockCurrentUserEndpoint({successResponse: HttpResponse.json({})}), notmockCurrentUserEndpointbare. - No jsdom APIs: tests run in a real browser, so
document.querySelectortechnically works but defeats the purpose. Usescreen.getBy*queries. msw/browser, notmsw/node: the MSW worker runs in the browser viasetupWorker. If you see imports frommsw/node, that's wrong.- No
vi.mock()for HTTP: it silently breaks in browser mode and is the wrong abstraction anyway. Use MSW. render()returnsscreen: unlike Testing Library wherescreenis a global import, hererender()returns the screen object. Useconst screen = await render(<Comp />). Same forrenderWithRouter()—const screen = await renderWithRouter(MyPage, {path: '/my-path'}).
Commands
Run from webapp/client/apps/orchestration-cluster-webapp/:
npm run test:unit # Headless Chromium — CI and local
npm run test:unit:ui # Visible browser — useful for debugging
Format changed files via npm run prettier:format from webapp/client/ and typecheck via npm run typecheck from the app directory — never invoke Prettier or tsc directly.
Template references
src/shared/pages/LoginPage.test.tsx— page-level test usingrenderWithRouter.src/shared/mock-test.test.tsx— component test with MSW mocking.src/vitest-modules/test-extend.ts— customitfixture source.src/vitest-modules/render-with-router.tsx—renderWithRouterutility source.shared-test-modules/mock-endpoint.ts—createEndpointMockfactory source.shared-test-modules/mock-handlers.ts— shared endpoint mock definitions.docs/monorepo-docs/frontend/testing.md— full testing guide.