frontend-unit-test

star 4.2k

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/.

camunda By camunda schedule Updated 6/12/2026

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 it from #/vitest-modules/test-extend, not from vitest. The custom fixture auto-starts an MSW service worker before each test and resets/stops it after. Importing from vitest directly means no MSW interception — HTTP calls will fail or hit real endpoints.
  • Use render() from vitest-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 import screen from @testing-library/react — it does not exist in this setup.
  • For components that need routing context (pages, components using <Link>, useNavigate, route hooks), use renderWithRouter(Component, {path, initialEntry?}) from #/vitest-modules/render-with-router instead of bare render(). It mounts the component in a minimal isolated route tree backed by an in-memory history — no full routeTree.gen is loaded, no beforeLoad runs.
  • Both render() and renderWithRouter() return the screen object — you MUST capture the return value: const screen = await renderWithRouter(MyPage, {path: '/my-path'}). There is no global screen import.
  • Use expect.element() for DOM assertions — it retries automatically until the assertion passes or times out. This replaces the waitFor / findBy* / screen.findByRole patterns you may know from Testing Library. There is no waitFor here.
  • Perform all UI interactions (clicks, typing, filling, selecting, tabbing, hovering) with userEvent imported from vitest/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 using screen.getBy* for queries and expect.element() for assertions — userEvent is only for interactions. Do NOT use locator .click() or .fill() methods directly.
  • Mock HTTP through the worker fixture using endpoint mocks from #/shared-test-modules/mock-handlers. Each mock is an individually named export (e.g., mockCurrentUserEndpoint, mockLoginEndpoint) created with createEndpointMock from #/shared-test-modules/mock-endpoint. Both unit and Playwright tests use the same definitions. All endpoint mocks must be defined in apps/orchestration-cluster-webapp/shared-test-modules/mock-handlers.ts — never create createEndpointMock calls inline in test files. Never use vi.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. Avoid querySelector and getByTestId — they test DOM structure, not behavior.
  • Co-locate test files with source: a test for src/shared/foo/bar.tsx sits at src/shared/foo/bar.test.tsx; a test for src/operate/components/Foo.tsx sits next to it at src/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-router when 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; use useParams({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 with vi.useFakeTimers.
  • Do not use // given / when / then comments — 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, and failureResponse. 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 waitFor or findBy*: expect.element() handles async natively. Writing await waitFor(() => ...) will error — it doesn't exist in this setup.
  • No queryByText / queryByRole: these don't exist. Use getByText / getByRole with expect.element(...).not.toBeVisible() for absence checks.
  • userEvent is not a fixture — import it directly: import {userEvent} from 'vitest/browser';. Use userEvent.click(), userEvent.fill(), userEvent.type(), etc. for all interactions. Do not use locator .click() or .fill() methods — they bypass the realistic event sequence that userEvent provides.
  • Endpoint mocks are functions: always call them with a config object — mockCurrentUserEndpoint({successResponse: HttpResponse.json({})}), not mockCurrentUserEndpoint bare.
  • No jsdom APIs: tests run in a real browser, so document.querySelector technically works but defeats the purpose. Use screen.getBy* queries.
  • msw/browser, not msw/node: the MSW worker runs in the browser via setupWorker. If you see imports from msw/node, that's wrong.
  • No vi.mock() for HTTP: it silently breaks in browser mode and is the wrong abstraction anyway. Use MSW.
  • render() returns screen: unlike Testing Library where screen is a global import, here render() returns the screen object. Use const screen = await render(<Comp />). Same for renderWithRouter()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 using renderWithRouter.
  • src/shared/mock-test.test.tsx — component test with MSW mocking.
  • src/vitest-modules/test-extend.ts — custom it fixture source.
  • src/vitest-modules/render-with-router.tsxrenderWithRouter utility source.
  • shared-test-modules/mock-endpoint.tscreateEndpointMock factory source.
  • shared-test-modules/mock-handlers.ts — shared endpoint mock definitions.
  • docs/monorepo-docs/frontend/testing.md — full testing guide.
Install via CLI
npx skills add https://github.com/camunda/camunda --skill frontend-unit-test
Repository Details
star Stars 4,170
call_split Forks 791
navigation Branch main
article Path SKILL.md
More from Creator