Skip to content

TDD 与测试策略

概述

测试驱动开发(TDD)和合理的测试策略是保证代码质量的关键实践。

一、测试金字塔

        /\
       /  \
      / E2E \        少量:关键用户流程
     /------\
    /  集成  \       中等:模块间交互
   /----------\
  /    单元    \     大量:独立函数/组件
 /--------------\

各层职责

层级数量速度成本关注点
E2E~10%用户流程
集成~20%模块协作
单元~70%独立逻辑

前端测试金字塔变体

        /\
       /  \
      / E2E \              Playwright/Cypress
     /------\
    / 组件集成 \            RTL + MSW
   /----------\
  / 组件单元   \           RTL
 /--------------\
/   函数单元    \          Jest/Vitest

二、TDD 红绿重构

核心循环

    ┌──────────┐
    │  RED     │  写一个失败的测试
    └────┬─────┘

    ┌────▼─────┐
    │  GREEN   │  用最简单的代码通过测试
    └────┬─────┘

    ┌────▼─────┐
    │ REFACTOR │  重构代码,保持测试通过
    └────┬─────┘

         └──────── 循环

TDD 示例

javascript
// 需求:实现一个购物车折扣计算器
// 规则:满 100 减 10,满 200 减 30

// Step 1: RED - 写失败的测试
test('no discount for total under 100', () => {
  expect(calculateDiscount(50)).toBe(0);
});

// Step 2: GREEN - 最简实现
function calculateDiscount(total) {
  return 0;
}

// Step 3: RED - 下一个测试
test('10 discount for total >= 100', () => {
  expect(calculateDiscount(100)).toBe(10);
  expect(calculateDiscount(150)).toBe(10);
});

// Step 4: GREEN - 扩展实现
function calculateDiscount(total) {
  if (total >= 100) return 10;
  return 0;
}

// Step 5: RED - 下一个测试
test('30 discount for total >= 200', () => {
  expect(calculateDiscount(200)).toBe(30);
  expect(calculateDiscount(300)).toBe(30);
});

// Step 6: GREEN - 完成实现
function calculateDiscount(total) {
  if (total >= 200) return 30;
  if (total >= 100) return 10;
  return 0;
}

// Step 7: REFACTOR - 重构
const DISCOUNT_TIERS = [
  { threshold: 200, discount: 30 },
  { threshold: 100, discount: 10 },
];

function calculateDiscount(total) {
  const tier = DISCOUNT_TIERS.find(t => total >= t.threshold);
  return tier?.discount ?? 0;
}

三、测试策略矩阵

按功能类型选择测试类型

功能类型推荐测试示例
纯函数/工具单元测试formatDate, calculateTotal
UI 组件组件测试 (RTL)Button, Form
状态逻辑Hook 测试useAuth, useCart
API 集成集成测试 (MSW)fetchUser
用户流程E2E登录→购物→支付
样式/布局视觉回归响应式布局

测试覆盖策略

javascript
// 高覆盖优先级
// 1. 核心业务逻辑
// 2. 公共组件库
// 3. 关键用户路径
// 4. 边界条件

// 低覆盖优先级
// 1. 第三方库封装
// 2. 简单展示组件
// 3. 样式细节
// 4. 实验性功能

四、测试最佳实践

1. 测试隔离

javascript
// ❌ 测试间共享状态
let counter = 0;

test('test 1', () => {
  counter++;
  expect(counter).toBe(1);
});

test('test 2', () => {
  counter++;
  expect(counter).toBe(1); // 失败!
});

// ✅ 每个测试独立
let counter;

beforeEach(() => {
  counter = 0;
});

test('test 1', () => {
  counter++;
  expect(counter).toBe(1);
});

test('test 2', () => {
  counter++;
  expect(counter).toBe(1); // 通过
});

2. 避免测试实现细节

javascript
// ❌ 测试实现细节
test('sets isLoading state', () => {
  const { result } = renderHook(() => useData());
  expect(result.current.isLoading).toBe(true);
});

