|
| 1 | +--- |
| 2 | +globs: *.test.tsx |
| 3 | +alwaysApply: false |
| 4 | +--- |
| 5 | + |
| 6 | +# Testing Rules for UI Kit |
| 7 | + |
| 8 | +## Setup |
| 9 | + |
| 10 | +- Place test files next to components with `.test.tsx` extension |
| 11 | +- Mock internal warnings: `jest.mock('../../../_internal/hooks/use-warn')` |
| 12 | +- Define test data at the top of `describe` blocks |
| 13 | + |
| 14 | +## Render Functions |
| 15 | + |
| 16 | +**Choose based on component requirements:** |
| 17 | +- `render()` - Simple components (Button, Text, standalone UI elements) |
| 18 | +- `renderWithRoot()` - Components using overlays, popovers, portals, modals, or complex interactions (Select, ComboBox, Menu, Dialog, etc.) |
| 19 | +- `renderWithForm()` - Form-integrated components (returns `{ formInstance, ...renderResult }`) |
| 20 | + |
| 21 | +Note: Root provides ModalProvider, PortalProvider, EventBusProvider, NotificationsProvider, and styled-components context. Most interactive components need it, but simple presentational components don't. |
| 22 | + |
| 23 | +## User Interactions |
| 24 | + |
| 25 | +**Always use `userEvent` for interactions:** |
| 26 | +- `await userEvent.click(element)` |
| 27 | +- `await userEvent.type(input, 'text')` |
| 28 | +- `await userEvent.clear(input)` |
| 29 | +- `await userEvent.keyboard('{Enter}')` / `'{Escape}'` / `'{ArrowDown}'` / `'{Home}'` / etc. |
| 30 | +- `await userEvent.tab()` |
| 31 | + |
| 32 | +**Focus management:** |
| 33 | +```tsx |
| 34 | +await act(async () => { |
| 35 | + element.focus(); |
| 36 | + element.blur(); |
| 37 | +}); |
| 38 | +``` |
| 39 | + |
| 40 | +## Async Testing |
| 41 | + |
| 42 | +Use `waitFor()` for async state changes: |
| 43 | +```tsx |
| 44 | +await userEvent.click(button); |
| 45 | + |
| 46 | +await waitFor(() => { |
| 47 | + expect(element).toBeInTheDocument(); |
| 48 | +}); |
| 49 | +``` |
| 50 | + |
| 51 | +Wait for removal: `await waitForElementToBeRemoved(() => queryByRole('dialog'))` |
| 52 | + |
| 53 | +## Common Patterns |
| 54 | + |
| 55 | +### Basic Rendering & User Interactions |
| 56 | +```tsx |
| 57 | +it('should handle button press', async () => { |
| 58 | + const onPress = jest.fn(); |
| 59 | + const { getByRole } = render(<Button onPress={onPress}>Label</Button>); |
| 60 | + |
| 61 | + await userEvent.click(getByRole('button')); |
| 62 | + |
| 63 | + expect(onPress).toHaveBeenCalled(); |
| 64 | +}); |
| 65 | +``` |
| 66 | + |
| 67 | +### Popover/Overlay State |
| 68 | +```tsx |
| 69 | +it('should open and close popover', async () => { |
| 70 | + const { getByRole, queryByRole } = renderWithRoot(<Select label="test">...</Select>); |
| 71 | + |
| 72 | + expect(queryByRole('listbox')).not.toBeInTheDocument(); |
| 73 | + |
| 74 | + await userEvent.click(getByRole('button')); |
| 75 | + await waitFor(() => expect(queryByRole('listbox')).toBeInTheDocument()); |
| 76 | + |
| 77 | + await userEvent.keyboard('{Escape}'); |
| 78 | + await waitFor(() => expect(queryByRole('listbox')).not.toBeInTheDocument()); |
| 79 | +}); |
| 80 | +``` |
| 81 | + |
| 82 | +### Form Integration |
| 83 | +```tsx |
| 84 | +// Modern Form integration |
| 85 | +it('should integrate with Form', async () => { |
| 86 | + const { getByRole, formInstance } = renderWithForm( |
| 87 | + <TextInput name="test" label="test" /> |
| 88 | + ); |
| 89 | + |
| 90 | + await userEvent.type(getByRole('textbox'), 'Hello'); |
| 91 | + expect(formInstance.getFieldValue('test')).toBe('Hello'); |
| 92 | +}); |
| 93 | + |
| 94 | +// Legacy Field wrapper |
| 95 | +it('should interop with legacy <Field />', async () => { |
| 96 | + const { getByRole, formInstance } = renderWithForm( |
| 97 | + <Field name="test"><Switch aria-label="test" /></Field> |
| 98 | + ); |
| 99 | + |
| 100 | + await userEvent.click(getByRole('switch')); |
| 101 | + expect(formInstance.getFieldValue('test')).toBe(true); |
| 102 | +}); |
| 103 | +``` |
| 104 | + |
| 105 | +### Props & State Changes (Rerender) |
| 106 | +```tsx |
| 107 | +it('should respect state props', async () => { |
| 108 | + const { getByRole, rerender } = renderWithRoot(<Component isDisabled />); |
| 109 | + |
| 110 | + expect(getByRole('button')).toBeDisabled(); |
| 111 | + |
| 112 | + rerender(<Component isReadOnly />); |
| 113 | + expect(getByRole('button')).toHaveAttribute('readonly'); |
| 114 | +}); |
| 115 | +``` |
| 116 | + |
| 117 | +### Keyboard Navigation |
| 118 | +```tsx |
| 119 | +it('should handle keyboard navigation', async () => { |
| 120 | + const { getByRole } = renderWithRoot(<ComboBox label="test">...</ComboBox>); |
| 121 | + const input = getByRole('combobox'); |
| 122 | + |
| 123 | + await act(async () => { |
| 124 | + input.focus(); |
| 125 | + await userEvent.keyboard('{ArrowDown}'); |
| 126 | + }); |
| 127 | + |
| 128 | + await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'true')); |
| 129 | +}); |
| 130 | +``` |
| 131 | + |
| 132 | +### Filtering/Search |
| 133 | +```tsx |
| 134 | +it('should filter options', async () => { |
| 135 | + const { getByRole, getAllByRole } = renderWithRoot(<ComboBox label="test">{items}</ComboBox>); |
| 136 | + |
| 137 | + await userEvent.type(getByRole('combobox'), 'red'); |
| 138 | + |
| 139 | + await waitFor(() => { |
| 140 | + expect(getAllByRole('option')).toHaveLength(2); |
| 141 | + }); |
| 142 | +}); |
| 143 | +``` |
| 144 | + |
| 145 | +### Validation |
| 146 | +```tsx |
| 147 | +it('should display validation errors', async () => { |
| 148 | + const { getByRole, getByText } = renderWithForm( |
| 149 | + <TextInput name="test" label="test" rules={[{ required: true, message: 'Required' }]} /> |
| 150 | + ); |
| 151 | + |
| 152 | + await userEvent.type(getByRole('textbox'), 'a'); |
| 153 | + await userEvent.clear(getByRole('textbox')); |
| 154 | + await userEvent.tab(); |
| 155 | + |
| 156 | + await waitFor(() => expect(getByText('Required')).toBeInTheDocument()); |
| 157 | +}); |
| 158 | +``` |
| 159 | + |
| 160 | +### Parameterized Tests |
| 161 | +```tsx |
| 162 | +it.each([ |
| 163 | + ['aria-label', { 'aria-label': 'test' }], |
| 164 | + ['label', { label: 'test' }], |
| 165 | +])('should not warn if %s is provided', (_, props) => { |
| 166 | + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); |
| 167 | + render(<Button {...props} />); |
| 168 | + expect(spy).not.toHaveBeenCalled(); |
| 169 | + spy.mockRestore(); |
| 170 | +}); |
| 171 | +``` |
| 172 | + |
| 173 | +## Query Selectors Priority |
| 174 | + |
| 175 | +1. `getByRole()` - most accessible |
| 176 | +2. `getByLabelText()` - form fields |
| 177 | +3. `getByTestId()` - QA attributes (`qa` prop) |
| 178 | +4. `getByText()` - text content |
| 179 | + |
| 180 | +Use `query*` when checking non-existence, `getAll*` for multiple elements. |
| 181 | + |
| 182 | +## Common Matchers |
| 183 | + |
| 184 | +- `toBeInTheDocument()`, `toHaveTextContent()`, `toHaveAttribute()`, `toHaveValue()` |
| 185 | +- `toBeDisabled()`, `toBeChecked()` |
| 186 | +- `toHaveBeenCalled()`, `toHaveBeenCalledWith()`, `toHaveBeenCalledTimes()` |
| 187 | + |
| 188 | +## Best Practices |
| 189 | + |
| 190 | +### Do |
| 191 | +- ✅ Test user-facing behavior, not implementation |
| 192 | +- ✅ Use semantic queries (`getByRole`) |
| 193 | +- ✅ Wait for async changes with `waitFor()` |
| 194 | +- ✅ Test accessibility (ARIA, keyboard navigation) |
| 195 | +- ✅ Test form integration when supported |
| 196 | +- ✅ Use descriptive test names |
| 197 | + |
| 198 | +### Don't |
| 199 | +- ❌ Don't test implementation details |
| 200 | +- ❌ Don't overuse `act()` (userEvent handles it) |
| 201 | +- ❌ Don't query by class names when semantic queries work |
| 202 | +- ❌ Don't test `styles` or simple layout props (e.g., `icon` that just renders to slot) |
| 203 | +- ❌ Don't forget to mock warnings for clean output |
| 204 | + |
| 205 | +## Debugging |
| 206 | + |
| 207 | +- `screen.debug()` - print DOM |
| 208 | +- `waitFor(() => {...}, { timeout: 5000 })` - custom timeout |
0 commit comments