Skip to content

Latest commit

 

History

History
247 lines (174 loc) · 8.31 KB

tests.md

File metadata and controls

247 lines (174 loc) · 8.31 KB

Testing Rules for UI Kit

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 probe and read the answer. Writing a spec to run once and delete is the habit the probe exists to replace.

Setup

  • Place test files next to components with .test.tsx extension
  • Mock internal warnings: vi.mock('../../../_internal/hooks/use-warn')
  • Define test data at the top of describe blocks

Render Functions

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.

User Interactions

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();
});

Async Testing

Use waitFor() for async state changes:

await userEvent.click(button);

await waitFor(() => {
  expect(element).toBeInTheDocument();
});

Wait for removal: await waitForElementToBeRemoved(() => queryByRole('dialog'))

Common Patterns

Basic Rendering & User Interactions

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();
});

Popover/Overlay State

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.

Form Integration

// 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);
});

Props & State Changes (Rerender)

it('should respect state props', async () => {
  const { getByRole, rerender } = renderWithRoot(<Component isDisabled />);

  expect(getByRole('button')).toBeDisabled();

  rerender(<Component isReadOnly />);
  expect(getByRole('button')).toHaveAttribute('readonly');
});

Keyboard Navigation

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'));
});

Filtering/Search

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);
  });
});

Validation

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());
});

Parameterized Tests

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();
});

Screen Reader

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.

Query Selectors Priority

  1. getByRole() - most accessible
  2. getByLabelText() - form fields
  3. getByTestId() - QA attributes (qa prop)
  4. getByText() - text content

Use query* when checking non-existence, getAll* for multiple elements.

Common Matchers

  • toBeInTheDocument(), toHaveTextContent(), toHaveAttribute(), toHaveValue()
  • toBeDisabled(), toBeChecked()
  • toHaveBeenCalled(), toHaveBeenCalledWith(), toHaveBeenCalledTimes()

Best Practices

Do

  • ✅ 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

  • ❌ 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 styles or 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

Debugging

  • screen.debug() - print DOM
  • waitFor(() => {...}, { timeout: 5000 }) - custom timeout

React 19 and compiled behavior

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.