测试模式 testing-patterns

这个技能是关于软件测试的全面模式和方法,涵盖单元测试、集成测试、端到端测试和视觉回归测试,提供测试哲学、框架选择、模拟策略和CI集成的最佳实践。它帮助开发者和测试工程师编写高效、可维护的测试,提升代码质量和软件可靠性。关键词包括:测试模式、单元测试、集成测试、端到端测试、模拟策略、CI/CD、Vitest、Playwright、测试覆盖率。

测试 0 次安装 0 次浏览 更新于 3/7/2026

name: 测试模式 description: 通过战略性测试构建信心——涵盖单元测试、集成测试、端到端测试和视觉回归测试。包括测试哲学、框架选择、模拟策略和CI集成。当涉及测试、测试覆盖率、TDD或质量保证请求时触发。 license: MIT complexity: 初学者 time_to_learn: 30分钟 tags:

  • 测试
  • 单元测试
  • 集成测试
  • 端到端测试
  • vitest
  • playwright
  • 模拟

测试模式

通过战略性测试构建信心。

测试哲学

测试奖杯

        ╱╲
       ╱  ╲        端到端测试(少量)
      ╱────╲       
     ╱      ╲      集成测试(更多)
    ╱────────╲     
   ╱          ╲    单元测试(大量、快速)
  ╱────────────╲   
 ╱   静态测试     ╲  TypeScript、ESLint
╱────────────────╲

测试什么

测试类型 内容 原因
静态测试 类型、lint规则 在编写时捕获错误
单元测试 纯函数、工具函数 快速、精确的反馈
集成测试 组件 + 依赖项 测试合约
端到端测试 用户流程 真实使用中的信心

不测试什么

  • 实现细节(内部状态、私有方法)
  • 第三方库内部
  • 常量和配置
  • 框架代码

单元测试

结构:AAA模式

describe('calculateTotal', () => {
  it('should apply discount to subtotal', () => {
    // Arrange
    const items = [{ price: 100 }, { price: 50 }];
    const discount = 0.1;
    
    // Act
    const result = calculateTotal(items, discount);
    
    // Assert
    expect(result).toBe(135);
  });
});

测试命名

// 模式:should [预期行为] when [条件]

it('should return empty array when input is null')
it('should throw error when user is not authenticated')
it('should apply discount when coupon is valid')

测试纯函数

// utils/format.ts
export function formatCurrency(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

// utils/format.test.ts
describe('formatCurrency', () => {
  it('should format cents to dollar string', () => {
    expect(formatCurrency(1000)).toBe('$10.00');
    expect(formatCurrency(1)).toBe('$0.01');
    expect(formatCurrency(0)).toBe('$0.00');
  });

  it('should handle negative values', () => {
    expect(formatCurrency(-500)).toBe('$-5.00');
  });
});

需要考虑的边缘情况

  • 空/null/undefined输入
  • 边界值(0, -1, MAX_INT)
  • 空数组/对象
  • 无效类型(如果不使用TypeScript)
  • 异步边缘情况(竞态条件、超时)

React组件测试

测试库哲学

“你的测试越像软件使用方式,它们能给你的信心就越多。”

组件测试结构

import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';

describe('Counter', () => {
  it('should increment count when button clicked', async () => {
    render(<Counter initialCount={0} />);
    
    const button = screen.getByRole('button', { name: /increment/i });
    await fireEvent.click(button);
    
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

查询优先级

按此顺序使用查询(从最优先到最不优先):

  1. getByRole - 对所有人可访问
  2. getByLabelText - 表单字段
  3. getByPlaceholderText - 如果没有标签
  4. getByText - 非交互内容
  5. getByTestId - 最后手段

异步测试

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

it('should load data after button click', async () => {
  const user = userEvent.setup();
  render(<DataLoader />);
  
  await user.click(screen.getByRole('button', { name: /load/i }));
  
  // 等待异步内容
  await waitFor(() => {
    expect(screen.getByText('Data loaded')).toBeInTheDocument();
  });
});

模拟

// 模拟模块
vi.mock('./api', () => ({
  fetchUser: vi.fn(() => Promise.resolve({ name: 'Test User' })),
}));

// 模拟钩子
vi.mock('./useAuth', () => ({
  useAuth: () => ({ user: { id: '1' }, isLoading: false }),
}));

// 模拟fetch
global.fetch = vi.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ data: 'test' }),
  })
);

