testing-strategy

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Resources

资源

scripts/
  validate-tests.sh
references/
  testing-patterns.md
scripts/
  validate-tests.sh
references/
  testing-patterns.md

Testing Strategy

测试策略

This skill guides you through implementing comprehensive testing strategies using modern testing frameworks and GoodVibes precision tools. Use this workflow when adding tests to existing code, setting up test infrastructure, or achieving coverage goals.
本技能将引导你使用现代测试框架和GoodVibes精准工具实现全面的测试策略。当你需要为现有代码添加测试、搭建测试基础设施或达成覆盖率目标时,可使用此工作流。

When to Use This Skill

何时使用本技能

  • Setting up test infrastructure (Vitest, Jest, Playwright)
  • Writing unit tests for functions, hooks, and utilities
  • Creating component tests with React Testing Library
  • Implementing E2E tests with Playwright
  • Adding API mocking with MSW
  • Improving test coverage
  • Configuring CI test pipelines
  • Debugging flaky tests
  • Creating test fixtures and factories
  • 搭建测试基础设施(Vitest、Jest、Playwright)
  • 为函数、Hook和工具类编写单元测试
  • 使用React Testing Library创建组件测试
  • 用Playwright实现E2E测试
  • 通过MSW添加API模拟
  • 提升测试覆盖率
  • 配置CI测试流水线
  • 调试不稳定的测试(Flaky Tests)
  • 创建测试夹具和工厂函数

Test Organization

测试组织

File Naming Conventions

文件命名规范

Follow consistent naming patterns:
typescript
// Co-located pattern (recommended)
src/
  components/
    Button.tsx
    Button.test.tsx          // Component tests
  utils/
    formatDate.ts
    formatDate.test.ts       // Unit tests
  api/
    users.ts
    users.integration.test.ts // Integration tests

// Centralized pattern (alternative)
__tests__/
  components/
    Button.test.tsx
  utils/
    formatDate.test.ts
遵循统一的命名模式:
typescript
// 同目录模式(推荐)
src/
  components/
    Button.tsx
    Button.test.tsx          // 组件测试
  utils/
    formatDate.ts
    formatDate.test.ts       // 单元测试
  api/
    users.ts
    users.integration.test.ts // 集成测试

// 集中式模式(备选)
__tests__/
  components/
    Button.test.tsx
  utils/
    formatDate.test.ts

Test Suite Structure

测试套件结构

Organize tests with clear describe blocks:
typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { formatDate } from './formatDate';

describe('formatDate', () => {
  describe('with valid dates', () => {
    it('formats ISO dates to MM/DD/YYYY', () => {
      expect(formatDate('2024-01-15')).toBe('01/15/2024');
    });

    it('handles Date objects', () => {
      const date = new Date('2024-01-15');
      expect(formatDate(date)).toBe('01/15/2024');
    });
  });

  describe('with invalid dates', () => {
    it('throws for invalid strings', () => {
      expect(() => formatDate('invalid')).toThrow('Invalid date');
    });

    it('throws for null', () => {
      expect(() => formatDate(null)).toThrow('Invalid date');
    });
  });
});
使用清晰的describe块组织测试:
typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { formatDate } from './formatDate';

describe('formatDate', () => {
  describe('with valid dates', () => {
    it('formats ISO dates to MM/DD/YYYY', () => {
      expect(formatDate('2024-01-15')).toBe('01/15/2024');
    });

    it('handles Date objects', () => {
      const date = new Date('2024-01-15');
      expect(formatDate(date)).toBe('01/15/2024');
    });
  });

  describe('with invalid dates', () => {
    it('throws for invalid strings', () => {
      expect(() => formatDate('invalid')).toThrow('Invalid date');
    });

    it('throws for null', () => {
      expect(() => formatDate(null)).toThrow('Invalid date');
    });
  });
});

Discovery: Finding Test Files

发现:查找测试文件

Use precision tools to discover existing test patterns:
yaml
undefined
使用精准工具发现现有测试模式:
yaml
undefined

Find all test files and analyze patterns

查找所有测试文件并分析模式

