|
| 1 | +--- |
| 2 | +name: browser-tests |
| 3 | +description: Write and extend Vitest browser tests for design-system components. Use when adding or editing `*.browser.test.tsx`, writing behavioral coverage, or moving assertions out of Storybook. |
| 4 | +--- |
| 5 | + |
| 6 | +# Browser Tests Skill |
| 7 | + |
| 8 | +Behavioral coverage lives next to the component: `ds-{name}/__tests__/ds-{name}.browser.test.tsx`. Stories document UI and controls only. |
| 9 | + |
| 10 | +## Quick start |
| 11 | + |
| 12 | +```tsx |
| 13 | +import { describe, expect, it, vi } from 'vitest'; |
| 14 | +import { page } from 'vitest/browser'; |
| 15 | +import DsWidget from '../ds-widget'; |
| 16 | + |
| 17 | +describe('DsWidget', () => { |
| 18 | + it('calls onSave when submitted', async () => { |
| 19 | + const onSave = vi.fn(); |
| 20 | + await page.render(<DsWidget onSave={onSave} />); |
| 21 | + |
| 22 | + await page.getByRole('button', { name: /save/i }).click(); |
| 23 | + expect(onSave).toHaveBeenCalledOnce(); |
| 24 | + }); |
| 25 | +}); |
| 26 | +``` |
| 27 | + |
| 28 | +## Principles |
| 29 | + |
| 30 | +1. **Stories document UI**; assert behavior in `*.browser.test.tsx`. |
| 31 | +2. **Spy callbacks with `vi.fn()`** in tests (not Storybook `fn()`). |
| 32 | +3. **Prefer a11y queries**: `page.getByRole`, `getByLabelText`, `getByText` — avoid `getByTestId` unless unavoidable. |
| 33 | +4. **Use Vitest browser matchers**: `await expect.element(locator).toBeChecked()`, `toBeDisabled()`, `toBeVisible()`, etc. |
| 34 | +5. **Await async UI**: `await locator.click()`, `await expect.element(...)`. |
| 35 | +6. **Test user-visible behavior**, not implementation details; use `data-*` only when it is an explicit contract (e.g. indeterminate state). |
| 36 | +7. **Disabled / blocked interaction**: `await locator.click({ force: true })` when asserting a disabled control does not change state. |
| 37 | +8. **Controlled components**: inline wrapper with `useState` inside the test. |
| 38 | +9. **Prop changes**: `const { rerender } = await page.render(...)` then `await rerender(<Comp ... />)`. |
| 39 | +10. **Hover**: `await locator.hover()` / `await locator.unhover()` for tooltip-style UI. |
| 40 | +11. **Nested locators**: scope within parent — `parentLocator.getByText(...)`. |
| 41 | +12. **`toBeInTheDocument` vs `toBeVisible`**: `not.toBeInTheDocument()` for removed DOM; `not.toBeVisible()` for hidden. |
| 42 | +13. **Disabled queries**: `page.getByRole('checkbox', { disabled: true })`. |
| 43 | + |
| 44 | +## Common patterns |
| 45 | + |
| 46 | +### Callback spy |
| 47 | + |
| 48 | +```tsx |
| 49 | +const onCheckedChange = vi.fn(); |
| 50 | +await page.render(<DsCheckbox onCheckedChange={onCheckedChange} />); |
| 51 | +await page.getByRole('checkbox').click(); |
| 52 | +expect(onCheckedChange).toHaveBeenCalledWith(true); |
| 53 | +``` |
| 54 | + |
| 55 | +### Controlled state |
| 56 | + |
| 57 | +Inline wrapper with `useState` — same pattern as [react-patterns](../react-patterns/SKILL.md) controlled stories. |
| 58 | + |
| 59 | +### Disabled — no state change |
| 60 | + |
| 61 | +```tsx |
| 62 | +const checkbox = page.getByRole('checkbox', { disabled: true }); |
| 63 | +await expect.element(checkbox).toBeDisabled(); |
| 64 | +await checkbox.click({ force: true }); |
| 65 | +await expect.element(checkbox).not.toBeChecked(); |
| 66 | +``` |
| 67 | + |
| 68 | +### Rerender |
| 69 | + |
| 70 | +```tsx |
| 71 | +const { rerender } = await page.render(<DsTooltip content={undefined}>...</DsTooltip>); |
| 72 | +await rerender(<DsTooltip content="Now visible">...</DsTooltip>); |
| 73 | +``` |
| 74 | + |
| 75 | +### Ark UI parts (last resort) |
| 76 | + |
| 77 | +Use when a11y queries cannot express the contract (e.g. `data-state="indeterminate"`, row checkbox label hit target in tables): |
| 78 | + |
| 79 | +```tsx |
| 80 | +const CHECKBOX_ROOT = '[data-scope="checkbox"][data-part="root"]'; |
| 81 | + |
| 82 | +const checkboxRoot = (idx = 0) => |
| 83 | + page.elementLocator(document.querySelectorAll<HTMLElement>(CHECKBOX_ROOT)[idx] as HTMLElement); |
| 84 | + |
| 85 | +await expect.element(checkboxRoot()).toHaveAttribute('data-state', 'indeterminate'); |
| 86 | +await page.elementLocator(selectAllRoot).click(); |
| 87 | +``` |
| 88 | + |
| 89 | +- Prefer `getByRole` / `getByLabelText` / `getByText` first. |
| 90 | +- Use `data-scope` + `data-part` from Ark styling guide — not arbitrary selectors. |
| 91 | +- Hoist repeated selectors to a file-level helper; one-line comment if non-obvious. |
| 92 | +- `page.elementLocator(domNode)` wraps raw DOM when Vitest needs a locator API. |
| 93 | + |
| 94 | +## Anti-patterns |
| 95 | + |
| 96 | +Reject or rewrite tests that only prove something mounted: |
| 97 | + |
| 98 | +| Smell | Fix | |
| 99 | +| ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | |
| 100 | +| `expect(x).toBeTruthy()` with no interaction | Action + asserted outcome | |
| 101 | +| `toBeInTheDocument()` / `toBeVisible()` alone for a scenario named "toggles", "calls onX", "disabled" | Assert checked/disabled state, spy calls, text change, or `not.toBeInTheDocument()` after unmount | |
| 102 | +| Duplicate "renders" `it` blocks | Merge or delete — one smoke test max per component | |
| 103 | +| `getByTestId` when role/label works | Use a11y queries | |
| 104 | + |
| 105 | +**Good:** click → `toBeChecked()` + `vi.fn()` called with expected arg. |
| 106 | + |
| 107 | +**Bad:** render → `toBeVisible()` only (says nothing about the scenario name). |
| 108 | + |
| 109 | +## Verify |
| 110 | + |
| 111 | +```bash |
| 112 | +pnpm --filter @drivenets/design-system test packages/design-system/src/components/ds-{name}/__tests__/ds-{name}.browser.test.tsx --run |
| 113 | +pnpm eslint packages/design-system/src/components/ds-{name}/ |
| 114 | +``` |
| 115 | + |
| 116 | +## Related |
| 117 | + |
| 118 | +- **Migrate from Storybook play**: [migrate-story-tests](../migrate-story-tests/SKILL.md) |
| 119 | +- **New component**: [component-scaffold](../component-scaffold/SKILL.md) |
| 120 | +- **React state in tests**: [react-patterns](../react-patterns/SKILL.md) |
| 121 | +- **Examples**: `packages/design-system/src/components/*/__tests__/*.browser.test.tsx` |
0 commit comments