集成测试

API路由测试

import { createMocks } from 'node-mocks-http';
import handler from './api/posts';

describe('/api/posts', () => {
  it('should return posts list', async () => {
    const { req, res } = createMocks({
      method: 'GET',
    });

    await handler(req, res);

    expect(res._getStatusCode()).toBe(200);
    expect(JSON.parse(res._getData())).toHaveLength(3);
  });
});

数据库集成

import { db } from '@/lib/db';

describe('User service', () => {
  beforeEach(async () => {
    await db.user.deleteMany(); // 清理状态
  });

  afterAll(async () => {
    await db.$disconnect();
  });

  it('should create user with valid data', async () => {
    const user = await createUser({ 
      email: 'test@example.com',
      name: 'Test User' 
    });

    expect(user.id).toBeDefined();
    expect(user.email).toBe('test@example.com');
  });
});

端到端测试

Playwright设置

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

test.describe('Authentication', () => {
  test('should allow user to sign in', async ({ page }) => {
    await page.goto('/login');
    
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Welcome');
  });
});

页面对象模式

// e2e/pages/login.page.ts
export class LoginPage {
  constructor(private page: Page) {}

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

  async login(email: string, password: string) { <!-- allow-secret -->
    await this.page.fill('[name="email"]', email);
    await this.page.fill('[name="password"]', password);
    await this.page.click('button[type="submit"]');
  }
}

// e2e/auth.spec.ts
test('should login successfully', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password');
  
  await expect(page).toHaveURL('/dashboard');
});

视觉回归测试

test('homepage should match snapshot', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

模拟策略

何时模拟

模拟对象 时机
外部API 在单元/集成测试中总是模拟
数据库 有时模拟(测试容器 vs 模拟)
时间/日期 测试依赖时间的逻辑时
随机性 测试确定性输出时
网络 在单元测试中总是模拟

MSW(模拟服务工作者)

// mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: 'John' },
        { id: 2, name: 'Jane' },
      ])
    );
  }),
  
  rest.post('/api/users', async (req, res, ctx) => {
    const body = await req.json();
    return res(ctx.status(201), ctx.json({ id: 3, ...body }));
  }),
];

测试配置

Vitest配置

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    coverage: {
      reporter: ['text', 'html'],
      exclude: ['node_modules/', 'test/'],
    },
  },
});

测试设置

// test/setup.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';

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

覆盖率策略

有意义的覆盖率

类型 目标 注意事项
语句覆盖率 70-80% 不要追求100%
分支覆盖率 70-80% 测试重要路径
函数覆盖率 80%+ 所有公共API
行覆盖率 70-80% 平衡与开发速度

高覆盖率不代表什么

  • 测试是好的
  • 没有错误
  • 代码可维护
  • 边缘情况已覆盖

CI集成

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

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
      - run: npm run test:coverage
      
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info


相关技能

互补技能(一起使用)

  • tdd-workflow - 测试驱动开发工作流;与测试模式结合使用以实现完整TDD实践
  • verification-loop - 使用测试作为质量门的迭代验证过程
  • deployment-cicd - 在流水线中运行测试的CI/CD集成

替代技能(相似目的)

  • webapp-testing - 专注于浏览器自动化的专项Web应用程序测试

先决技能(先学习)

  • 无必需技能 - 这是一个基础技能

参考文献

  • references/vitest-patterns.md - Vitest特定模式
  • references/playwright-patterns.md - 端到端测试模式
  • references/mock-examples.md - 模拟示例