测试模式 testing-patterns

测试模式是一门关于软件测试的方法论技能,提供全面的测试策略,涵盖单元测试、集成测试、端到端测试和视觉回归测试。内容包括测试哲学、框架选择、模拟技术和持续集成,帮助开发者在全栈中编写有效测试,提升软件质量和开发效率。关键词:测试模式、单元测试、集成测试、端到端测试、测试覆盖率、TDD、质量保证、软件测试、测试策略。

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

名称: 测试模式 描述: 在全栈中编写有效的测试——单元测试、集成测试、端到端测试和视觉回归测试。涵盖测试哲学、框架选择、模拟策略和CI集成。触发于测试、测试覆盖率、TDD或质量保证请求。 许可证: MIT 复杂度: 初学者 学习时间: 30分钟 标签:

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

测试模式

通过战略测试建立信心。

测试哲学

测试奖杯

        ╱╲
       ╱  ╲        端到端测试(少)
      ╱────╲       
     ╱      ╲      集成测试(多)
    ╱────────╲     
   ╱          ╲    单元测试(多,快)
  ╱────────────╲   
 ╱   静态测试    ╲  TypeScript, ESLint
╱────────────────╲

测试什么

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

不测试什么

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

单元测试

结构:AAA模式

describe('calculateTotal', () => {
  it('应将折扣应用于小计', () => {
    // 安排
    const items = [{ price: 100 }, { price: 50 }];
    const discount = 0.1;
    
    // 执行
    const result = calculateTotal(items, discount);
    
    // 断言
    expect(result).toBe(135);
  });
});

测试命名

// 模式:应该 [预期行为] 当 [条件]

it('当输入为null时应返回空数组')
it('当用户未认证时应抛出错误')
it('当优惠券有效时应应用折扣')

测试纯函数

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

// utils/format.test.ts
describe('formatCurrency', () => {
  it('应将美分格式化为美元字符串', () => {
    expect(formatCurrency(1000)).toBe('$10.00');
    expect(formatCurrency(1)).toBe('$0.01');
    expect(formatCurrency(0)).toBe('$0.00');
  });

  it('应处理负值', () => {
    expect(formatCurrency(-500)).toBe('$-5.00');
  });
});

考虑的边界情况

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

React组件测试

测试库哲学

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

组件测试结构

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

describe('Counter', () => {
  it('点击按钮时应增加计数', 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('点击按钮后应加载数据', 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('应返回帖子列表', 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('用户服务', () => {
  beforeEach(async () => {
    await db.user.deleteMany(); // 清理状态
  });

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

  it('应使用有效数据创建用户', 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('认证', () => {
  test('应允许用户登录', 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('应成功登录', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password');
  
  await expect(page).toHaveURL('/dashboard');
});

视觉回归

test('主页应匹配快照', 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% 与速度平衡

高覆盖率不意味着什么

  • 测试是好的
  • 没有bug
  • 代码可维护
  • 边界情况已覆盖

CI集成

# .github/workflows/test.yml
name: 测试
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 - 模拟配方