diff --git a/.agents/skills/component-testing/SKILL.md b/.agents/skills/component-testing/SKILL.md new file mode 100644 index 00000000000..9196a93df86 --- /dev/null +++ b/.agents/skills/component-testing/SKILL.md @@ -0,0 +1,138 @@ +--- +name: component-testing +description: 'Use when authoring, modifying, reviewing, or debugging Primer React component and hook tests in Vitest browser mode. Do not use for Node/SSR tests, standalone Playwright visual or accessibility tests, or component implementation without test changes.' +--- + +# Component Testing + +Use these conventions for Primer React component and hook tests in Vitest browser +mode. Test observable behavior and the component's public contract rather than +its implementation details. + +Read [the testing guide](../../../contributor-docs/testing.md) for strategy, +environment setup, and commands. Keep Node/SSR tests and the separate Playwright +visual and accessibility suites on the APIs available in their own environments. + +## Start with RTL rendering and browser interactions + +Use React Testing Library (RTL) for `render`, `rerender`, `unmount`, `renderHook`, +cleanup, and manual `act` boundaries. Use `page` and `userEvent` from +`vitest/browser` for browser queries and interactions. + +```tsx +import {render} from '@testing-library/react' +import {expect, it, vi} from 'vitest' +import {page, userEvent} from 'vitest/browser' +import {Button} from '@primer/react' + +it('calls onClick', async () => { + const onClick = vi.fn() + render() + + const button = page.getByRole('button', {name: 'Submit', exact: true}) + await expect.element(button).toBeVisible() + await userEvent.click(button) + expect(onClick).toHaveBeenCalledTimes(1) +}) +``` + +Await browser interactions directly, without wrapping them in `act`. + +## Choose assertions based on when the result should be available + +| Contract | Assertion | +| :---------------------------------------- | :------------------------------------------------------ | +| Eventual DOM state | `await expect.element(locator).toHaveAttribute(...)` | +| Eventual computed value | `await expect.poll(() => readCurrentValue()).toBe(...)` | +| Immediate result or synchronous guarantee | `expect(value).toBe(...)` | + +Prefer assertions on observable conditions over fixed sleeps. This lets the test +continue as soon as the expected state is available. + +```tsx +// Avoid +await new Promise(resolve => setTimeout(resolve, 100)) +expect(document.getElementById(announcementId)?.textContent).toBe('Loading') + +// Prefer +await expect.poll(() => document.getElementById(announcementId)?.textContent).toBe('Loading') +``` + +Read the current value inside the polling callback so each attempt checks the +latest state. + +Use ordinary `expect` when the result must be available immediately, such as +after synchronous focus redirection, event cancellation, or explicit timer +advancement. Retrying these assertions can hide a regression. + +When testing timed behavior, keep fake timers and advance them explicitly inside +RTL's `act`. Assert the state at the required boundaries, including before the +deadline when relevant, so the test verifies when the change occurs rather than +only that it eventually occurs. + +## Use RTL's act for manual React updates + +Import `act` from `@testing-library/react`, not `react`. Use it for manual updates +that can trigger React work, including programmatic focus, timer advancement, +hook updates, and hydration. + +```tsx +import {act} from '@testing-library/react' + +const button = page.getByRole('button', {name: 'Submit', exact: true}) +act(() => { + button.element().focus() +}) +expect(button.element()).toHaveFocus() +``` + +RTL scopes the React act environment to the operation. When an act warning +occurs, check that manual updates use RTL's `act` and browser interactions are +awaited directly. Keep that scoped behavior rather than changing +`IS_REACT_ACT_ENVIRONMENT` globally or suppressing the warning. + +## Use locators for current DOM state + +Prefer role and accessible-name queries. A locator resolves again each time it +is used, so it must stay unambiguous after interactions. For example, expanding a +tree can make a partial name match both a parent and its descendants. Use an +exact accessible name or scope the query to the intended region. + +Prefer an exact accessible name or a scoped query over `.first()`. This keeps the +test tied to the intended element when the DOM changes. Use positional selection +when position is part of the behavior being tested. + +Use `.element()` when the assertion needs a DOM node, such as for computed +styles, structural relationships, existing snapshots, or node identity. Capture +the node before removal when checking that a particular node was detached: + +```tsx +const {rerender} = render() +const originalButton = page.getByRole('button', {name: 'Submit', exact: true}).element() + +rerender(<>) +expect(originalButton).not.toBeInTheDocument() +``` + +## Assert presence and visibility separately + +DOM presence and visibility describe different behavior. Assert removal when a node +must be removed, and visibility when it may remain mounted but hidden. +Role queries normally exclude inaccessible elements, so a missing role match +alone doesn't prove that a hidden node was removed. + +For accessibility relationships, check the actual ID reference and the referenced +node's content. A generic text match can select the wrong node, such as a +spinner's "Loading" text instead of the live-region announcement. Precise DOM +access is appropriate when testing that relationship. + +## Keep intentional low-level event tests + +Use browser interactions for user behavior, but keep synthetic events when +testing disabled-click suppression, event cancellation, or a specific event +payload. Browser actions wait for actionable elements and don't expose the +boolean result of `dispatchEvent`. + +If a browser action unexpectedly fails because an element cannot be interacted +with, investigate its visibility, enabled state, and other interaction +requirements before choosing a synthetic event.