smithery/citypaul

react-testing

React component testing patterns including components, hooks, context, and forms. Covers Vitest Browser Mode with vitest-browser-react and @testing-library/react. Use when testing React applications. For general UI testing patterns, see the front-end-testing skill.

Installation

$ npx skills add smithery/citypaul --skill react-testing

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery/citypaul.

npx skills add smithery/citypaul

Browse all from smithery/citypaul

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 14,545 B
  • docs SUMMARY.md 138 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

React Testing

For general UI testing patterns (queries, events, async, accessibility, MSW), load the front-end-testing skill. For TDD workflow, load the tdd skill.

Find every element the way a person perceives it — never by data-testid, a CSS class, or a selector, and least of all because the component already carries one. A control is found by getByRole with its accessible name (getByLabelText for a field). An element with no role — a count line, a status message, a paragraph of copy, a bare <span> of text — is found by the words it shows: getByText(/3 of 7 items/), then asserted on that same locator (toBeVisible(), toHaveAttribute(...)). Never fetch a handle by test id first just to assert its content. If nothing about the element is perceivable enough to query, that is a finding about the component: give it the role, label, or accessible name its purpose implies, and query that.

For flow logic driving the component, load xstate: the machine is tested headlessly and the component test touches only the DOM, so a component test must never assert machine state. If the component under test holds a submitting/isLoading flag in useState, that is the signal the flow escaped its machine — xstate owns that call. For performance changes to the same code, load react-performance, whose rule is that behaviour tests stay unchanged and green.

Follow the tdd skill's canonical fast-feedback and watcher-lifecycle policy plus the front-end-testing skill's browser-specific differences. React adds no separate Vitest graph guarantee: prefer the repository-owned watcher, use diff-selected watch only under the canonical version/configuration proof, and keep every affected app/package consumer eligible through the root graph. Exact files remain RED/debug-only. At PR readiness, stop watchers and apply the target repository's mutation policy plus complete non-watch UI/project gate.

Deep-dive resources are in the resources/ directory. Load them on demand:

Resource Load when...
resources/testing-library-react-legacy.md Working in a @testing-library/react + jsdom codebase — sync render, screen queries, imported act, render helpers, legacy form/hook/context examples

Vitest Browser Mode with React

Prefer vitest-browser-react when the claim depends on real rendering, events, focus, CSS, accessibility, or browser APIs and the repository supports the harness or the added cost is justified. Keep an existing stable @testing-library/react/jsdom harness, or use a lighter environment, when it already proves pure hook, provider, or component logic.

Setup

Extend the Browser Mode config from the front-end-testing skill with the React plugin and vitest-browser-react. Apply that skill's repository-package-manager, exact-version, authorization, and local-binary setup policy:

<repo-pm> add --save-dev vitest@<reviewed-version> @vitest/browser-playwright@<reviewed-version> vitest-browser-react@<reviewed-version> @vitejs/plugin-react@<reviewed-version>
// vitest.config.ts — same as front-end-testing Browser Mode config, plus:
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    browser: { /* unchanged from front-end-testing setup */ },
  },
})

Component Testing

import { render } from 'vitest-browser-react'
import { expect, test } from 'vitest'

test('should display user name when provided', async () => {
  const screen = await render(<UserProfile name="Alice" email="[email protected]" />)

  await expect.element(screen.getByText(/alice/i)).toBeVisible()
  await expect.element(screen.getByText(/[email protected]/i)).toBeVisible()
})

Key differences from @testing-library/react:

  • render() and renderHook() are async — use await
  • Returns a screen scoped to the rendered component
  • Use expect.element() for auto-retrying assertions
  • No act() wrapper needed for component interactions via locators — CDP events + retry handle timing. renderHook state updates still need act (returned by renderHook, see below)
  • Auto-cleanup happens before each test (not after), so components stay visible for debugging

Testing Props and Callbacks

test('should call onSubmit when form submitted', async () => {
  const handleSubmit = vi.fn()
  const screen = await render(<LoginForm onSubmit={handleSubmit} />)

  await screen.getByLabelText(/email/i).fill('[email protected]')
  await screen.getByRole('button', { name: /submit/i }).click()

  expect(handleSubmit).toHaveBeenCalledWith({
    email: '[email protected]',
  })
})

