返回 Skill 列表
extension
分类: 开发与工程无需 API Key

testing-helper

测试React和React Router v7的模式 - Vitest, React Testing Library, 路由测试, 模拟加载器/动作

person作者: jakexiaohubgithub

Testing Helper

Master testing for React and React Router v7 applications. Learn how to write effective tests using Vitest and React Testing Library.

Quick Reference

Basic Component Test

import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";

test("renders button", () => {
  render(<Button>Click me</Button>);
  expect(screen.getByText("Click me")).toBeInTheDocument();
});

Test User Interactions

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

test("handles click", async () => {
  const user = userEvent.setup();
  const handleClick = vi.fn();
  
  render(<Button onClick={handleClick}>Click</Button>);
  await user.click(screen.getByRole("button"));
  
  expect(handleClick).toHaveBeenCalledOnce();
});

Test Loaders

import { loader } from "./route";

test("loader fetches user", async () {
  const result = await loader({
    params: { userId: "123" },
    request: new Request("http://localhost"),
    context: {},
  });
  
  expect(result.user).toBeDefined();
});

When to Use This Skill

  • Setting up testing infrastructure
  • Writing component tests
  • Testing loaders and actions
  • Mocking API calls
  • Testing user interactions
  • Testing forms and validation
  • Integration testing routes

Setup

Install Dependencies

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

Configure Vitest

// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./test/setup.ts"],
    globals: true,
  },
});

Test Setup File

// test/setup.ts
import "@testing-library/jest-dom";
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";

// Cleanup after each test
afterEach(() => {
  cleanup();
});

Component Testing

1. Basic Rendering

import { render, screen } from "@testing-library/react";
import { describe, test, expect } from "vitest";
import { UserCard } from "./UserCard";

describe("UserCard", () => {
  test("renders user information", () => {
    const user = {
      id: "1",
      name: "John Doe",
      email: "john@example.com",
    };
    
    render(<UserCard user={user} />);
    
    expect(screen.getByText("John Doe")).toBeInTheDocument();
    expect(screen.getByText("john@example.com")).toBeInTheDocument();
  });
  
  test("displays avatar when provided", () => {
    const user = {
      id: "1",
      name: "John Doe",
      email: "john@example.com",
      avatar: "https://example.com/avatar.jpg",
    };
    
    render(<UserCard user={user} />);
    
    const avatar = screen.getByRole("img", { name: "John Doe" });
    expect(avatar).toHaveAttribute("src", user.avatar);
  });
});

2. User Interactions

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

test("calls onClick when button is clicked", async () => {
  const user = userEvent.setup();
  const handleClick = vi.fn();
  
  render(<Button onClick={handleClick}>Click me</Button>);
  
  await user.click(screen.getByRole("button", { name: "Click me" }));
  
  expect(handleClick).toHaveBeenCalledOnce();
});

test("types in input field", async () => {
  const user = userEvent.setup();
  const handleChange = vi.fn();
  
  render(<input onChange={handleChange} />);
  
  await user.type(screen.getByRole("textbox"), "Hello");
  
  expect(screen.getByRole("textbox")).toHaveValue("Hello");
  expect(handleChange).toHaveBeenCalledTimes(5); // Once per character
});

3. Async Operations

import { render, screen, waitFor } from "@testing-library/react";

