Skip to content
Back to skills

Qa React

ASecurity

React testing skill with React Testing Library, Vitest, MSW, and Playwright. Use when writing or running React frontend tests.

  • 3 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 26, 2026
testingtypescriptgoreactnodetestingapifrontendfullstack

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 7 files and shows the line behind each finding

Scanned May 27, 2026

npx -y skills add endorphin-ai/claude-code-teams --skill qa-react --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Qa React?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Qa React
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/endorphin-ai-qa-react/badge)](https://www.skillsdirectory.com/skills/endorphin-ai-qa-react)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: qa-react
description: "React testing skill with React Testing Library, Vitest, MSW, and Playwright. Use when writing or running React frontend tests."
---

# QA React

## Purpose

This skill equips the `tester-react` agent with everything needed to write, run, and maintain React frontend tests in a fullstack Golang + React codebase. It covers unit tests for components and hooks, integration tests for pages, API mocking with MSW, accessibility checks, and optional E2E flows with Playwright. The guiding philosophy: **test what the user experiences, not how the code is wired**.

## Core Testing Stack

| Tool | Role | Version Guidance |
|------|------|------------------|
| **Vitest** | Test runner | Vite-compatible, ESM-native, fast HMR-aware |
| **React Testing Library (RTL)** | Component rendering + queries | `@testing-library/react` |
| **user-event** | Realistic user interaction simulation | `@testing-library/user-event` v14+ |
| **MSW** | Network-level API mocking | `msw` v2+ — intercepts fetch/axios at Service Worker level |
| **jest-axe** | Automated accessibility assertions | `jest-axe` via `toHaveNoViolations` |
| **Playwright** | E2E browser tests (optional) | For critical user flows only |
| **@testing-library/react-hooks** | Hook testing (legacy) | Prefer `renderHook` from `@testing-library/react` v14+ |

## RTL Philosophy: Test Behavior, Not Implementation

The single most important principle: **tests should resemble how users interact with your app**. If a test breaks because you renamed an internal state variable but the UI behavior didn't change, the test is wrong.

### Forbidden Patterns
- **Never** query by CSS class name, tag name, or component internal state
- **Never** assert on `useState` / `useReducer` values directly
- **Never** use `container.querySelector` unless absolutely no RTL query works
- **Never** test implementation details (internal function calls, hook return values in isolation without rendering)
- **Never** use `act()` directly when RTL's `waitFor` / `findBy` handles it

### Encouraged Patterns
- Query by accessible role, label, placeholder, text — what the user sees
- Simulate real user actions (click, type, tab) with `userEvent`
- Assert on visible output: text content, element presence/absence, ARIA attributes
- Use `waitFor` and `findBy*` for async state transitions

## RTL Query Priority (strict order)

Use the highest-priority query that works for your case:

1. **`getByRole`** — Accessible role + name. Best for buttons, headings, links, form controls. Example: `getByRole('button', { name: /submit/i })`
2. **`getByLabelText`** — Form fields with associated `<label>`. Example: `getByLabelText(/email address/i)`
3. **`getByPlaceholderText`** — When label is absent (not ideal, but acceptable). Example: `getByPlaceholderText(/search/i)`
4. **`getByText`** — Visible text content. Example: `getByText(/welcome back/i)`
5. **`getByDisplayValue`** — Current value of input/textarea/select.
6. **`getByAltText`** — Images with alt text.
7. **`getByTitle`** — Elements with title attribute.
8. **`getByTestId`** — **Last resort only**. Use `data-testid` when no semantic query works. Example: `getByTestId('chart-container')`

### Query Variants

| Need | Prefix | Throws on miss? | Async? |
|------|--------|-----------------|--------|
| Element must exist | `getBy` | Yes | No |
| Element must NOT exist | `queryBy` | No (returns null) | No |
| Element will appear (async) | `findBy` | Yes (waits) | Yes |
| Multiple elements | `getAllBy` / `queryAllBy` / `findAllBy` | Respective behavior | Respective |

## User Event Simulation

Always use `@testing-library/user-event` over `fireEvent`. It simulates complete user interaction sequences (focus, keydown, keypress, keyup, input, change) rather than dispatching isolated synthetic events.

```typescript
import userEvent from '@testing-library/user-event';

// Setup — create a user instance per test
const user = userEvent.setup();

// Click
await user.click(screen.getByRole('button', { name: /save/i }));

// Type into input
await user.type(screen.getByLabelText(/username/i), 'john_doe');

// Clear and retype
await user.clear(screen.getByLabelText(/email/i));
await user.type(screen.getByLabelText(/email/i), 'new@email.com');

// Select option
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'US');

// Keyboard
await user.keyboard('{Enter}');
await user.tab();

// Upload file
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
await user.upload(screen.getByLabelText(/upload/i), file);
```

## Async Testing

### `waitFor` — poll until assertion passes
```typescript
await waitFor(() => {
  expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
```

### `findBy` — shorthand for waitFor + getBy
```typescript
const heading = await screen.findByRole('heading', { name: /dashboard/i });
expect(heading).toBeInTheDocument();
```

### `waitForElementToBeRemoved` — wait for loading spinners to disappear
```typescript
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
```

### Rules
- Default timeout is 1000ms. Increase for slow operations: `waitFor(() => ..., { timeout: 3000 })`
- Never use `setTimeout` or manual delays in tests
- If a `findBy` query is sufficient, prefer it over `waitFor` + `getBy`

## MSW (Mock Service Worker) — API Mocking

MSW intercepts HTTP requests at the network level. Tests hit the same fetch/axios calls as production — no mocking of modules.

### Server Setup (`src/test/setup.ts`)

```typescript
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

// Default handlers — shared across all tests
export const handlers = [
  http.get('/api/v1/user/me', () => {
    return HttpResponse.json({ id: 1, name: 'Test User', email: 'test@example.com' });
  }),
  http.get('/api/v1/health', () => {
    return HttpResponse.json({ status: 'ok' });
  }),
];

export const server = setupServer(...handlers);

// Vitest global setup
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

### Per-Test Handler Overrides

```typescript
import { server } from '@/test/setup';
import { http, HttpResponse } from 'msw';

test('shows error when API fails', async () => {
  server.use(
    http.get('/api/v1/user/me', () => {
      return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }),
  );

  render(<ProfilePage />);
  expect(await screen.findByText(/unauthorized/i)).toBeInTheDocument();
});
```

### MSW Handler Patterns

| Scenario | Pattern |
|----------|---------|
| Success response | `HttpResponse.json(data, { status: 200 })` |
| Error response | `HttpResponse.json({ error: msg }, { status: 4xx/5xx })` |
| Empty response | `HttpResponse.json(null, { status: 204 })` |
| Network error | `HttpResponse.error()` |
| Delayed response | `await delay(ms)` before returning |
| Request body validation | Read `await request.json()` and assert / branch |
| Paginated list | Read URL search params, return sliced data |

### MSW Convention
- Place default handlers in `src/test/handlers/` organized by API domain (e.g., `user.ts`, `projects.ts`, `auth.ts`)
- Export each handler array, compose into `server` in `setup.ts`
- Override in individual tests only for error/edge cases

## Component Testing Pattern

```typescript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  const user = userEvent.setup();
  const mockOnSubmit = vi.fn();

  beforeEach(() => {
    mockOnSubmit.mockClear();
  });

  it('renders email and password fields', () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
  });

  it('calls onSubmit with credentials when form is filled and submitted', async () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'user@test.com');
    await user.type(screen.getByLabelText(/password/i), 'secret123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(mockOnSubmit).toHaveBeenCalledWith({
      email: 'user@test.com',
      password: 'secret123',
    });
  });

  it('shows validation error when email is empty', async () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
});
```

## Hook Testing Pattern

Use `renderHook` from `@testing-library/react` (not the deprecated `@testing-library/react-hooks` package).

```typescript
import { renderHook, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('starts with initial value', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter(0));
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });
});
```

### Hooks that need providers (React Query, Router, etc.)
```typescript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