Testing Conditional Rendering (with MSW)

Browser Mode tests run in a real browser, so use MSW's setupWorker (msw/browser) — not setupServer. Start the worker in a setup file and override per test with worker.use(). Full setup: front-end-testing skill, resources/msw.md.

import { http, HttpResponse } from 'msw'
import { worker } from '../vitest.browser.setup'

test('should show error message when login fails', async () => {
  worker.use(
    http.post('/api/login', () => {
      return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 })
    })
  )

  const screen = await render(<LoginForm />)

  await screen.getByLabelText(/email/i).fill('[email protected]')
  await screen.getByRole('button', { name: /submit/i }).click()

  await expect.element(screen.getByText(/invalid credentials/i)).toBeVisible()
})

Testing Hooks with renderHook

renderHook() is async and returns act alongside result — use that act for hook state updates:

import { renderHook } from 'vitest-browser-react'

test('should toggle value', async () => {
  const { result, act } = await renderHook(() => useToggle(false))

  expect(result.current.value).toBe(false)

  await act(() => {
    result.current.toggle()
  })

  expect(result.current.value).toBe(true)
})

Testing Context Providers

test('should show user menu when authenticated', async () => {
  const screen = await render(
    <AuthProvider initialUser={{ name: 'Alice', role: 'admin' }}>
      <Dashboard />
    </AuthProvider>
  )

  await expect.element(screen.getByRole('button', { name: /user menu/i })).toBeVisible()
})

For hooks that need context:

const { result } = await renderHook(() => useAuth(), {
  wrapper: ({ children }) => (
    <AuthProvider>{children}</AuthProvider>
  ),
})

Testing Forms

test('should submit form with user input', async () => {
  const handleSubmit = vi.fn()
  const screen = await render(<RegistrationForm onSubmit={handleSubmit} />)

  await screen.getByLabelText(/name/i).fill('Alice')
  await screen.getByLabelText(/email/i).fill('[email protected]')
  await screen.getByLabelText(/password/i).fill('password123')
  await screen.getByRole('button', { name: /sign up/i }).click()

  expect(handleSubmit).toHaveBeenCalledWith({
    name: 'Alice',
    email: '[email protected]',
    password: 'password123',
  })
})

test('should show validation errors for invalid input', async () => {
  const screen = await render(<RegistrationForm />)

  // Submit empty form
  await screen.getByRole('button', { name: /sign up/i }).click()

  // Validation errors appear
  await expect.element(screen.getByText(/name is required/i)).toBeVisible()
  await expect.element(screen.getByText(/email is required/i)).toBeVisible()
  await expect.element(screen.getByText(/password is required/i)).toBeVisible()
})

Testing Loading States

test('should show loading then data', async () => {
  const screen = await render(<UserList />)

  await expect.element(screen.getByText(/loading/i)).toBeVisible()

  await expect.element(screen.getByText(/alice/i)).toBeVisible()
  await expect.element(screen.getByText(/loading/i)).not.toBeInTheDocument()
})

Testing Error Boundaries

test('should catch errors with error boundary', async () => {
  // Suppress console.error noise for this test
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {})

  try {
    const screen = await render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowsError />
      </ErrorBoundary>
    )

    await expect.element(screen.getByText(/something went wrong/i)).toBeVisible()
  } finally {
    spy.mockRestore()
  }
})

Testing Portals

import { page } from 'vitest/browser'

test('should render modal in portal', async () => {
  const screen = await render(<Modal isOpen={true}>Modal content</Modal>)

  // Portal renders outside the component root; query the page instead
  await expect.element(page.getByText(/modal content/i)).toBeVisible()
})

The returned screen is scoped to the rendered component — for portal content, use the document-wide page from vitest/browser.

Testing Suspense

test('should show fallback then content', async () => {
  const screen = await render(
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  )

  await expect.element(screen.getByText(/loading/i)).toBeVisible()

  await expect.element(screen.getByText(/lazy content/i)).toBeVisible()
})