discover: queries: - id: test-files type: glob patterns: - "/*.test.{ts,tsx,js,jsx}" - "/.spec.{ts,tsx,js,jsx}" - "e2e/**/.spec.ts" exclude: - "/node_modules/" - "/dist/" - id: test-config type: glob patterns: - "vitest.config.ts" - "jest.config.js" - "playwright.config.ts" - id: skipped-tests type: grep pattern: "\.skip|it\.only|describe\.only" glob: "**/*.test.{ts,tsx}" output_mode: files_only
undefined
discover: queries: - id: test-files type: glob patterns: - "/*.test.{ts,tsx,js,jsx}" - "/.spec.{ts,tsx,js,jsx}" - "e2e/**/.spec.ts" exclude: - "/node_modules/" - "/dist/" - id: test-config type: glob patterns: - "vitest.config.ts" - "jest.config.js" - "playwright.config.ts" - id: skipped-tests type: grep pattern: "\.skip|it\.only|describe\.only" glob: "**/*.test.{ts,tsx}" output_mode: files_only
undefined

Unit Testing

单元测试

Vitest Setup (Recommended)

Vitest 配置(推荐)

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

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/dist/**',
      ],
      all: true,
      lines: 80,
      functions: 80,
      branches: 80,
      statements: 80,
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/dist/**',
      ],
      all: true,
      lines: 80,
      functions: 80,
      branches: 80,
      statements: 80,
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Testing Pure Functions

测试纯函数

typescript
import { describe, it, expect } from 'vitest';
import { calculateTotal, discountPrice } from './pricing';

describe('pricing utilities', () => {
  describe('calculateTotal', () => {
    it('sums item prices', () => {
      const items = [
        { price: 10.00, quantity: 2 },
        { price: 5.50, quantity: 1 },
      ];
      expect(calculateTotal(items)).toBe(25.50);
    });

    it('returns 0 for empty array', () => {
      expect(calculateTotal([])).toBe(0);
    });

    it('handles quantity multipliers', () => {
      const items = [{ price: 10, quantity: 3 }];
      expect(calculateTotal(items)).toBe(30);
    });
  });

  describe('discountPrice', () => {
    it('applies percentage discount', () => {
      expect(discountPrice(100, 0.2)).toBe(80);
    });

    it('rounds to 2 decimal places', () => {
      expect(discountPrice(10.99, 0.15)).toBe(9.34);
    });

    it('throws for invalid discounts', () => {
      expect(() => discountPrice(100, -0.1)).toThrow('Invalid discount');
      expect(() => discountPrice(100, 1.5)).toThrow('Invalid discount');
    });
  });
});
typescript
import { describe, it, expect } from 'vitest';
import { calculateTotal, discountPrice } from './pricing';

describe('pricing utilities', () => {
  describe('calculateTotal', () => {
    it('sums item prices', () => {
      const items = [
        { price: 10.00, quantity: 2 },
        { price: 5.50, quantity: 1 },
      ];
      expect(calculateTotal(items)).toBe(25.50);
    });

    it('returns 0 for empty array', () => {
      expect(calculateTotal([])).toBe(0);
    });

    it('handles quantity multipliers', () => {
      const items = [{ price: 10, quantity: 3 }];
      expect(calculateTotal(items)).toBe(30);
    });
  });

  describe('discountPrice', () => {
    it('applies percentage discount', () => {
      expect(discountPrice(100, 0.2)).toBe(80);
    });

    it('rounds to 2 decimal places', () => {
      expect(discountPrice(10.99, 0.15)).toBe(9.34);
    });

    it('throws for invalid discounts', () => {
      expect(() => discountPrice(100, -0.1)).toThrow('Invalid discount');
      expect(() => discountPrice(100, 1.5)).toThrow('Invalid discount');
    });
  });
});

Testing React Hooks

测试React Hooks

typescript
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAuth } from './useAuth';

// Mock the auth context
vi.mock('@/contexts/AuthContext', () => ({
  useAuthContext: vi.fn(),
}));

describe('useAuth', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns user when authenticated', () => {
    vi.mocked(useAuthContext).mockReturnValue({
      user: { id: '1', email: 'test@example.com' },
      isLoading: false,
    });

    const { result } = renderHook(() => useAuth());
    
    expect(result.current.user).toEqual({
      id: '1',
      email: 'test@example.com',
    });
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('handles loading state', () => {
    vi.mocked(useAuthContext).mockReturnValue({
      user: null,
      isLoading: true,
    });

    const { result } = renderHook(() => useAuth());
    
    expect(result.current.isLoading).toBe(true);
    expect(result.current.isAuthenticated).toBe(false);
  });

  it('refetches user on login', async () => {
    const refetch = vi.fn().mockResolvedValue({ id: '1' });
    vi.mocked(useAuthContext).mockReturnValue({
      user: null,
      refetch,
    });

    const { result } = renderHook(() => useAuth());
    
    await result.current.login('test@example.com', 'password');
    
    expect(refetch).toHaveBeenCalledOnce();
  });
});
typescript
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAuth } from './useAuth';

// Mock the auth context
vi.mock('@/contexts/AuthContext', () => ({
  useAuthContext: vi.fn(),
}));

describe('useAuth', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns user when authenticated', () => {
    vi.mocked(useAuthContext).mockReturnValue({
      user: { id: '1', email: 'test@example.com' },
      isLoading: false,
    });

    const { result } = renderHook(() => useAuth());
    
    expect(result.current.user).toEqual({
      id: '1',
      email: 'test@example.com',
    });
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('handles loading state', () => {
    vi.mocked(useAuthContext).mockReturnValue({
      user: null,
      isLoading: true,
    });

    const { result } = renderHook(() => useAuth());
    
    expect(result.current.isLoading).toBe(true);
    expect(result.current.isAuthenticated).toBe(false);
  });

  it('refetches user on login', async () => {
    const refetch = vi.fn().mockResolvedValue({ id: '1' });
    vi.mocked(useAuthContext).mockReturnValue({
      user: null,
      refetch,
    });

    const { result } = renderHook(() => useAuth());
    
    await result.current.login('test@example.com', 'password');
    
    expect(refetch).toHaveBeenCalledOnce();
  });
});

Component Testing

组件测试

React Testing Library Patterns

React Testing Library 模式

typescript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import { SearchInput } from './SearchInput';

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

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

  it('disables button when loading', () => {
    render(<Button isLoading>Submit</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('shows spinner when loading', () => {
    render(<Button isLoading>Submit</Button>);
    expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
  });

  it('applies variant styles', () => {
    const { container } = render(<Button variant="primary">Primary</Button>);
    expect(container.firstChild).toHaveClass('bg-blue-600');
  });
});

describe('SearchInput', () => {
  it('debounces search input', async () => {
    const onSearch = vi.fn();
    const user = userEvent.setup();
    
    render(<SearchInput onSearch={onSearch} debounce={300} />);
    const input = screen.getByRole('searchbox');
    
    await user.type(input, 'test query');
    
    // Should not call immediately
    expect(onSearch).not.toHaveBeenCalled();
    
    // Should call after debounce
    await waitFor(
      () => expect(onSearch).toHaveBeenCalledWith('test query'),
      { timeout: 500 }
    );
  });

  it('clears input on clear button click', async () => {
    const user = userEvent.setup();
    
    render(<SearchInput />);
    const input = screen.getByRole('searchbox') as HTMLInputElement;
    
    await user.type(input, 'test');
    expect(input.value).toBe('test');
    
    await user.click(screen.getByRole('button', { name: /clear/i }));
    expect(input.value).toBe('');
  });
});
typescript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import { SearchInput } from './SearchInput';

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

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

  it('disables button when loading', () => {
    render(<Button isLoading>Submit</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('shows spinner when loading', () => {
    render(<Button isLoading>Submit</Button>);
    expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
  });

  it('applies variant styles', () => {
    const { container } = render(<Button variant="primary">Primary</Button>);
    expect(container.firstChild).toHaveClass('bg-blue-600');
  });
});

describe('SearchInput', () => {
  it('debounces search input', async () => {
    const onSearch = vi.fn();
    const user = userEvent.setup();
    
    render(<SearchInput onSearch={onSearch} debounce={300} />);
    const input = screen.getByRole('searchbox');
    
    await user.type(input, 'test query');
    
    // Should not call immediately
    expect(onSearch).not.toHaveBeenCalled();
    
    // Should call after debounce
    await waitFor(
      () => expect(onSearch).toHaveBeenCalledWith('test query'),
      { timeout: 500 }
    );
  });

  it('clears input on clear button click', async () => {
    const user = userEvent.setup();
    
    render(<SearchInput />);
    const input = screen.getByRole('searchbox') as HTMLInputElement;
    
    await user.type(input, 'test');
    expect(input.value).toBe('test');
    
    await user.click(screen.getByRole('button', { name: /clear/i }));
    expect(input.value).toBe('');
  });
});

Testing Async Components

测试异步组件

typescript
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from '@/api/users';

vi.mock('@/api/users');

describe('UserProfile', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('shows loading state initially', () => {
    vi.mocked(api.getUser).mockReturnValue(new Promise(() => {}));
    
    render(<UserProfile userId="1" />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('displays user data when loaded', async () => {
    vi.mocked(api.getUser).mockResolvedValue({
      id: '1',
      name: 'John Doe',
      email: 'john@example.com',
    });
    
    render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });
    
    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });

  it('shows error message on fetch failure', async () => {
    vi.mocked(api.getUser).mockRejectedValue(new Error('Failed to fetch'));
    
    render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
    });
  });
});
typescript
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from '@/api/users';

vi.mock('@/api/users');

describe('UserProfile', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('shows loading state initially', () => {
    vi.mocked(api.getUser).mockReturnValue(new Promise(() => {}));
    
    render(<UserProfile userId="1" />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('displays user data when loaded', async () => {
    vi.mocked(api.getUser).mockResolvedValue({
      id: '1',
      name: 'John Doe',
      email: 'john@example.com',
    });
    
    render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });
    
    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });

  it('shows error message on fetch failure', async () => {
    vi.mocked(api.getUser).mockRejectedValue(new Error('Failed to fetch'));
    
    render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
    });
  });
});

Integration Testing

集成测试

API Route Testing

API路由测试

typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createMocks } from 'node-mocks-http';
import { POST } from '@/app/api/posts/route';
import { prisma } from '@/lib/prisma';
import { createTestUser, cleanupDatabase } from '@/test/helpers';

describe('POST /api/posts', () => {
  let testUser: { id: string; email: string };

  beforeAll(async () => {
    testUser = await createTestUser();
  });

  afterAll(async () => {
    await cleanupDatabase();
  });

  it('creates a new post', async () => {
    const { req } = createMocks({
      method: 'POST',
      body: {
        title: 'Test Post',
        content: 'Test content',
      },
      headers: {
        authorization: `Bearer ${testUser.token}`,
      },
    });

    const response = await POST(req);
    const data = await response.json();

    expect(response.status).toBe(201);
    expect(data).toMatchObject({
      title: 'Test Post',
      content: 'Test content',
      authorId: testUser.id,
    });

    // Verify in database
    const post = await prisma.post.findUnique({
      where: { id: data.id },
    });
    expect(post).toBeTruthy();
  });

  it('validates required fields', async () => {
    const { req } = createMocks({
      method: 'POST',
      body: { title: '' }, // Missing content
      headers: { authorization: `Bearer ${testUser.token}` },
    });

    const response = await POST(req);
    const data = await response.json();

    expect(response.status).toBe(400);
    expect(data.error).toMatchObject({
      fieldErrors: {
        title: expect.arrayContaining([expect.any(String)]),
        content: expect.arrayContaining([expect.any(String)]),
      },
    });
  });

  it('requires authentication', async () => {
    const { req } = createMocks({
      method: 'POST',
      body: { title: 'Test', content: 'Test' },
      // No authorization header
    });

    const response = await POST(req);
    expect(response.status).toBe(401);
  });
});
typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createMocks } from 'node-mocks-http';
import { POST } from '@/app/api/posts/route';
import { prisma } from '@/lib/prisma';
import { createTestUser, cleanupDatabase } from '@/test/helpers';

describe('POST /api/posts', () => {
  let testUser: { id: string; email: string };

  beforeAll(async () => {
    testUser = await createTestUser();
  });

  afterAll(async () => {
    await cleanupDatabase();
  });

  it('creates a new post', async () => {
    const { req } = createMocks({
      method: 'POST',
      body: {
        title: 'Test Post',
        content: 'Test content',
      },
      headers: {
        authorization: `Bearer ${testUser.token}`,
      },
    });

    const response = await POST(req);
    const data = await response.json();

    expect(response.status).toBe(201);
    expect(data).toMatchObject({
      title: 'Test Post',
      content: 'Test content',
      authorId: testUser.id,
    });

    // Verify in database
    const post = await prisma.post.findUnique({
      where: { id: data.id },
    });
    expect(post).toBeTruthy();
  });

  it('validates required fields', async () => {
    const { req } = createMocks({
      method: 'POST',
      body: { title: '' }, // Missing content
      headers: { authorization: `Bearer ${testUser.token}` },
    });

    const response = await POST(req);
    const data = await response.json();

    expect(response.status).toBe(400);
    expect(data.error).toMatchObject({
      fieldErrors: {
        title: expect.arrayContaining([expect.any(String)]),
        content: expect.arrayContaining([expect.any(String)]),
      },
    });
  });

  it('requires authentication', async () => {
    const { req } = createMocks({
      method: 'POST',
      body: { title: 'Test', content: 'Test' },
      // No authorization header
    });

    const response = await POST(req);
    expect(response.status).toBe(401);
  });
});

Database Testing with Fixtures

数据库测试与夹具

typescript
// test/helpers.ts
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';

export const prisma = new PrismaClient();

export async function createTestUser(overrides = {}) {
  const hashedPassword = await hash('password123', 10);
  
  return prisma.user.create({
    data: {
      email: `test-${Date.now()}@example.com`,
      name: 'Test User',
      password: hashedPassword,
      ...overrides,
    },
  });
}

export async function createTestPost(userId: string, overrides = {}) {
  return prisma.post.create({
    data: {
      title: 'Test Post',
      content: 'Test content',
      authorId: userId,
      published: false,
      ...overrides,
    },
  });
}

export async function cleanupDatabase() {
  // Delete in correct order to respect foreign keys
  await prisma.comment.deleteMany();
  await prisma.post.deleteMany();
  await prisma.user.deleteMany();
}
typescript
// test/helpers.ts
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';

export const prisma = new PrismaClient();

export async function createTestUser(overrides = {}) {
  const hashedPassword = await hash('password123', 10);
  
  return prisma.user.create({
    data: {
      email: `test-${Date.now()}@example.com`,
      name: 'Test User',
      password: hashedPassword,
      ...overrides,
    },
  });
}

export async function createTestPost(userId: string, overrides = {}) {
  return prisma.post.create({
    data: {
      title: 'Test Post',
      content: 'Test content',
      authorId: userId,
      published: false,
      ...overrides,
    },
  });
}

export async function cleanupDatabase() {
  // Delete in correct order to respect foreign keys
  await prisma.comment.deleteMany();
  await prisma.post.deleteMany();
  await prisma.user.deleteMany();
}

E2E Testing

E2E测试

Playwright Setup

Playwright配置

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Page Object Pattern

页面对象模式

typescript
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    // Note: expect is imported from @playwright/test
    await expect(this.errorMessage).toContainText(message);
  }
}

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('Authentication', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('test@example.com', 'password123');
    
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome back')).toBeVisible();
  });

  test('invalid credentials show error', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('invalid@example.com', 'wrong');
    
    await loginPage.expectError('Invalid email or password');
  });

  test('preserves redirect after login', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL(/\/login\?redirect=%2Fdashboard/);
    
    const loginPage = new LoginPage(page);
    await loginPage.login('test@example.com', 'password123');
    
    await expect(page).toHaveURL('/dashboard');
  });
});
typescript
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    // Note: expect is imported from @playwright/test
    await expect(this.errorMessage).toContainText(message);
  }
}

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('Authentication', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('test@example.com', 'password123');
    
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome back')).toBeVisible();
  });

  test('invalid credentials show error', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('invalid@example.com', 'wrong');
    
    await loginPage.expectError('Invalid email or password');
  });

  test('preserves redirect after login', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL(/\/login\?redirect=%2Fdashboard/);
    
    const loginPage = new LoginPage(page);
    await loginPage.login('test@example.com', 'password123');
    
    await expect(page).toHaveURL('/dashboard');
  });
});

Mocking Patterns

模拟模式

MSW for API Mocking

MSW用于API模拟

typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: '1',
      email: 'test@example.com',
      name: 'Test User',
    });
  }),

  http.post('/api/posts', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      {
        id: '1',
        ...body,
        createdAt: new Date().toISOString(),
      },
      { status: 201 }
    );
  }),

  http.get('/api/posts/:id', ({ params }) => {
    const { id } = params;
    
    if (id === '404') {
      return new HttpResponse(null, { status: 404 });
    }
    
    return HttpResponse.json({
      id,
      title: 'Test Post',
      content: 'Test content',
    });
  }),
];

// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: '1',
      email: 'test@example.com',
      name: 'Test User',
    });
  }),

  http.post('/api/posts', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      {
        id: '1',
        ...body,
        createdAt: new Date().toISOString(),
      },
      { status: 201 }
    );
  }),

  http.get('/api/posts/:id', ({ params }) => {
    const { id } = params;
    
    if (id === '404') {
      return new HttpResponse(null, { status: 404 });
    }
    
    return HttpResponse.json({
      id,
      title: 'Test Post',
      content: 'Test content',
    });
  }),
];

// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

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

Module Mocking

模块模拟

typescript
import { vi } from 'vitest';
import type * as PrismaModule from '@/lib/prisma';

// Mock entire module
vi.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      findUnique: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
    post: {
      findMany: vi.fn(),
      create: vi.fn(),
    },
  },
}));

// Mock with implementation
vi.mock('@/lib/auth', () => ({
  hashPassword: vi.fn((password: string) => `hashed_${password}`),
  verifyPassword: vi.fn((password: string, hash: string) => {
    return hash === `hashed_${password}`;
  }),
}));

// Partial mock
vi.mock('@/lib/email', async (importOriginal) => {
  const actual = await importOriginal<typeof import('@/lib/email')>();
  return {
    ...actual,
    sendEmail: vi.fn(), // Mock only sendEmail
  };
});
typescript
import { vi } from 'vitest';
import type * as PrismaModule from '@/lib/prisma';

// Mock entire module
vi.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      findUnique: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
    post: {
      findMany: vi.fn(),
      create: vi.fn(),
    },
  },
}));

// Mock with implementation
vi.mock('@/lib/auth', () => ({
  hashPassword: vi.fn((password: string) => `hashed_${password}`),
  verifyPassword: vi.fn((password: string, hash: string) => {
    return hash === `hashed_${password}`;
  }),
}));

// Partial mock
vi.mock('@/lib/email', async (importOriginal) => {
  const actual = await importOriginal<typeof import('@/lib/email')>();
  return {
    ...actual,
    sendEmail: vi.fn(), // Mock only sendEmail
  };
});

Coverage Strategy

覆盖率策略

Target Thresholds

目标阈值

Aim for high coverage on new code:
  • 100% on new code (enforced in CI)
  • 80%+ on existing code (gradual improvement)
  • 100% on critical paths (auth, payment, data integrity)
为新代码设定高覆盖率目标:
  • 新代码100%覆盖率(CI中强制执行)
  • 现有代码80%+覆盖率(逐步提升)
  • 关键路径100%覆盖率(认证、支付、数据完整性)

Coverage Configuration

覆盖率配置

typescript
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json-summary', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/dist/**',
        '**/*.stories.tsx', // Storybook
        '**/types/**',
      ],
      all: true,
      lines: 80,
      functions: 80,
      branches: 80,
      statements: 80,
      // Fail CI if coverage drops
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});
typescript
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json-summary', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/dist/**',
        '**/*.stories.tsx', // Storybook
        '**/types/**',
      ],
      all: true,
      lines: 80,
      functions: 80,
      branches: 80,
      statements: 80,
      // Fail CI if coverage drops
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