it('fetches user data', async () => {
  const { result } = renderHook(() => useUser(1), { wrapper: createWrapper() });

  await waitFor(() => expect(result.current.isSuccess).toBe(true));
  expect(result.current.data?.name).toBe('Test User');
});
```

## Page / Integration Testing Pattern

Page tests render a full page component with router context, mocked API, and all providers.

```typescript
import { render, screen } from '@/test/test-utils'; // custom render with providers
import { DashboardPage } from './DashboardPage';

describe('DashboardPage', () => {
  it('loads and displays project list', async () => {
    render(<DashboardPage />);

    // Wait for loading to complete
    await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));

    // Assert data is rendered
    expect(screen.getByRole('heading', { name: /my projects/i })).toBeInTheDocument();
    expect(screen.getAllByRole('listitem')).toHaveLength(3);
  });

  it('navigates to project detail when clicked', async () => {
    render(<DashboardPage />);

    await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
    await user.click(screen.getByText(/project alpha/i));

    expect(await screen.findByRole('heading', { name: /project alpha/i })).toBeInTheDocument();
  });
});
```

### Custom Render (`src/test/test-utils.tsx`)

```typescript
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function AllProviders({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>{children}</BrowserRouter>
    </QueryClientProvider>
  );
}

const customRender = (ui: React.ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
  render(ui, { wrapper: AllProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };
```

## Snapshot Testing

Use snapshots **sparingly** and only for stable, presentational UI that changes rarely.

```typescript
it('matches snapshot for static footer', () => {
  const { container } = render(<Footer />);
  expect(container.firstChild).toMatchSnapshot();
});
```

### Snapshot Rules
- Never snapshot components with dynamic data (dates, IDs, random values)
- Never snapshot entire pages
- Prefer inline snapshots (`toMatchInlineSnapshot`) for small outputs
- When a snapshot update is needed, review the diff carefully before accepting with `--update`
- If a snapshot breaks frequently, replace it with explicit assertions

## Accessibility Testing

```typescript
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

it('has no accessibility violations', async () => {
  const { container } = render(<LoginForm onSubmit={vi.fn()} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
```

### Accessibility Checks to Include
- Run `jest-axe` on every form component and every page-level component
- Verify focus management after modal open/close
- Verify keyboard navigation for custom interactive elements
- Check ARIA labels on icon-only buttons

## Coverage Targets

| Category | Target | Rationale |
|----------|--------|-----------|
| Business logic hooks (`use*`) | **80%+** | Core logic, high ROI |
| Form components | **80%+** | User input paths must be solid |
| Page components | **70%+** | Integration coverage, some paths are hard to reach |
| Presentational/layout components | **50%+** | Low logic, snapshot may suffice |
| Utility functions | **90%+** | Pure functions, easy to test exhaustively |
| E2E critical paths | **100% of defined flows** | If you write an E2E test, it must pass |

Run coverage: `npx vitest run --coverage`

## Playwright E2E (Optional — Critical Flows Only)

Use Playwright only for high-value end-to-end user flows that cannot be adequately tested with RTL + MSW. Examples: login flow, checkout, multi-step wizard.

```typescript
import { test, expect } from '@playwright/test';

test('user can log in and see dashboard', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel(/email/i).fill('user@test.com');
  await page.getByLabel(/password/i).fill('password123');
  await page.getByRole('button', { name: /sign in/i }).click();

  await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
```

### Playwright Rules
- E2E tests live in `e2e/` at project root, not alongside components
- E2E tests run against a real dev server (or preview build), not mocked APIs
- Keep E2E suite small: 10-20 critical flows max
- Tag with `@critical` for CI gating

## Test Setup File (`src/test/setup.ts`)

This file is loaded by Vitest before every test file. It configures:

1. **MSW server** — starts, resets between tests, closes after suite
2. **jest-dom matchers** — extends `expect` with `.toBeInTheDocument()`, `.toHaveTextContent()`, etc.
3. **jest-axe matchers** — extends `expect` with `.toHaveNoViolations()`
4. **Global cleanup** — ensures no leaked state between tests

```typescript
import '@testing-library/jest-dom/vitest';
import { server } from './handlers';

beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

Referenced in `vitest.config.ts`:
```typescript
export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
    css: false,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/test/**', 'src/**/*.d.ts', 'src/main.tsx'],
    },
  },
});
```

## File Conventions

| Convention | Rule |
|-----------|------|
| Test file location | Same directory as source: `Button.tsx` -> `Button.test.tsx` |
| Test file naming | `ComponentName.test.tsx` for components, `useHook.test.ts` for hooks |
| Test utility location | `src/test/test-utils.tsx` for custom render, `src/test/handlers/` for MSW handlers |
| E2E test location | `e2e/` at project root |
| Describe blocks | `describe('ComponentName', () => { ... })` — one per file, named after component |
| Test names | Start with verb describing user action or state: "renders...", "shows...", "calls...", "navigates..." |

## Conventions

1. **One test file per source file** — never combine tests for multiple components
2. **Arrange-Act-Assert** — every test follows this structure clearly
3. **No test interdependence** — each test must work in isolation; no shared mutable state
4. **Mock at the network level** — use MSW, not `vi.mock()` for API calls. Reserve `vi.mock()` for non-HTTP dependencies (e.g., `window.matchMedia`, `IntersectionObserver`)
5. **Descriptive test names** — a failing test name should tell you what broke without reading the test body
6. **No magic numbers** — use named constants or factory functions for test data
7. **Test data factories** — create helper functions for generating test objects with sensible defaults
8. **Clean up** — if a test modifies global state (localStorage, cookies), restore it in `afterEach`

## Knowledge Strategy

- **Patterns to capture:** Reusable MSW handler configurations, custom render wrappers for project-specific providers, test data factory patterns, recurring accessibility violation fixes.
- **Examples to collect:** Successful test suites with high coverage, effective integration test patterns for complex pages, MSW handler compositions for multi-step API flows.
- **Update permission:** Agents may freely add/update files in `references/`. Changes to `SKILL.md` or `scripts/` require user approval.

Files in this skill

  • DRY_RUN.md4.8 KB
  • FORMAT.md5 KB
  • SKILL.md17.4 KB
  • VOICE.md3.7 KB
  • workflows/bugfix.md4.5 KB
  • workflows/feature.md7.2 KB
  • workflows/review.md7.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.