AgentSkillsCN

testing-patterns

引导 Vitest 单元测试与 Playwright E2E 测试的测试规范。适用于编写测试、创建 Mock 工厂、使用 Testing Library 测试 React 组件、测试 Hook、测试 Zod Schema,或搭建 Playwright Page Object Model 时使用。

SKILL.md
--- frontmatter
name: testing-patterns
description: "Guides testing conventions for Vitest unit tests and Playwright E2E tests. Activates when writing tests, creating mock factories, testing React components with Testing Library, testing hooks, testing Zod schemas, or setting up Playwright Page Object Models."

Testing Patterns

This project uses Vitest + Testing Library for unit tests and Playwright for E2E tests. All tests follow strict conventions.

IMPORTANT: Never hardcode test data inline. Always use mock factories from the feature's mocks/ folder. If mock factories don't exist yet, create them first before writing tests.

Unit Tests (Vitest)

Location & Structure

  • Co-located next to source: [name].test.ts(x) inside the feature
  • describe/it blocks with AAA pattern (Arrange, Act, Assert)
  • Readable names: it("should show error when API returns 500")

Query Priority (Testing Library)

Always prefer the most accessible query:

  1. getByRole — best (semantic, accessible)
  2. getByLabelText — good (form elements)
  3. getByText — ok (visible text)
  4. getByTestId — last resort

NEVER use CSS selectors or XPath in tests.

Use userEvent for interactions, never fireEvent.

Mock Factories

Never hardcode test data. Always use mock factories from ../mocks/.

See examples/mock-factory.ts for the pattern.

Rules:

  • One file per entity: [name].mock.ts
  • Factory: createMock[Entity](overrides?: Partial<Entity>)
  • List factory: createMock[Entity]List(count, overrides?)
  • DTO factories: createMock[Create|Update][Entity]Input(overrides?)
  • Deterministic defaults (no Math.random())
typescript
// src/features/tasks/mocks/task.mock.ts
import type { Task } from "../types/task";

export function createMockTask(overrides?: Partial<Task>): Task {
  return {
    id: "task-1",
    title: "Default task",
    status: "pending",
    createdAt: "2024-01-01T00:00:00.000Z",
    ...overrides,
  };
}

export function createMockTaskList(count = 3, overrides?: Partial<Task>): Task[] {
  return Array.from({ length: count }, (_, i) =>
    createMockTask({ id: `task-${i + 1}`, title: `Task ${i + 1}`, ...overrides })
  );
}

Decision Tree: What Test Type Do I Need?

code
What am I testing?
├── A React component?
│   └── Component test with @testing-library/react
│       ├── Does it fetch data? → Mock apiClient, test loading/success/error/empty
│       ├── Does it accept props? → Test with different prop values
│       ├── Does it have user interactions? → Test with userEvent
│       └── Does it use feedback states? → Verify EmptyState/ErrorState/LoadingState render
├── A Zod schema?
│   └── Schema test with parse/safeParse
│       ├── Valid data → schema.parse() should succeed
│       ├── Invalid data → schema.safeParse() should fail with correct errors
│       └── Edge cases → optional fields, empty strings, boundary values
├── A custom hook?
│   └── Hook test with @testing-library/react renderHook
│       ├── Does it use React Query? → Wrap in QueryClientProvider
│       └── Does it use Zustand? → Reset store in beforeEach
├── A Zustand store?
│   └── Store test
│       ├── Initial state → verify defaults
│       ├── Actions → verify state changes
│       └── Reset in beforeEach → use[Name]Store.setState(initialState)
├── A query hook?
│   └── Query hook test
│       ├── Mock @/lib/api-client
│       ├── Fresh QueryClient per test
│       └── Test success, error, and loading states
└── A utility function?
    └── Simple unit test with input/output assertions

Component Test Template

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TaskList } from "./task-list";
import { createMockTaskList } from "../mocks/task.mock";

vi.mock("@/lib/api-client", () => ({
  apiClient: vi.fn(),
}));

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

describe("TaskList", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("should render tasks when data is available", async () => {
    // Arrange
    const tasks = createMockTaskList(3);
    const { apiClient } = await import("@/lib/api-client");
    vi.mocked(apiClient).mockResolvedValue({ data: tasks, error: null });

    // Act
    render(<TaskList />, { wrapper: createWrapper() });

    // Assert
    expect(await screen.findByText("Task 1")).toBeInTheDocument();
  });

  it("should render EmptyState when no tasks exist", async () => {
    // Arrange
    const { apiClient } = await import("@/lib/api-client");
    vi.mocked(apiClient).mockResolvedValue({ data: [], error: null });

    // Act
    render(<TaskList />, { wrapper: createWrapper() });

    // Assert
    expect(await screen.findByText(/no tasks/i)).toBeInTheDocument();
  });

  it("should render ErrorState when API fails", async () => {
    // Arrange
    const { apiClient } = await import("@/lib/api-client");
    vi.mocked(apiClient).mockRejectedValue(new Error("Server error"));

    // Act
    render(<TaskList />, { wrapper: createWrapper() });

    // Assert
    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });
});