Identifying Coverage Gaps

识别覆盖率缺口

Use precision tools to find untested code:
yaml
undefined
使用精准工具查找未测试代码:
yaml
undefined

Find files missing tests

Find files missing tests

precision_exec: commands: - cmd: "npm run test:coverage -- --reporter=json-summary" expect: exit_code: 0
precision_exec: commands: - cmd: "npm run test:coverage -- --reporter=json-summary" expect: exit_code: 0

Parse coverage report

Parse coverage report

precision_read: files: - path: "coverage/coverage-summary.json" extract: content verbosity: minimal
precision_read: files: - path: "coverage/coverage-summary.json" extract: content verbosity: minimal

Find source files without corresponding tests

Find source files without corresponding tests

discover: queries: - id: source-files type: glob patterns: ["src//*.{ts,tsx}"] exclude: ["/.test.", "/.spec."] - id: test-files type: glob patterns: ["/*.test.{ts,tsx}"] output_mode: files_only
undefined
discover: queries: - id: source-files type: glob patterns: ["src//*.{ts,tsx}"] exclude: ["/.test.", "/.spec."] - id: test-files type: glob patterns: ["/*.test.{ts,tsx}"] output_mode: files_only
undefined

CI Integration

CI集成

GitHub Actions Example

GitHub Actions示例