test("loads and displays data", async () => {
  render(<UserProfile userId="123" />);
  
  // Initially shows loading
  expect(screen.getByText("Loading...")).toBeInTheDocument();
  
  // Wait for data to load
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
  
  // Loading indicator is gone
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

Testing React Router

1. Test Loaders

import { loader } from "./route";
import { vi } from "vitest";

// Mock fetch
global.fetch = vi.fn();

test("loader returns user data", async () => {
  const mockUser = { id: "123", name: "John" };
  
  (fetch as any).mockResolvedValueOnce({
    ok: true,
    json: async () => mockUser,
  });
  
  const result = await loader({
    params: { userId: "123" },
    request: new Request("http://localhost/users/123"),
    context: {},
  });
  
  expect(result).toEqual({ user: mockUser });
  expect(fetch).toHaveBeenCalledWith("/api/users/123");
});

test("loader throws on 404", async () => {
  (fetch as any).mockResolvedValueOnce({
    ok: false,
    status: 404,
  });
  
  await expect(loader({
    params: { userId: "999" },
    request: new Request("http://localhost/users/999"),
    context: {},
  })).rejects.toThrow();
});

2. Test Actions

import { action } from "./route";

test("action creates user on valid data", async () => {
  const formData = new FormData();
  formData.set("name", "John Doe");
  formData.set("email", "john@example.com");
  
  const request = new Request("http://localhost/users", {
    method: "POST",
    body: formData,
  });
  
  const result = await action({
    request,
    params: {},
    context: {},
  });
  
  expect(result).toHaveProperty("user");
});

test("action returns errors on invalid data", async () => {
  const formData = new FormData();
  formData.set("name", "");
  formData.set("email", "invalid");
  
  const request = new Request("http://localhost/users", {
    method: "POST",
    body: formData,
  });
  
  const result = await action({
    request,
    params: {},
    context: {},
  });
  
  expect(result).toHaveProperty("errors");
  expect(result.errors).toHaveProperty("name");
  expect(result.errors).toHaveProperty("email");
});

3. Test Routes with RouterProvider

import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";

test("renders route component", async () => {
  const router = createMemoryRouter(
    [
      {
        path: "/users/:userId",
        element: <UserProfile />,
        loader: async () => ({ user: { id: "1", name: "John" } }),
      },
    ],
    {
      initialEntries: ["/users/1"],
    }
  );
  
  render(<RouterProvider router={router} />);
  
  await screen.findByText("John");
});

Mocking

1. Mock API Calls

import { vi } from "vitest";

// Mock fetch globally
global.fetch = vi.fn();

// Mock specific responses
(fetch as any).mockResolvedValue({
  ok: true,
  json: async () => ({ data: "mocked" }),
});

// Clean up after test
afterEach(() => {
  vi.clearAllMocks();
});

2. Mock Modules

import { vi } from "vitest";

// Mock entire module
vi.mock("./api", () => ({
  fetchUser: vi.fn(),
  createUser: vi.fn(),
}));

// Import mocked module
import { fetchUser } from "./api";

test("uses mocked API", async () => {
  (fetchUser as any).mockResolvedValue({ id: "1", name: "John" });
  
  const user = await fetchUser("1");
  
  expect(user).toEqual({ id: "1", name: "John" });
});

3. Mock React Router Hooks

import { vi } from "vitest";
import * as ReactRouter from "react-router";

vi.spyOn(ReactRouter, "useNavigate").mockReturnValue(vi.fn());
vi.spyOn(ReactRouter, "useLoaderData").mockReturnValue({
  user: { id: "1", name: "John" },
});

Form Testing

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";

test("submits form with valid data", async () => {
  const user = userEvent.setup();
  const actionSpy = vi.fn().mockResolvedValue({ success: true });
  
  const router = createMemoryRouter(
    [
      {
        path: "/create",
        element: <CreateUserForm />,
        action: actionSpy,
      },
    ],
    {
      initialEntries: ["/create"],
    }
  );
  
  render(<RouterProvider router={router} />);
  
  // Fill form
  await user.type(screen.getByLabelText("Name"), "John Doe");
  await user.type(screen.getByLabelText("Email"), "john@example.com");
  
  // Submit
  await user.click(screen.getByRole("button", { name: "Submit" }));
  
  // Verify action was called
  expect(actionSpy).toHaveBeenCalled();
});

test("displays validation errors", async () => {
  const user = userEvent.setup();
  const actionSpy = vi.fn().mockResolvedValue({
    errors: { email: ["Invalid email"] },
  });
  
  const router = createMemoryRouter(
    [
      {
        path: "/create",
        element: <CreateUserForm />,
        action: actionSpy,
      },
    ],
    {
      initialEntries: ["/create"],
    }
  );
  
  render(<RouterProvider router={router} />);
  
  await user.type(screen.getByLabelText("Email"), "invalid");
  await user.click(screen.getByRole("button", { name: "Submit" }));
  
  await screen.findByText("Invalid email");
});

Best Practices

  • [ ] Use screen queries over destructured render result
  • [ ] Prefer getByRole over other query methods
  • [ ] Use userEvent instead of fireEvent
  • [ ] Test user behavior, not implementation details
  • [ ] Mock external dependencies (APIs, modules)
  • [ ] Clean up after each test
  • [ ] Use waitFor for async assertions
  • [ ] Test accessibility with role queries
  • [ ] Don't test third-party libraries
  • [ ] Keep tests simple and focused

Query Priority

Use queries in this order:

  1. getByRole - Most accessible
  2. getByLabelText - Good for forms
  3. getByPlaceholderText - Form fallback
  4. getByText - User-visible text
  5. getByTestId - Last resort
// ✅ Best - accessible
screen.getByRole("button", { name: "Submit" });
screen.getByRole("textbox", { name: "Email" });

// ✅ Good - form labels
screen.getByLabelText("Email");

// ⚠️  Okay - if no role/label
screen.getByPlaceholderText("Enter email");

// ⚠️  Fallback
screen.getByText("Submit");

// ❌ Last resort
screen.getByTestId("submit-button");

Common Issues

Issue 1: Act Warnings

Symptoms: "Warning: An update to Component was not wrapped in act()" Cause: State updates not properly awaited Solution: Use waitFor or findBy queries

// ❌ Causes act warning
render(<AsyncComponent />);
expect(screen.getByText("Loaded")).toBeInTheDocument();

// ✅ Waits for update
render(<AsyncComponent />);
await screen.findByText("Loaded");

Issue 2: Can't Find Element

Symptoms: "Unable to find an element" Cause: Wrong query or timing issue Solution: Use correct query method and wait for element

// ❌ Element not visible yet
expect(screen.getByText("Success")).toBeInTheDocument();

// ✅ Wait for element to appear
await screen.findByText("Success");

// Or check if it doesn't exist
expect(screen.queryByText("Error")).not.toBeInTheDocument();

References