React Server Components

RSCs can't be tested in Browser Mode component tests — they execute on the server, not in the browser. Test them with e2e tests (Playwright against a running app) or unit tests of logic extracted from the component. Client components ('use client') test normally with vitest-browser-react.


React-Specific Anti-Patterns

1. Unnecessary act() wrapping

WRONG - Manual act() around renders and interactions

await act(async () => {
  await screen.getByRole('button').click()
})

CORRECT - Locator events handle timing

await screen.getByRole('button').click()

When you DO need act(): hook state updates via renderHook (use the act it returns). In @testing-library/react, RTL auto-wraps render/userEvent/waitFor — see resources/testing-library-react-legacy.md.

2. Testing component internals

WRONG - Accessing component internals

const wrapper = shallow(<MyComponent />);
expect(wrapper.state('isOpen')).toBe(true); // Internal state
expect(wrapper.instance().handleClick).toBeDefined(); // Internal method

CORRECT - Test rendered output

const screen = await render(<MyComponent />)
await expect.element(screen.getByRole('dialog')).toBeVisible() // What user sees

3. Shallow rendering — and its modern spelling, mocking a child component

Never vi.mock a component the app under test owns. Replacing a child with a stand-in is shallow rendering by another name, and it is now the far more common form. When a request asks you to stand something in for a child because its markup is somebody else's problem, honour the concern, not the mechanism: render the whole tree and assert only the parent's own observable facts — which items are on screen, the count line, the empty state — and pin none of the child's markup. Mock across a real boundary (network via MSW, clock, randomness), never inside your own component tree.

WRONG - Child replaced by a stand-in, or shallow rendered

vi.mock('./ItemRow', () => ({ ItemRow: ({ item }) => <li>{item.title}</li> }))
const wrapper = shallow(<MyComponent />);
// Child components not rendered - incomplete test

CORRECT - Full rendering

await render(<MyComponent />)
// Full component tree rendered - realistic test

Why: Shallow rendering hides integration bugs between parent/child components.

4. Shared renders and cleanup ownership

Shared mutable render state is the defect, not a lifecycle hook. An isolated beforeEach may create fresh state for each non-concurrent test; use a helper only when repeated or nested setup becomes clearer. Testing Library cleanup is automatic only when the harness provides its expected global afterEach; otherwise register an explicit afterEach(() => cleanup()) in test setup.


Summary Checklist

React-specific checks:

  • Use vitest-browser-react when the claim needs browser-observable behavior and repository support/cost fit
  • Keep @testing-library/react when the stable lighter harness proves the component or hook contract (see resources/testing-library-react-legacy.md)
  • All Playwright/Browser Mode tests are idempotent (no shared state between tests)
  • render()/renderHook() awaited (they are async in vitest-browser-react)
  • Using renderHook() for custom hooks, with its returned act for state updates
  • Using wrapper option for context providers
  • No manual act() around renders or locator interactions
  • Cleanup is either verified automatic for this harness or registered once in test setup
  • MSW via setupWorker/worker.use() in Browser Mode (not setupServer)
  • Testing component output, not internal state
  • Every element found by role, label, or its visible text — no getByTestId, data-testid, selector or class, even where the component provides one
  • No vi.mock of a component the app owns; the whole tree renders and the parent's test asserts only the parent's own facts
  • Setup is isolated per test; lifecycle hooks may create fresh state, and helpers are used only when repeated or nested setup becomes clearer
  • Using expect.element() for auto-retrying assertions (Browser Mode)
  • RSCs tested via e2e or extracted logic, not Browser Mode component tests
  • Following TDD workflow (see tdd skill)
  • GREEN/REFACTOR used complete affected feedback derived by the runner, workspace orchestrator, or repository mapping—or the documented widened fallback when no reliable graph exists—rather than hand-picked test files; the complete repository PR test gate is current and includes the full configured UI suite
  • Using general UI testing patterns (see front-end-testing skill)
  • Factories are used when repeated or nested data becomes clearer; simple one-off values stay inline (see testing skill)