yaml
undefined
yaml
undefined

.github/workflows/test.yml

.github/workflows/test.yml

name: Tests
on: push: branches: [main] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest
steps:
  - uses: actions/checkout@v4
  
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'
  
  - run: npm ci
  
  - name: Run unit tests
    run: npm run test:coverage
  
  - name: Upload coverage
    uses: codecov/codecov-action@v4
    with:
      files: ./coverage/coverage-final.json
  
  - name: Run E2E tests
    run: npx playwright test
  
  - name: Upload test results
    if: always()
    uses: actions/upload-artifact@v4
    with:
      name: playwright-report
      path: playwright-report/
      retention-days: 30
undefined
name: Tests
on: push: branches: [main] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest
steps:
  - uses: actions/checkout@v4
  
  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'
  
  - run: npm ci
  
  - name: Run unit tests
    run: npm run test:coverage
  
  - name: Upload coverage
    uses: codecov/codecov-action@v4
    with:
      files: ./coverage/coverage-final.json
  
  - name: Run E2E tests
    run: npx playwright test
  
  - name: Upload test results
    if: always()
    uses: actions/upload-artifact@v4
    with:
      name: playwright-report
      path: playwright-report/
      retention-days: 30
undefined

Parallel Test Execution

并行测试执行

typescript
// vitest.config.ts - parallel by default
export default defineConfig({
  test: {
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
        maxThreads: 8,
      },
    },
  },
});

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 1 : 4, // Limit parallelism in CI
  fullyParallel: true,
});
typescript
// vitest.config.ts - parallel by default
export default defineConfig({
  test: {
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
        maxThreads: 8,
      },
    },
  },
});

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 1 : 4, // Limit parallelism in CI
  fullyParallel: true,
});