// ✅ 测试用户可见行为
test('shows loading indicator', async () => {
  render(<DataComponent />);
  expect(screen.getByRole('progressbar')).toBeInTheDocument();
});

3. 使用测试工厂

javascript
// test-factories.js
export const createUser = (overrides = {}) => ({
  id: 1,
  name: 'Test User',
  email: 'test@example.com',
  role: 'user',
  ...overrides
});

export const createProduct = (overrides = {}) => ({
  id: 1,
  name: 'Test Product',
  price: 100,
  stock: 10,
  ...overrides
});

// 使用
test('admin can delete user', () => {
  const admin = createUser({ role: 'admin' });
  const target = createUser({ id: 2 });
  // ...
});

4. 避免过度 Mock

javascript
// ❌ Mock 一切
jest.mock('./api');
jest.mock('./utils');
jest.mock('./config');

// ✅ 只 Mock 边界
// - 外部 API
// - 时间/随机数
// - 文件系统
jest.mock('./api'); // API 是边界
// utils 和 config 使用真实实现

五、覆盖率策略

合理的覆盖率目标

javascript
// jest.config.js
module.exports = {
  coverageThreshold: {
    // 全局阈值
    global: {
      branches: 70,
      functions: 80,
      lines: 80,
      statements: 80
    },
    // 关键模块更高标准
    './src/core/**: {
      branches: 90,
      functions: 95,
      lines: 95
    },
    // 工具类可以放宽
    './src/utils/**': {
      lines: 60
    }
  }
};

覆盖率陷阱

javascript
// ❌ 追求 100% 覆盖率的无意义测试
test('getter returns value', () => {
  const obj = { get name() { return 'test'; } };
  expect(obj.name).toBe('test');
});

// ✅ 关注有意义的覆盖
test('handles edge case', () => {
  expect(divide(10, 0)).toThrow('Division by zero');
});

六、持续集成测试

测试流水线阶段

yaml
# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run test:unit -- --coverage
      - uses: codecov/codecov-action@v3

  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run test:integration

  e2e-test:
    runs-on: ubuntu-latest
    needs: integration-test
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:e2e

PR 检查策略

检查项阻断 PR可选
单元测试通过
覆盖率不下降
集成测试通过
E2E 测试通过✅ (耗时)
视觉回归

七、测试组织结构

目录结构选项

# 选项 1:与源码同级
src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   └── Button.stories.tsx
│   └── Form/
│       ├── Form.tsx
│       └── Form.test.tsx

# 选项 2:独立测试目录
src/
├── components/
│   ├── Button.tsx
│   └── Form.tsx
tests/
├── unit/
│   ├── Button.test.tsx
│   └── Form.test.tsx
├── integration/
│   └── checkout.test.tsx
└── e2e/
    └── purchase-flow.spec.ts

面试高频题

Q1: TDD 的优缺点?

优点:

  1. 更好的代码设计(可测试性强)
  2. 减少过度设计
  3. 内置回归测试
  4. 提高开发信心

缺点:

  1. 初期开发速度变慢
  2. 需要团队纪律
  3. 某些场景不适用(探索性开发)

Q2: 如何制定测试策略?

:

  1. 分析业务关键路径
  2. 确定各层测试比例(金字塔)
  3. 设置合理的覆盖率目标
  4. 建立 CI 自动化流程
  5. 定期回顾和调整

Q3: 什么时候不写测试?

:

  1. 原型/POC 阶段
  2. 第三方库的简单封装
  3. 纯展示组件(只有 props 到 UI)
  4. 即将废弃的代码
  5. 时间极其紧迫(但要技术债记录)

Q4: 如何说服团队采用 TDD?

:

  1. 从小范围试点开始
  2. 展示 bug 减少的数据
  3. 强调重构的安全性
  4. 计算长期维护成本节省
  5. 结合 Code Review 推广

前端面试知识库