Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions .agents/skills/component-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -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(<Button onClick={onClick}>Submit</Button>)

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(<Button>Submit</Button>)
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.
Loading