Precision Tool Integration

精准工具集成

Running Tests with Expectations

带预期的测试运行

yaml
undefined
yaml
undefined

Run tests and validate output

Run tests and validate output

precision_exec: commands: - cmd: "npm run test -- --run" expect: exit_code: 0
- cmd: "npm run typecheck"
  expect:
    exit_code: 0

- cmd: "npm run test:coverage -- --run"
  expect:
    exit_code: 0
    stdout_contains: "All files"
verbosity: minimal
undefined
precision_exec: commands: - cmd: "npm run test -- --run" expect: exit_code: 0
- cmd: "npm run typecheck"
  expect:
    exit_code: 0

- cmd: "npm run test:coverage -- --run"
  expect:
    exit_code: 0
    stdout_contains: "All files"
verbosity: minimal
undefined

Batch Test Validation

批量测试验证

yaml
batch:
  id: validate-tests
  operations:
    query:
      - id: find-skipped
        type: grep
        pattern: "\\.skip|it\\.only|describe\\.only"
        glob: "**/*.test.ts"
        output:
          format: count_only
    
    exec:
      - id: run-tests
        type: command
        commands:
          - cmd: "npm run test -- --run"
            expect: { exit_code: 0 }
      
      - id: check-coverage
        type: command
        commands:
          - cmd: "npm run test:coverage -- --run"
            expect:
              exit_code: 0
              stdout_contains: "All files"
  
  config:
    execution:
      mode: sequential
