Alexis Le Dain 04c68e483a
Add React language track with agents, skills, rules, and commands (#2024)
* feat(rules): add rules/react/ track

Five rule files mirroring per-language convention (coding-style,
hooks, patterns, security, testing). Each has `paths:` glob
frontmatter for auto-activation when editing matching files.

- coding-style.md: file extensions, naming, JSX, RSC boundary
- hooks.md: React hooks (NOT Claude Code hooks) — rules-of-hooks,
  dep arrays, cleanup, memoization, React 19 additions
- patterns.md: container/presentational split, state location
  decision tree, Suspense + error boundaries, forms, data fetching
- security.md: dangerouslySetInnerHTML, unsafe URL schemes,
  server-action validation, env-var leaks, CSP
- testing.md: RTL queries, userEvent, async, MSW, axe, anti-patterns

Each file extends typescript/* and common/* rules.

* feat(skills): add react-patterns, react-testing, react-performance

Three new skills under skills/ following the SKILL.md convention.

- react-patterns: React 18/19 idioms — hooks discipline, state
  location decision tree, server/client component boundary,
  Suspense + error boundaries, form actions (React 19), data
  fetching matrix, composition recipes, accessibility-first.
- react-testing: React Testing Library + Vitest/Jest, query
  priority order, userEvent, MSW network mocking, axe a11y
  assertions, RTL vs Playwright CT boundary, TDD workflow.
- react-performance: 70-rule performance ruleset adapted from
  Vercel Labs react-best-practices (MIT) across 8 priority
  categories — waterfalls, bundle size, server-side, client
  fetch, re-render, rendering, JS micro, advanced patterns.
  Includes Lighthouse / Web Vitals mapping and attribution to
  upstream.

Cross-links between the three skills and out to frontend-patterns,
accessibility, e2e-testing, tdd-workflow.

* feat(agents): add react-reviewer and react-build-resolver

Two new agents covering React-specific code review and build error
resolution, plus matching .kiro/ mirrors and a routing pointer
edit on typescript-reviewer.

- react-reviewer: slim React-only lanes (hooks rules,
  dangerouslySetInnerHTML, unsafe URL schemes, key prop, state
  mutation, derived-state-in-effect, server/client component
  boundary, accessibility, render performance, Server Action
  validation, env-var leaks). Explicitly delegates generic
  TypeScript/async/Node concerns to typescript-reviewer. Both
  agents should be invoked together on .tsx/.jsx PRs.
- react-build-resolver: React build/bundler/runtime hydration
  failures across Vite, webpack, Next.js, CRA, Parcel, esbuild,
  Bun, Rsbuild. Handles JSX/TSX compile errors, tsconfig fixes,
  Next.js App Router server/client boundary errors, hydration
  mismatches, duplicated React copies, Tailwind/PostCSS pipeline.
- .kiro/agents/react-reviewer.json + react-build-resolver.json:
  Kiro IDE format mirrors following the per-language precedent.
- typescript-reviewer: routing pointer added to its MEDIUM React
  block — defers to /react-review for React-specific concerns
  while keeping its block as fallback for repos that only invoke
  typescript-reviewer.

All agents carry the standard Prompt Defense Baseline stanza.

* feat(commands): add /react-review /react-build /react-test

Three new slash commands invoking the React agents.

- /react-review: invokes react-reviewer. Documents the routing
  rule with typescript-reviewer — both should run together on
  TSX/JSX PRs. Lists CRITICAL/HIGH/MEDIUM rule categories and
  the automated checks (eslint with react-hooks + jsx-a11y,
  tsc --noEmit, npm audit).
- /react-build: invokes react-build-resolver. Documents bundler
  detection, common failure patterns, fix strategy, and stop
  conditions.
- /react-test: enforces TDD with React Testing Library + Vitest
  or Jest, behavior-focused queries, userEvent + MSW patterns,
  axe accessibility assertions, coverage targets.

Each command file has the required description: frontmatter and
follows the per-language command convention (cpp-test, go-test,
kotlin-test, etc.).

* chore: wire react track into manifests and stack mappings

- agent.yaml: add react-patterns, react-performance, react-testing
  to the skills array; add react-build, react-review, react-test to
  the commands array (alphabetically inserted to satisfy the
  ci/agent-yaml-surface sync test).
- config/project-stack-mappings.json: extend the `react` stack
  entry — add "react" to rules array (was ["common","typescript",
  "web"]); add react-patterns, react-performance, react-testing,
  accessibility to the skills array.
- docs/COMMAND-REGISTRY.json: bump totalCommands 75 -> 78; add
  three new entries (react-build, react-review, react-test) with
  primaryAgents / allAgents / skills wiring. react-review's
  allAgents includes typescript-reviewer to reflect the dual-agent
  routing convention.
- CLAUDE.md: add Skills-table row mapping *.tsx / *.jsx /
  components/** to react-patterns + react-testing skills and
  the /react-review, /react-build, /react-test commands.

* chore(catalog): sync counts to 62 agents / 78 commands / 235 skills

Auto-generated via `node scripts/ci/catalog.js --write --text`
after the react track additions:

- 2 new agents: react-reviewer, react-build-resolver (60 -> 62)
- 3 new commands: react-build, react-review, react-test (75 -> 78)
- 3 new skills: react-patterns, react-performance, react-testing
  (232 -> 235)

Files updated by the catalog sync:
- .claude-plugin/plugin.json description string
- .claude-plugin/marketplace.json plugin description
- README.md quick-start summary, project tree, feature parity tables
- README.zh-CN.md quick-start summary
- AGENTS.md project structure summary
- docs/zh-CN/README.md parity table
- docs/zh-CN/AGENTS.md project structure summary

All counts now match the filesystem catalog (verified by
ci/catalog.test.js).

* feat(kiro): add react agent markdown companions to JSON entries

* feat(kiro): add react skills into manifests

* fix(ci): sync catalog counts, registry, and package files for react track

- .claude-plugin/{plugin,marketplace}.json: bump description counts to 62/235/78
- docs/COMMAND-REGISTRY.json: regenerate to include quality-gate and react commands
- package.json: add skills/react-{patterns,performance,testing}/ to files allowlist so npm-publish-surface aligns with install-modules manifest

* fix(react): address PR #2024 review feedback

Critical:
- Remove undefined/.claude/session-aliases.json containing __proto__ prototype-pollution
  fixture committed by accident in a7333c14

High:
- agents/react-build-resolver.md: replace brittle `test -o $(grep -l ...)` and
  `test -a -n $(grep ...)` detection with explicit `{ ... || grep -q ...; }` so
  bundler detection no longer breaks when grep returns empty
- agents/react-build-resolver.md: drop hardcoded `npm i react@^19 react-dom@^19`
  remediation; replace with version-agnostic pair-upgrade note that honors the
  project's installed major (17/18/19) — surgical fix principle
- commands/react-review.md: guard `tsc --noEmit -p tsconfig.json` with
  `[ -f tsconfig.json ] &&` so the review skips cleanly on JS-only projects

Medium:
- rules/react/security.md: correct the React-18-blocks-javascript-URL claim
  (React only warns in dev; production navigation is not blocked)
- rules/react/security.md: correct CRA env-var exposure row (CRA exposes
  REACT_APP_*, NODE_ENV, PUBLIC_URL — not 'all' variables)
- skills/react-testing/SKILL.md: instantiate QueryClient once outside the
  wrapper closure so React Query cache survives re-renders (flaky-test fix)
- skills/react-testing/SKILL.md: restore console.error spy with mockRestore()
  in a try/finally so the mock does not leak across tests
- commands/react-test.md: switch outer example-session fence to 4 backticks
  so the inner ```tsx/```bash blocks don't prematurely terminate it

* fix(kiro): mirror react-build-resolver react 19 conditional remediation

Discussion r3272907106 flagged the kiro json variant still carrying the hardcoded
'npm i react@^19 react-dom@^19' line that the .md companion already dropped.
Replace with the same conditional, version-agnostic guidance so both variants
stay in sync.

* fix(react): bump react-build example session fence to 4 backticks

Discussion r3272907144 flagged the same nested-fence issue in
commands/react-build.md that we fixed earlier in commands/react-test.md.
The outer triple-backtick text block was being prematurely terminated by
the inner bash/tsx fences inside the Example Session.

* fix(react): bump react-review example usage fence to 4 backticks

Discussion r3272907201 flagged the same nested-fence issue in
commands/react-review.md. The outer triple-backtick text block was
being prematurely terminated by the inner tsx/ts fences inside the
Example Usage transcript.

* fix(docs): clarify commands row as legacy shims in feature parity table

Discussion r3272912003: README comparison table said 'PASS: 78 commands'
while the install-section and quick-start prose use 'legacy command shims'.
Aligned the comparison-table cell to 'PASS: 78 commands (legacy shims)' so
the count word survives the catalog-validator regex while making the legacy
nature explicit.

Widened the catalog comparison-table commands regex to tolerate an optional
parenthetical after the count word, so both the existing 'X commands' and
the new 'X commands (legacy shims)' phrasings validate without breaking
older READMEs/translations.

* Update rules/react/security.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix(react): guard tsc in react-build-resolver diagnostic commands

Discussion r3288910205: the agent prompt instructed an unconditional
'tsc --noEmit -p tsconfig.json', which adds noise (or hard-fails) on
JavaScript-only projects with no tsconfig.json or no installed TypeScript.

Replaced with 'test -f tsconfig.json && npx --yes tsc --noEmit -p tsconfig.json'
in both variants:
- agents/react-build-resolver.md
- .kiro/agents/react-build-resolver.json (prompt string mirrored)

Mirrors the same guard already applied to commands/react-review.md in de135f61.

* fix(react): pin tsc resolution to local install in build resolver

Discussion r3289054157: previous fix used 'npx --yes tsc' which auto-installs
the latest TypeScript from npm when none is local, producing version drift
and non-reproducible typecheck results across machines.

Switched to 'npx --no-install tsc' in both variants so the diagnostic uses
only the project's pinned TypeScript and fails fast if it isn't installed:
- agents/react-build-resolver.md
- .kiro/agents/react-build-resolver.json (prompt string mirrored)

* feat(counts): resolve counts for agents, skills...

* fix(ci): regen command registry for golang-testing entry

Removes stale kotlin-patterns entry to satisfy command-registry:check.

* fix: keep local Claude settings out of React track PR

---------

Co-authored-by: AlexisLeDain <a.ledain@docoon.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Affaan Mustafa <affaan@dcube.ai>
2026-05-28 07:32:52 -04:00

424 lines
13 KiB
Markdown

---
name: react-testing
description: React component testing with React Testing Library, Vitest/Jest, MSW for network mocking, accessibility assertions with axe, and the decision boundary between component tests and Playwright/Cypress end-to-end runs. Use when writing or fixing tests for React components, hooks, or pages.
origin: ECC
---
# React Testing
Comprehensive React testing patterns for behavior-focused component tests, custom hook tests, accessibility assertions, and network-level mocking.
## When to Activate
- Writing tests for React components, custom hooks, or pages
- Adding test coverage to legacy untested components
- Migrating from Enzyme or class-component-era patterns to React Testing Library
- Setting up Vitest or Jest for a new React project
- Mocking HTTP requests in tests
- Asserting accessibility violations
- Deciding which tests belong in RTL vs Playwright Component Testing vs full E2E
## Core Principle
Test what the user sees and does, not implementation details.
A test should:
- Render the component with the same providers it has in production
- Interact with it via accessible queries (role, label) and `userEvent`
- Assert visible output and observable side effects (callback fired, request sent)
A test should NOT:
- Inspect component state, props passed to children, or which hooks were called
- Mock React itself or framework hooks
- Assert on the number of renders or DOM structure beyond what affects users
## Library Choice
| Runner | When | Note |
|---|---|---|
| **Vitest** | Vite, Remix, modern setups | Faster, native ESM, Jest-compatible API |
| **Jest** | Next.js, CRA, established repos | Default for many React projects |
| **Playwright Component Testing** | Real browser engine needed | Use when JSDOM lacks the required feature |
| **Cypress Component Testing** | Real browser, Cypress already in use | Alternative to Playwright CT |
Pick one. Do not run RTL + Vitest AND Playwright CT in the same repo unless you have a clear lane separation.
## Query Priority
React Testing Library exposes queries in three tiers — use top-down:
1. **Accessible to everyone**: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByDisplayValue`
2. **Semantic**: `getByAltText`, `getByTitle`
3. **Test IDs (escape hatch)**: `getByTestId`
```tsx
// Best
screen.getByRole("button", { name: /save/i });
// OK for inputs
screen.getByLabelText("Email");
// Last resort
screen.getByTestId("save-btn");
```
Variants:
- `getBy*` — throws if no match
- `queryBy*` — returns `null` (use for "assert absence")
- `findBy*` — async, returns a Promise (use for elements that appear after async work)
## User Interaction with `userEvent`
```tsx
import userEvent from "@testing-library/user-event";
test("submits the form", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com" });
});
```
- Always `await` userEvent calls
- Call `userEvent.setup()` once per test, reuse the returned `user`
- `userEvent` simulates a real browser sequence; `fireEvent` dispatches a single synthetic event — prefer `userEvent`
## Async Patterns
```tsx
// Element that appears after async work
expect(await screen.findByText("Loaded")).toBeInTheDocument();
// Side effect assertion
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// Element that should disappear
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
```
Never `setTimeout` + assertion — flaky. Use the matchers above.
## Network Mocking with MSW
Mock Service Worker mocks at the network layer. The component, hooks, and fetch library all behave exactly as in production.
### Setup
```ts
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
Configure `onUnhandledRequest: "error"` so any unmocked request fails the test loudly — silent passes are worse than red.
### Per-test override
```tsx
test("renders error on 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
```
## Provider Wrapping
Wrap providers once in a `test-utils.tsx`:
```tsx
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";
```
Then `import { renderWithProviders, screen } from "test-utils"` in every test file.
## Custom Hook Testing
```tsx
import { renderHook, act } from "@testing-library/react";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter accepts initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser fetches user data", async () => {
// Instantiate QueryClient ONCE per test outside the wrapper so it survives re-renders.
// Creating it inside the wrapper closure resets cache state on every render, producing flaky tests.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});
```
- Wrap state-changing calls in `act`
- Test through the hook's public API only
- For hooks that use context, pass a `wrapper`
## Accessibility Assertions
```tsx
import { axe, toHaveNoViolations } from "jest-axe"; // or vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard has no a11y violations", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
```
Run axe in component tests for every interactive component. Catches:
- Missing labels on form inputs
- Invalid ARIA usage
- Poor color contrast (limited — JSDOM has no real CSS engine, so this works for inline styles only; visual contrast belongs in Playwright)
- Missing alt text on images
- Heading order violations
Cross-link: [skills/accessibility/SKILL.md](../accessibility/SKILL.md) for the broader a11y testing playbook.
## When NOT to Use Snapshot Tests
Snapshots of rendered output:
- Break on every styling change
- Get rubber-stamped during review
- Test implementation detail (DOM structure), not behavior
Acceptable snapshot uses:
- Pure data serialization functions (`formatInvoice(invoice)` -> stable string)
- Generated config files (e.g., webpack config output)
For visual regression on components, use Playwright/Cypress screenshots or Percy/Chromatic — actual visual diffs, not DOM strings.
## When to Reach for Playwright / Cypress
JSDOM (used by Vitest/Jest) cannot:
- Render real layout (flexbox, grid, viewport queries)
- Run native browser animation, CSS transitions
- Test scrolling behavior, drag-and-drop, paste from clipboard
- Handle iframes, popups, downloads, cross-origin flows
- Run real network in a controlled environment with full DevTools support
For any of those, use Playwright Component Testing (component test in real browser) or full E2E. See [e2e-testing skill](../e2e-testing/SKILL.md).
Decision boundary:
- A hook, a presentational component, a form with logic -> RTL
- A component whose layout matters or that uses browser APIs not in JSDOM -> Playwright CT
- A full user flow across multiple pages -> Playwright/Cypress E2E
## Coverage Targets
| Layer | Target |
|---|---|
| Pure utilities | >=90% |
| Custom hooks | >=85% |
| Presentational components | >=80% — behavior, not lines |
| Container components | >=70% — golden paths + error states |
| Pages | E2E covered separately; smoke test minimum |
Configure via `vitest.config.ts` / `jest.config.js`:
```ts
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
```
## Anti-Patterns
- `container.querySelector("...")` — bypasses accessibility queries, lets tests pass when real users would fail
- Asserting on number of renders — implementation detail
- `jest.mock("react", ...)` — never mock React. Refactor the component instead
- Mocking child components by default — tests the integration, not isolation. Mock only when the child has heavy side effects
- Ignoring `act()` warnings — they signal real bugs (state update after unmount, missing async wrapping)
- Sharing mutable state across tests — flakes when test order changes
- Tests that pass with `it.skip()` removed — your test does not actually assert what you think
## TDD Workflow
```
RED -> Write failing test for the next requirement
GREEN -> Write minimal component code to pass
REFACTOR -> Improve the component, tests stay green
REPEAT -> Next requirement
```
For new components:
1. Define the component's prop type and signature
2. Write the first test for the simplest case
3. Verify it fails for the right reason
4. Implement just enough to pass
5. Add the next test case
6. Refactor when the third similar test reveals a pattern
## Test Commands
```bash
# Vitest
vitest # watch
vitest run # one-shot
vitest run --coverage # with coverage
vitest run path/to/file.test.tsx # single file
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# CI mode
CI=true vitest run --coverage
```
## Related
- Rules: [rules/react/testing.md](../../rules/react/testing.md)
- Skills: [react-patterns](../react-patterns/SKILL.md), [accessibility](../accessibility/SKILL.md), [e2e-testing](../e2e-testing/SKILL.md), [tdd-workflow](../tdd-workflow/SKILL.md)
- Agents: `react-reviewer` (reviews test quality during code review), `tdd-guide` (enforces TDD process)
- Commands: `/react-test`, `/react-review`
## Examples
### Form submission with MSW and userEvent
```tsx
test("submits user form and shows success", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
});
```
### Testing an error boundary
```tsx
function Broken() {
throw new Error("boom");
}
test("error boundary renders fallback", () => {
// Suppress React's console.error noise for the expected throw, then restore so
// the spy does not leak across tests and hide real errors elsewhere.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
```
### Testing a Suspense boundary
```tsx
test("shows loading then content", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});
```