Schema Test Template

typescript
import { describe, it, expect } from "vitest";
import { taskSchema, createTaskInputSchema } from "../schemas/task.schema";
import { createMockTask } from "../mocks/task.mock";

describe("taskSchema", () => {
  it("should parse valid task data", () => {
    const task = createMockTask();
    expect(() => taskSchema.parse(task)).not.toThrow();
  });

  it("should reject task without required title", () => {
    const result = taskSchema.safeParse({ id: "1", status: "pending" });
    expect(result.success).toBe(false);
  });

  it("should reject invalid status enum value", () => {
    const task = createMockTask({ status: "invalid" as never });
    const result = taskSchema.safeParse(task);
    expect(result.success).toBe(false);
  });
});

Scenarios to Always Cover

For Components:

  • Default render with required props
  • All interactive states (hover, focus, disabled)
  • Empty data → EmptyState renders (NOT null)
  • Error state → ErrorState renders
  • Loading state → LoadingState renders

For Schemas:

  • Valid data parses correctly
  • Invalid data is rejected with correct errors
  • Optional fields handle undefined
  • Edge cases: empty strings, boundary values, enum values

For Hooks/Queries:

  • Fresh QueryClient per test
  • Mock @/lib/api-client
  • Test loading, success, and error states
  • Verify mutations invalidate correct queries

For Stores:

  • Initial state is correct
  • Actions update state correctly
  • Reset state in beforeEach

Filter Utilities

  • Test applyClientFilters() as a pure function (no React needed)
  • Test search: matching, partial, case-insensitive, empty query, no match
  • Test sorting: string/numeric asc/desc, null handling, immutability

E2E Tests (Playwright)

Location & Structure

  • Tests: tests/e2e/[feature].spec.ts
  • Page objects: tests/e2e/fixtures/[page].fixture.ts

Page Object Model

Create for pages with 3+ interactions. See examples/page-object.ts.

typescript
// tests/e2e/fixtures/tasks-page.fixture.ts
import type { Page, Locator } from "@playwright/test";

export class TasksPage {
  readonly page: Page;
  readonly heading: Locator;
  readonly createButton: Locator;
  readonly taskList: Locator;
  readonly emptyState: Locator;

  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole("heading", { name: /tasks/i });
    this.createButton = page.getByRole("button", { name: /create task/i });
    this.taskList = page.getByRole("list", { name: /tasks/i });
    this.emptyState = page.getByText(/no tasks/i);
  }

  async goto() {
    await this.page.goto("/tasks");
  }

  async createTask(title: string) {
    await this.createButton.click();
    await this.page.getByLabel(/title/i).fill(title);
    await this.page.getByRole("button", { name: /save/i }).click();
  }
}

Selector Priority

  1. getByRole > getByLabel > getByText > getByPlaceholder > getByTestId
  2. NEVER use CSS selectors or XPath

Key Rules

  • Each test is self-contained and independent
  • SSR-first: first load shows content immediately (no loading spinners to wait for)
  • Use page.route() for API mocking in edge cases
  • Web-first assertions: await expect(locator).toBeVisible()
  • Test responsive: 375px (mobile) and 1280px (desktop)

Scenarios to Cover

  • Happy path (main user flow end-to-end)
  • Form validation (required fields, invalid input, error messages)
  • Navigation (links work, breadcrumbs update, URL matches)
  • Error states (ErrorState renders when API fails)
  • Empty states (EmptyState renders, not blank page)
  • Responsive layouts (mobile 375px and desktop 1280px)
  • Sidebar collapsed/expanded (dashboard pages)

DO NOT

  • DO NOT hardcode test data inline — always use mock factories from ../mocks/.
  • DO NOT use fireEvent — always use userEvent.
  • DO NOT import from the barrel in test files — use relative imports within the feature.
  • DO NOT mock the unit under test — mock its dependencies.
  • DO NOT use CSS selectors or XPath — use Testing Library / Playwright locators.
  • DO NOT skip testing feedback states (empty, error, loading) — every data component must handle all three.
  • DO NOT share state between tests — each test must be independent.
  • DO NOT use Math.random() in mock factories — use deterministic values.
  • DO NOT skip running tests after writing them — run npx vitest run [path] and fix all failures.