react-testing-best-practices

📁 rutpshah/skills 📅 6 days ago
3
总安装量
2
周安装量
#59106
全站排名
安装命令
npx skills add https://github.com/rutpshah/skills --skill react-testing-best-practices

Agent 安装分布

mcpjam 2
kimi-cli 2
gemini-cli 2
antigravity 2
windsurf 2
zencoder 2

Skill 文档

React Testing Best Practices

Comprehensive testing patterns for React applications using React Testing Library (RTL), Vitest, and Jest.

Core Philosophy

Test behavior, not implementation. Users interact with the DOM — tests should too.

  • Query by what users see: roles, labels, text — not class names or internal state
  • Avoid testing implementation details (state variables, internal methods)
  • Prefer integration-level tests over isolated unit tests for components
  • One assertion focus per test; use descriptive test names

Setup

Vitest + RTL (recommended for Vite projects)

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// vite.config.ts
export default defineConfig({
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: "./src/test/setup.ts",
  },
});

// src/test/setup.ts
import "@testing-library/jest-dom";

Jest + RTL (for Create React App / Next.js)

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
// jest.config.js
module.exports = {
  testEnvironment: "jsdom",
  setupFilesAfterFramework: ["@testing-library/jest-dom"],
};

Query Priority (RTL)

Always prefer in this order:

  1. getByRole — most accessible, mirrors how screen readers see the page
  2. getByLabelText — for form fields
  3. getByPlaceholderText — fallback for inputs
  4. getByText — for non-interactive content
  5. getByTestId — last resort only; use data-testid sparingly

❌ Never use: querySelector, getElementsByClassName, enzyme’s .find('.classname')


Component Testing

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";

describe("Button", () => {
  it("calls onClick when clicked", async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();

    render(<Button onClick={handleClick}>Submit</Button>);
    await user.click(screen.getByRole("button", { name: /submit/i }));

    expect(handleClick).toHaveBeenCalledOnce();
  });

  it("is disabled when loading", () => {
    render(<Button loading>Submit</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Form Testing

it("submits the form with user input", async () => {
  const user = userEvent.setup();
  const handleSubmit = vi.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

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

  expect(handleSubmit).toHaveBeenCalledWith({
    email: "user@example.com",
    password: "secret123",
  });
});

Async & API Testing

Use waitFor or findBy* for async state changes. Always mock fetch or axios at the module level.

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([{ id: 1, name: "Alice" }]);
  }),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it("renders users from API", async () => {
  render(<UserList />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  const user = await screen.findByText("Alice");
  expect(user).toBeInTheDocument();
});

it("shows error on API failure", async () => {
  server.use(http.get("/api/users", () => HttpResponse.error()));

  render(<UserList />);
  expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});

Prefer MSW (Mock Service Worker) over vi.mock('axios') — it intercepts at the network level, making tests more realistic.


Custom Hook Testing

import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";

it("increments the counter", () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

For hooks that depend on context, wrap with a provider:

const wrapper = ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useTheme(), { wrapper });

Context & Provider Testing

const renderWithProviders = (ui, options = {}) => {
  const { store = setupStore(), ...renderOptions } = options;

  const Wrapper = ({ children }) => (
    <Provider store={store}>
      <ThemeProvider theme="light">{children}</ThemeProvider>
    </Provider>
  );

  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
};

// Usage
it("shows user name from store", () => {
  const store = setupStore({ user: { name: "Alice" } });
  renderWithProviders(<Header />, { store });
  expect(screen.getByText("Alice")).toBeInTheDocument();
});

Extract renderWithProviders into src/test/utils.tsx and re-export from RTL:

// src/test/utils.tsx
export * from "@testing-library/react";
export { renderWithProviders as render };

Mocking

// Mock a module
vi.mock("../utils/api", () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));

// Mock only part of a module
vi.mock("../utils/date", async (importOriginal) => {
  const actual = await importOriginal();
  return { ...actual, formatDate: vi.fn(() => "Jan 1, 2025") };
});

// Spy on a method
const spy = vi.spyOn(console, "error").mockImplementation(() => {});

Always restore mocks: afterEach(() => vi.restoreAllMocks())


Accessibility Testing

npm install -D jest-axe
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);

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

Common Mistakes to Avoid

❌ Avoid ✅ Do instead
getByTestId('submit-btn') getByRole('button', { name: /submit/i })
wrapper.find(MyComponent) Query the DOM output directly
act() around every interaction userEvent handles act() internally
fireEvent.click() await userEvent.click() — more realistic
Asserting internal state Assert visible UI changes
Empty describe blocks Group only related tests; flat is fine

File Naming & Organization

src/
  components/
    Button/
      Button.tsx
      Button.test.tsx       ← colocate tests
  hooks/
    useCounter.ts
    useCounter.test.ts
  test/
    setup.ts                ← global setup
    utils.tsx               ← renderWithProviders, custom matchers
    mocks/
      handlers.ts           ← MSW handlers
      server.ts             ← MSW server setup

Additional Patterns

See references/testing-patterns.md for:

  • Snapshot testing guidance
  • Testing React Router navigation
  • Testing with React Query / TanStack Query
  • Testing drag-and-drop interactions
  • Visual regression testing with Playwright