Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when writing, modifying, or debugging unit tests (*.test.tsx) in the orchestration cluster webapp (webapp/client/apps/orchestration-cluster-webapp/src/). Covers Vitest browser mode, MSW mocking, and vitest-browser-react rendering.
.claude/skills/camunda-frontend-unit-test/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 59% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 83% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 50% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 66% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 133% | 0% |
Unit tests in @camunda/orchestration-cluster-webapp run in real Chromium with Vitest Browser Mode. They do not run in jsdom or happy-dom.
Use the browser DOM, browser events, and browser APIs. Use MSW to intercept HTTP requests through a service worker.
it from #/vitest-modules/test-extend.describe, expect, and vi, from vitest.userEvent from vitest/browser.render from vitest-browser-react.screen from @testing-library/react.@testing-library/react.vi.mock() for HTTP requests.The custom it fixture starts MSW before each test. It also resets and stops MSW after each test. Import this it even when the test does not use the worker argument.
Use render() for components that do not need a router.
tsxconst screen = await render(<Component />);
Use renderWithRouter() for pages and components that use links, navigation, route parameters, or route search parameters.
tsxconst screen = await renderWithRouter(Page, { path: '/users/$id', initialEntry: '/users/42', });
Import it from:
tsximport {renderWithRouter} from '#/vitest-modules/render-with-router';
renderWithRouter() creates an isolated TanStack Router with memory history and a fresh QueryClient. It does not load the full route tree or run route beforeLoad functions.
Do not mock the router.
Typed file-route hooks, such as Route.useParams(), do not resolve in this isolated router. Use:
tsxuseParams({from: '/users/$id'});
Both render functions return screen. Always capture it.
screen.getBy*() returns a lazy, retryable Locator. It does not return a DOM element.
Use selectors in this order:
| Need | Selector | |---|---| | Interactive element or semantic element | getByRole | | Form control | getByLabelText | | Independent static text | getByText | | Existing title attribute | getByTitle | | No suitable user-facing selector | getByTestId |
String locators use exact matching in this application. Match the complete, case-sensitive text, accessible name, label, or title.
A regular expression controls its own matching behavior.
The name option of getByRole matches the accessible name. The accessible name can include nested controls, icons, tooltips, badges, or hidden accessible text.
Do not change production semantics only to make a test pass. Do not add an ARIA role, aria-label, title, hidden text, wrapper, or test ID only for test discovery. Add ARIA only when it accurately describes the interface.
Keep locators unresolved. This preserves strict matching, retries, and useful diagnostics.
Chain a selector from its parent:
tsxconst betaProcessLink = screen.getByTitle('Beta Process – 3 Instances in 1 Version'); const alphaProcessLink = screen.getByTitle('Alpha Process – 6 Instances in 1 Version'); await expect.element(betaProcessLink).toBeVisible(); await expect.element(alphaProcessLink).toBeVisible(); await expect.element(betaProcessLink.getByTestId('draining-indicator')).toBeVisible(); await expect.element(alphaProcessLink.getByTestId('draining-indicator')).not.toBeInTheDocument();
Assert that a parent is visible before you assert that a child is absent. This prevents a false pass when the parent does not render.
Use .filter() to narrow a collection:
tsxconst invoiceRow = screen .getByRole('row') .filter({has: screen.getByRole('link', {name: 'Invoice Process'})}); await expect.element(invoiceRow.getByRole('button', {name: 'Delete'})).toBeEnabled();
Available filters are:
hashasNothasTexthasNotTextUse semantic filters before you use .first(), .last(), or .nth().
Do not unwrap locators for normal assertions:
tsx// Wrong const row = screen.getByText('Invoice Process').element().closest('a') as HTMLElement; const indicator = row.querySelector('[data-testid="draining-indicator"]') as HTMLElement; // Correct const processLink = screen.getByTitle('Invoice Process – 3 Instances in 1 Version'); await expect.element(processLink.getByTestId('draining-indicator')).toBeVisible();
Use element(), query(), elements(), or findElement() only when an external library or browser API requires a raw DOM element.
Do not use parentElement, closest, querySelector, or a type cast to replace Locator composition.
Use expect.element() for DOM assertions. It resolves the Locator and retries until the assertion passes or times out.
tsx// Visible await expect.element(screen.getByRole('button', {name: 'Submit'})).toBeVisible(); // Complete normalized text await expect.element(screen.getByRole('heading')).toHaveTextContent('Dashboard'); // Partial text await expect .element(screen.getByRole('dialog')) .toMatchTextContent('This operation is part of a batch.'); // Regular-expression text await expect.element(screen.getByRole('tooltip')).toMatchTextContent(/created on/i); // Attribute await expect .element(screen.getByRole('link', {name: 'Documentation'})) .toHaveAttribute('href', '/docs'); // Not present await expect.element(screen.getByText('Loading...')).not.toBeInTheDocument(); // Present but hidden await expect.element(screen.getByRole('dialog')).not.toBeVisible();
Use toHaveTextContent when the complete normalized text is the contract.
Use toMatchTextContent for a substring or regular-expression match.
Use not.toBeInTheDocument() when an element must not exist.
Use not.toBeVisible() when an element must remain mounted but hidden.
Do not use waitFor, findBy*, queryByText, or queryByRole.
Use userEvent for all user interactions.
tsximport {userEvent} from 'vitest/browser'; await userEvent.click(screen.getByRole('button', {name: 'Submit'})); await userEvent.fill(screen.getByLabelText('Name'), 'Alice'); await userEvent.type(screen.getByLabelText('Name'), ' Bob'); await userEvent.clear(screen.getByLabelText('Name')); await userEvent.selectOptions(screen.getByRole('combobox'), ['option-value']); await userEvent.keyboard('{Enter}'); await userEvent.tab(); await userEvent.hover(screen.getByText('Tooltip trigger')); await userEvent.unhover(screen.getByText('Tooltip trigger'));
userEvent accepts Locators. Do not resolve a Locator before an interaction.
tsx// Correct await userEvent.click(screen.getByRole('button', {name: 'Save'})); // Wrong await userEvent.click(screen.getByRole('button', {name: 'Save'}).element());
Do not use Locator .click() or .fill() methods. Use userEvent.
Await every interaction.
Use it.for when a loop would declare multiple tests.
Do not declare it() inside for, for...of, or forEach.
Ordinary loops inside one test remain valid.
The extended it passes case data as the first callback argument. It passes fixture context as the second callback argument.
Use %s in the title.
tsxit.for(['include', 'exclude'] as const)( 'should submit in %s mode', async (mode, {worker}) => { // ... }, );
Use $property in the title.
tsxit.for([ {filter: 'businessId', label: 'Business ID'}, {filter: 'errorMessage', label: 'Error Message'}, ] as const)( 'should display $label', async ({filter, label}, {worker}) => { // ... }, );
it.for passes a tuple as one value. Destructure the tuple in the first callback argument.
tsxit.for([ ['Delete', mockDeleteEndpoint], ['Cancel', mockCancelEndpoint], ] as const)( 'should submit %s', async ([action, endpointMock], {worker}) => { // ... }, );
Build the case table before you call it.for.
tsxconst languages = ['en', 'de'] as const; const actions = ['delete', 'cancel'] as const; const counts = [1, 3] as const; const cases = languages.flatMap((language) => actions.flatMap((action) => counts.map((count) => ({language, action, count})), ), ); it.for(cases)( 'should render the $language $action confirmation for $count instances', async ({language, action, count}) => { // ... }, );
Mock HTTP through the worker fixture.
tsxit('should display the current user', async ({worker}) => { worker.use( mockCurrentUserEndpoint({ successResponse: HttpResponse.json(createCurrentUser()), }), ); const screen = await render(<UserDetails />); await expect.element(screen.getByText('Demo User')).toBeVisible(); });
Import endpoint mocks from:
tsximport {mockCurrentUserEndpoint} from '#/shared-test-modules/mock-handlers';
Define all endpoint mocks in:
textshared-test-modules/mock-handlers.ts
Do not create createEndpointMock calls in test files.
Call every endpoint mock with a configuration object.
tsx// Correct mockCurrentUserEndpoint({ successResponse: HttpResponse.json(createCurrentUser()), }); // Wrong mockCurrentUserEndpoint;
Use these endpoint mock forms:
schema, successResponse, and failureResponse.successResponse.Use msw/browser, not msw/node.
Do not call worker.start(), worker.stop(), or worker.resetHandlers() in a test. The custom fixture owns the worker lifecycle.
Do not use vi.mock() for HTTP. For other dependencies, prefer real implementations. Mock only an external or browser boundary that cannot be used directly.
Co-locate each test with its source file.
Name tests with the should prefix.
tsxit('should display an error for invalid credentials', async () => { // ... });
Do not use // given, // when, or // then comments. Use blank lines to separate setup, action, and assertion code.
Run these commands from webapp/client/apps/orchestration-cluster-webapp/:
bash# Focused test npm run test:unit -- --run src/path/to/example.test.tsx # Type check npm run typecheck # Full unit-test suite npm run test:unit -- --run # Interactive debugging npm run test:unit:ui
Run these commands from webapp/client/:
bashnpm run prettier:format npm run lint
Do not invoke Prettier or tsc directly.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 18,959 | 13,271 | -30% | 1 | 1 | 0% | 3,133 | 4,984 | +59% | 0 | 0 | — |
case-02 | fail→pass | 18,699 | 17,239 | -8% | 1 | 1 | 0% | 3,084 | 5,636 | +83% | 0 | 0 | — |
case-03 | fail→pass | 19,900 | 20,120 | +1% | 1 | 1 | 0% | 3,343 | 5,016 | +50% | 0 | 0 | — |
case-04 | fail→fail | 15,990 | 5,898 | -63% | 1 | 1 | 0% | 1,123 | 3,826 | +241% | 0 | 0 | — |
case-05 | fail→pass | 15,492 | 10,966 | -29% | 1 | 1 | 0% | 2,851 | 4,734 | +66% | 0 | 0 | — |
case-06 | pass→pass | 9,007 | 10,320 | +15% | 1 | 1 | 0% | 1,336 | 4,517 | +238% | 0 | 0 | — |
case-07 | fail→pass | 12,361 | 9,834 | -20% | 1 | 1 | 0% | 1,854 | 4,323 | +133% | 0 | 0 | — |
case-08 | fail→pass | 31,457 | 10,856 | -65% | 1 | 1 | 0% | 3,057 | 4,682 | +53% | 0 | 0 | — |
case-09 | fail→pass | 10,666 | 16,694 | +57% | 1 | 1 | 0% | 1,584 | 4,033 | +155% | 0 | 0 | — |
case-10 | pass→pass | 11,538 | 7,774 | -33% | 1 | 1 | 0% | 1,810 | 4,016 | +122% | 0 | 0 | — |
case-11 | fail→pass | 6,243 | 5,028 | -19% | 1 | 1 | 0% | 912 | 3,433 | +276% | 0 | 0 | — |
case-12 | fail→pass | 23,118 | 7,533 | -67% | 1 | 1 | 0% | 2,703 | 3,981 | +47% | 0 | 0 | — |
case-13 | pass→pass | 8,179 | 6,472 | -21% | 1 | 1 | 0% | 1,359 | 3,778 | +178% | 0 | 0 | — |
case-14 | pass→pass | 14,798 | 11,333 | -23% | 1 | 1 | 0% | 2,250 | 4,515 | +101% | 0 | 0 | — |
case-15 | pass→pass | 6,877 | 7,166 | +4% | 1 | 1 | 0% | 1,098 | 3,805 | +247% | 0 | 0 | — |
case-16 | fail→fail | 9,673 | 3,259 | -66% | 1 | 1 | 0% | 1,423 | 3,079 | +116% | 0 | 0 | — |
case-17 | pass→pass | 12,600 | 4,139 | -67% | 1 | 1 | 0% | 1,109 | 3,108 | +180% | 0 | 0 | — |
case-18 | fail→pass | 6,141 | 3,318 | -46% | 1 | 1 | 0% | 883 | 3,075 | +248% | 0 | 0 | — |
case-19 | pass→pass | 10,382 | 8,197 | -21% | 1 | 1 | 0% | 1,491 | 4,021 | +170% | 0 | 0 | — |
case-20 | pass→pass | 9,710 | 5,975 | -38% | 1 | 1 | 0% | 1,742 | 3,649 | +109% | 0 | 0 | — |
case-21 | pass→pass | 18,414 | 15,652 | -15% | 1 | 1 | 0% | 2,908 | 5,552 | +91% | 0 | 0 | — |
case-22 | fail→pass | 13,069 | 7,877 | -40% | 1 | 1 | 0% | 2,097 | 3,944 | +88% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +50 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
| Model | Method | Date | Lift |
|---|---|---|---|
| gemini-3.6-flash | verified | 9/19/2026 | +70% |
| gemini-3.6-flash | verified | 8/11/2026 | +70% |
Other measured skills in the registry, with their headline benchmark lift.