yaml
batch:
  id: validate-tests
  operations:
    query:
      - id: find-skipped
        type: grep
        pattern: "\\.skip|it\\.only|describe\\.only"
        glob: "**/*.test.ts"
        output:
          format: count_only
    
    exec:
      - id: run-tests
        type: command
        commands:
          - cmd: "npm run test -- --run"
            expect: { exit_code: 0 }
      
      - id: check-coverage
        type: command
        commands:
          - cmd: "npm run test:coverage -- --run"
            expect:
              exit_code: 0
              stdout_contains: "All files"
  
  config:
    execution:
      mode: sequential

Debugging Flaky Tests

调试不稳定的测试

Common Causes

常见原因

  1. Race conditions: Use
    waitFor
    for async operations
  2. Time-dependent tests: Mock timers with
    vi.useFakeTimers()
  3. Test isolation: Ensure tests don't share state
  4. Network requests: Mock with MSW, don't rely on real APIs
  5. Random data: Use deterministic test data or seed random generators
  1. 竞态条件:对异步操作使用
    waitFor
  2. 时间相关测试:用
    vi.useFakeTimers()
    模拟定时器
  3. 测试隔离:确保测试不共享状态
  4. 网络请求:用MSW模拟,不要依赖真实API
  5. 随机数据:使用确定性测试数据或为随机生成器设置种子

