Test runner: Vitest (globals enabled — vi, describe, it, expect etc. are available without imports).
Before writing a spec, check you actually want one. A spec is a permanent artefact someone maintains and re-runs. If the question is "what does this render / what CSS does this produce / what does this token resolve to", that is a look, not a test — run
pnpm probeand read the answer. Writing a spec to run once and delete is the habit the probe exists to replace.
- Place test files next to components with
.test.tsxextension - Mock internal warnings:
vi.mock('../../../_internal/hooks/use-warn') - Define test data at the top of
describeblocks
Choose based on component requirements:
render()- Simple components (Button, Text, standalone UI elements)renderWithRoot()- Components using overlays, popovers, portals, modals, or complex interactions (Select, ComboBox, Menu, Dialog, etc.)renderWithForm()- Form-integrated components (returns{ formInstance, ...renderResult })
Note: Root provides Tasty batching, i18n, modal, portal, event-bus, notification and alert-dialog providers. Most interactive components need it, but simple presentational components don't.
Always use userEvent for interactions:
await userEvent.click(element)await userEvent.type(input, 'text')await userEvent.clear(input)await userEvent.keyboard('{Enter}')/'{Escape}'/'{ArrowDown}'/'{Home}'/ etc.await userEvent.tab()
Focus management:
await act(async () => {
element.focus();
element.blur();
});Use waitFor() for async state changes:
await userEvent.click(button);
await waitFor(() => {
expect(element).toBeInTheDocument();
});Wait for removal: await waitForElementToBeRemoved(() => queryByRole('dialog'))
it('should handle button press', async () => {
const onPress = vi.fn();
const { getByRole } = render(<Button onPress={onPress}>Label</Button>);
await userEvent.click(getByRole('button'));
expect(onPress).toHaveBeenCalled();
});it('should open and close popover', async () => {
const { getByRole, queryByRole } = renderWithRoot(<Select label="test">...</Select>);
expect(queryByRole('listbox')).not.toBeInTheDocument();
await userEvent.click(getByRole('button'));
await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument());
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(queryByRole('listbox')).not.toBeInTheDocument());
});While a modal, fullscreen or tray Dialog is open, the page behind it is aria-hidden, so role queries do not find it. { hidden: true } finds those elements, but an element hidden itself has no accessible name, so query the page behind an open modal by text or qa. Lists, popovers and tooltips opened from the dialog stay visible to role queries.
// Legacy name binding (renderWithForm uses Form.useForm)
it('should integrate with Form', async () => {
const { getByRole, formInstance } = renderWithForm(
<TextInput name="test" label="test" />
);
await userEvent.type(getByRole('textbox'), 'Hello');
expect(formInstance.getFieldValue('test')).toBe('Hello');
});
// Legacy Field wrapper
it('should interop with legacy <Field />', async () => {
const { getByRole, formInstance } = renderWithForm(
<Field name="test"><Switch aria-label="test" /></Field>
);
await userEvent.click(getByRole('switch'));
expect(formInstance.getFieldValue('test')).toBe(true);
});it('should respect state props', async () => {
const { getByRole, rerender } = renderWithRoot(<Component isDisabled />);
expect(getByRole('button')).toBeDisabled();
rerender(<Component isReadOnly />);
expect(getByRole('button')).toHaveAttribute('readonly');
});it('should handle keyboard navigation', async () => {
const { getByRole } = renderWithRoot(<ComboBox label="test">...</ComboBox>);
const input = getByRole('combobox');
await act(async () => {
input.focus();
await userEvent.keyboard('{ArrowDown}');
});
await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'true'));
});it('should filter options', async () => {
const { getByRole, getAllByRole } = renderWithRoot(<ComboBox label="test">{items}</ComboBox>);
await userEvent.type(getByRole('combobox'), 'red');
await waitFor(() => {
expect(getAllByRole('option')).toHaveLength(2);
});
});it('should display validation errors', async () => {
const { getByRole, getByText } = renderWithForm(
<TextInput name="test" label="test" rules={[{ required: true, message: 'Required' }]} />
);
await userEvent.type(getByRole('textbox'), 'a');
await userEvent.clear(getByRole('textbox'));
await userEvent.tab();
await waitFor(() => expect(getByText('Required')).toBeInTheDocument());
});it.each([
['aria-label', { 'aria-label': 'test' }],
['label', { label: 'test' }],
])('should not warn if %s is provided', (_, props) => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(<Button {...props} />);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});When what the user hears is the behavior under test — an announcement, a description, reading order, focus moving into a dialog — assert it with the virtual screen reader. Import from src/test/screen-reader.ts by path, and never start the package's virtual singleton yourself:
import { startScreenReader } from '../../../test/screen-reader';
const reader = await startScreenReader();
await reader.press('Tab');
await reader.press('Enter');
await waitFor(async () =>
expect(await reader.lastSpokenPhrase()).toBe('dialog, Rename file'),
);readWithScreenReader() returns a top-to-bottom reading instead. Recipes and the simulator's blind spots: screen-reader.md.
getByRole()- most accessiblegetByLabelText()- form fieldsgetByTestId()- QA attributes (qaprop)getByText()- text content
Use query* when checking non-existence, getAll* for multiple elements.
toBeInTheDocument(),toHaveTextContent(),toHaveAttribute(),toHaveValue()toBeDisabled(),toBeChecked()toHaveBeenCalled(),toHaveBeenCalledWith(),toHaveBeenCalledTimes()
- ✅ Test user-facing behavior, not implementation
- ✅ Use semantic queries (
getByRole) - ✅ Wait for async changes with
waitFor() - ✅ Test accessibility (ARIA, keyboard navigation)
- ✅ Test form integration when supported
- ✅ Use descriptive test names
- ❌ Don't test implementation details
- ❌ Don't overuse
act()(userEvent handles it) - ❌ Don't query by class names when semantic queries work
- ❌ Don't add specs just to inspect
stylesor simple layout props; use the probe. A layout or consumer-customization regression does need a behavior check, using a browser only when jsdom cannot observe the failure. - ❌ Don't forget to mock warnings for clean output
screen.debug()- print DOMwaitFor(() => {...}, { timeout: 5000 })- custom timeout
Follow the React Compiler rules. Run affected behavior in source and release-compiled modes. Preserve Strict Mode, hydration, observer/ref cleanup, accessibility, and frozen Form contracts. Callback migrations check current committed data, resource configuration and cleanup, and retained identity only where that is a real contract. Built/packed consumer fixtures cover public declarations and native runtime imports. Whole-library Hooks diagnostics and Compiler coverage must not regress; a baseline update is not a fix for a failing gate.
For trusted CDP pointer input in browser specs, translate iframe coordinates using its bounding rectangle and offsetWidth/offsetHeight scale, including scaled border offsets. Vitest can scale its test iframe; adding only its left/top offsets can send whitespace clicks outside the rendered row.