Flaky Test Patterns

不稳定测试模式

typescript
import { vi, beforeEach, afterEach } from 'vitest';

// Mock timers for time-dependent code
beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.restoreAllMocks();
});

it('debounces function calls', async () => {
  const callback = vi.fn();
  const debounced = debounce(callback, 1000);
  
  debounced('test');
  expect(callback).not.toHaveBeenCalled();
  
  vi.advanceTimersByTime(1000);
  expect(callback).toHaveBeenCalledWith('test');
});

// Ensure test isolation
beforeEach(async () => {
  await cleanupDatabase();
  vi.clearAllMocks();
});

// Use deterministic data
it('sorts users by creation date', () => {
  const users = [
    { id: '1', createdAt: new Date('2024-01-01') },
    { id: '2', createdAt: new Date('2024-01-02') },
  ];
  
  const sorted = sortByDate(users);
  expect(sorted[0].id).toBe('2');
});
typescript
import { vi, beforeEach, afterEach } from 'vitest';

// Mock timers for time-dependent code
beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.restoreAllMocks();
});

it('debounces function calls', async () => {
  const callback = vi.fn();
  const debounced = debounce(callback, 1000);
  
  debounced('test');
  expect(callback).not.toHaveBeenCalled();
  
  vi.advanceTimersByTime(1000);
  expect(callback).toHaveBeenCalledWith('test');
});

// Ensure test isolation
beforeEach(async () => {
  await cleanupDatabase();
  vi.clearAllMocks();
});

// Use deterministic data
it('sorts users by creation date', () => {
  const users = [
    { id: '1', createdAt: new Date('2024-01-01') },
    { id: '2', createdAt: new Date('2024-01-02') },
  ];
  
  const sorted = sortByDate(users);
  expect(sorted[0].id).toBe('2');
});

Best Practices

最佳实践

  1. Write tests first (TDD) for new features
  2. Test behavior, not implementation - focus on what, not how
  3. One assertion per test when possible for clarity
  4. Use descriptive test names - "it does X when Y"
  5. Avoid mocking everything - integration tests need real collaborators
  6. Keep tests fast - unit tests <100ms, integration <1s
  7. Test edge cases - null, undefined, empty arrays, boundary values
  8. Don't test framework code - test your logic, not React/Vue/etc.
  9. Maintain test fixtures - keep test data realistic and up-to-date
  10. Review test coverage - 100% coverage doesn't mean bug-free
  1. 先写测试(TDD)用于新功能
  2. 测试行为而非实现 - 关注“做什么”,而非“怎么做”
  3. 尽可能每个测试一个断言以提升清晰度
  4. 使用描述性测试名称 - “当Y时,它会做X”
  5. 不要模拟所有内容 - 集成测试需要真实的协作对象
  6. 保持测试快速 - 单元测试<100ms,集成测试<1s
  7. 测试边缘情况 - null、undefined、空数组、边界值
  8. 不要测试框架代码 - 测试你的逻辑,而非React/Vue等
  9. 维护测试夹具 - 保持测试数据真实且最新
  10. 审查测试覆盖率 - 100%覆盖率不代表没有bug

Common Anti-Patterns

常见反模式

[X] Testing Implementation Details

[X] 测试实现细节

typescript
// BAD - tests internal state
it('sets loading to true', () => {
  const { result } = renderHook(() => useUsers());
  expect(result.current.loading).toBe(true);
});

// GOOD - tests user-visible behavior
it('shows loading spinner while fetching', () => {
  render(<UserList />);
  expect(screen.getByRole('status')).toBeInTheDocument();
});
typescript
// BAD - tests internal state
it('sets loading to true', () => {
  const { result } = renderHook(() => useUsers());
  expect(result.current.loading).toBe(true);
});

// GOOD - tests user-visible behavior
it('shows loading spinner while fetching', () => {
  render(<UserList />);
  expect(screen.getByRole('status')).toBeInTheDocument();
});

[X] Overmocking

[X] 过度模拟

typescript
// BAD - mocks everything, tests nothing
vi.mock('./api');
vi.mock('./utils');
vi.mock('./hooks');

// GOOD - mock only external dependencies
vi.mock('axios'); // Mock HTTP client
// Let your code run for real
typescript
// BAD - mocks everything, tests nothing
vi.mock('./api');
vi.mock('./utils');
vi.mock('./hooks');

// GOOD - mock only external dependencies
vi.mock('axios'); // Mock HTTP client
// Let your code run for real

[X] Brittle Selectors

[X] 脆弱的选择器

typescript
// BAD - breaks when styling changes
const button = container.querySelector('.btn-primary');

// GOOD - uses accessible queries
const button = screen.getByRole('button', { name: /submit/i });
typescript
// BAD - breaks when styling changes
const button = container.querySelector('.btn-primary');

// GOOD - uses accessible queries
const button = screen.getByRole('button', { name: /submit/i });

Summary

总结

Effective testing requires:
  • Organized test files co-located with source code
  • Comprehensive unit tests for business logic and utilities
  • User-centric component tests with React Testing Library
  • Realistic integration tests for API routes and database operations
  • End-to-end tests for critical user flows
  • API mocking with MSW for deterministic tests
  • High coverage targets (100% on new code, 80%+ overall)
  • CI integration with parallel execution and coverage reporting
  • Flaky test prevention through proper mocking and isolation
Use precision tools to discover test gaps, run tests efficiently, and validate implementation quality.
有效的测试需要:
  • 有组织的测试文件与源代码同目录存放
  • 全面的单元测试用于业务逻辑和工具类
  • 以用户为中心的组件测试使用React Testing Library
  • 真实的集成测试用于API路由和数据库操作
  • 端到端测试用于关键用户流程
  • API模拟使用MSW实现确定性测试
  • 高覆盖率目标(新代码100%,整体80%+)
  • CI集成支持并行执行和覆盖率报告
  • 不稳定测试预防通过正确的模拟和隔离
使用精准工具发现测试缺口、高效运行测试并验证实现质量。