Engineering Quality

测试策略实战:从单元测试到 E2E 的完整金字塔

为什么你的测试总是跟不上代码变更?分享我团队 3 年迭代出的测试策略,包括测试金字塔实践、Mock 的艺术、以及如何让测试成为开发加速器而非负担。

Ioodu · · Updated: Feb 14, 2026 · 22 min read
#Testing #TDD #Unit Test #Integration Test #E2E #Quality Assurance

那个让团队停摆的 Bug

2023 年 10 月,凌晨 3 点。

支付系统突然崩溃,订单无法完成。我们花了 6 小时才定位问题:一个两周前的重构改动了订单状态机,但只更新了代码,没更新对应的单元测试。测试通过了,但生产环境炸了。

事后复盘,我问团队:“我们的测试覆盖率 85%,为什么没 catch 住这个问题?”

答案是:我们测了数量,没测质量。

从那以后,我们彻底重构了测试策略。这篇文章分享我们 3 年迭代出的方法论。

测试金字塔:被误解的经典

你可能听过测试金字塔:

        /\
       /  \     E2E (少量)
      /____\
     /      \   集成测试 (中等)
    /        \
   /__________\ 单元测试 (大量)

但问题在于:大多数人只画出了形状,没理解背后的原理。

金字塔的真正含义

层级成本速度稳定性定位问题目标
单元测试快 (< 100ms)精确验证逻辑正确性
集成测试中 (< 1s)模块级验证模块协作
E2E 测试慢 (> 5s)系统级验证用户场景

核心原则:用最低成本发现最多问题。

单元测试:打好基础

单元测试的 FIRST 原则

  • Fast:快速执行,毫秒级
  • Independent:测试之间不依赖
  • Repeatable:任何环境结果一致
  • Self-validating:自动断言,无需人工检查
  • Timely:及时编写(最好先于代码)

测试什么?不测试什么?

测试这些

  • 业务逻辑和算法
  • 状态转换
  • 边界条件
  • 错误处理

不测试这些

  • 框架自带功能(React 渲染、Express 路由)
  • 第三方库(moment.js、lodash)
  • 纯 getter/setter
  • 简单的数据转换

好的单元测试示例

// 购物车服务 - 业务逻辑
class CartService {
  calculateTotal(items: CartItem[]): Total {
    const subtotal = items.reduce((sum, item) => {
      return sum + item.price * item.quantity;
    }, 0);

    const discount = this.calculateDiscount(items, subtotal);
    const tax = (subtotal - discount) * 0.08;
    const total = subtotal - discount + tax;

    return { subtotal, discount, tax, total };
  }

  private calculateDiscount(
    items: CartItem[],
    subtotal: number
  ): number {
    // 满 100 减 10
    if (subtotal >= 100) {
      return Math.floor(subtotal / 100) * 10;
    }
    return 0;
  }
}

// ✅ 好的测试:关注业务规则
describe('CartService', () => {
  describe('calculateTotal', () => {
    it('应该正确计算小计', () => {
      const items = [
        { id: '1', price: 50, quantity: 2 },
        { id: '2', price: 30, quantity: 1 }
      ];

      const result = cartService.calculateTotal(items);

      expect(result.subtotal).toBe(130); // 50*2 + 30*1
    });

    it('满 100 应该应用折扣', () => {
      const items = [
        { id: '1', price: 60, quantity: 2 } // 120
      ];

      const result = cartService.calculateTotal(items);

      expect(result.discount).toBe(10); // 满 100 减 10
      expect(result.total).toBe(118.8); // (120-10)*1.08
    });

    it('未满 100 不应有折扣', () => {
      const items = [
        { id: '1', price: 40, quantity: 2 } // 80
      ];

      const result = cartService.calculateTotal(items);

      expect(result.discount).toBe(0);
      expect(result.total).toBe(86.4); // 80*1.08
    });

    it('空购物车应该返回 0', () => {
      const result = cartService.calculateTotal([]);

      expect(result).toEqual({
        subtotal: 0,
        discount: 0,
        tax: 0,
        total: 0
      });
    });
  });
});

Mock 的艺术

何时 Mock

  • 外部依赖(数据库、API、文件系统)
  • 时间相关逻辑
  • 随机数生成

何时不 Mock

  • 纯函数(无副作用的函数)
  • 值对象(DTO、Entity)
  • 测试目标本身

Mock 陷阱

// ❌ 不好的测试:过度 Mock
class OrderService {
  async createOrder(userId: string, items: Item[]) {
    const user = await this.userService.getUser(userId); // Mock
    const price = this.pricingService.calculate(items); // Mock
    const discount = this.discountService.getDiscount(user); // Mock
    const order = { user, items, price, discount }; // 测的全是 Mock
    await this.orderRepository.save(order); // Mock
    return order;
  }
}

describe('OrderService', () => {
  it('应该创建订单', async () => {
    userService.getUser.mockResolvedValue({ id: '1' });
    pricingService.calculate.mockReturnValue(100);
    discountService.getDiscount.mockReturnValue(10);

    const order = await orderService.createOrder('1', items);

    expect(order.price).toBe(100); // 测的是 Mock 的返回值
  });
});

问题:这个测试测的是 Mock,不是真实逻辑。如果 pricingService.calculate 的实现变了,测试仍然通过,但生产环境可能出错。

改进

// ✅ 更好的做法:直接测试计算逻辑,或测试集成
describe('OrderService', () => {
  describe('价格计算', () => {
    it('应该正确计算订单总价', () => {
      const items = [
        { price: 50, quantity: 2 },
        { price: 30, quantity: 1 }
      ];

      // 直接测试计算逻辑,不通过 Mock
      const total = calculateOrderTotal(items);

      expect(total).toBe(130);
    });
  });

  describe('集成测试', () => {
    it('应该完整创建订单并保存到数据库', async () => {
      // 使用真实数据库(测试实例)
      const order = await orderService.createOrder('1', items);

      // 验证数据库状态
      const savedOrder = await db.orders.findById(order.id);
      expect(savedOrder).toEqual(expect.objectContaining({
        userId: '1',
        status: 'pending'
      }));
    });
  });
});

集成测试:验证协作

集成测试的范围

集成测试验证多个模块协同工作,但不涉及外部真实系统(数据库用测试实例,API 用 Mock Server)。

// 集成测试:API 路由 + 服务 + 数据库
describe('POST /api/orders', () => {
  beforeAll(async () => {
    // 使用测试数据库
    await db.connect(process.env.TEST_DATABASE_URL);
  });

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

  beforeEach(async () => {
    // 每个测试前清理数据
    await db.orders.deleteMany();
    await db.users.deleteMany();
  });

  it('应该创建订单并返回 201', async () => {
    // 准备测试数据
    const user = await db.users.create({
      name: 'Test User',
      email: 'test@example.com'
    });

    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        userId: user.id,
        items: [
          { productId: '1', quantity: 2, price: 50 }
        ]
      });

    // 验证响应
    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('id');
    expect(response.body.status).toBe('pending');

    // 验证数据库状态
    const order = await db.orders.findById(response.body.id);
    expect(order).toBeTruthy();
    expect(order.total).toBe(100);
  });

  it('库存不足应该返回 400', async () => {
    const user = await db.users.create({...});

    // Mock 库存服务返回不足
    inventoryService.checkAvailability.mockResolvedValue({
      available: false,
      missing: ['1']
    });

    const response = await request(app)
      .post('/api/orders')
      .send({ userId: user.id, items: [...] });

    expect(response.status).toBe(400);
    expect(response.body.error).toContain('库存不足');

    // 验证数据库没有创建订单
    const orders = await db.orders.find({ userId: user.id });
    expect(orders).toHaveLength(0);
  });
});

测试数据库策略

策略一:内存数据库(最快,但不真实)

import { MongoMemoryServer } from 'mongodb-memory-server';

const mongod = new MongoMemoryServer();
const uri = await mongod.getUri();

策略二:Docker 测试数据库(推荐)

# docker-compose.test.yml
version: '3'
services:
  postgres-test:
    image: postgres:14
    environment:
      POSTGRES_DB: test
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    ports:
      - "5433:5432"

策略三:事务回滚(最快,共享数据库)

beforeEach(async () => {
  await db.transaction(async (trx) => {
    // 所有操作在事务中
    // 测试结束后自动回滚
  });
});

API 契约测试

当服务间通信复杂时,使用契约测试:

// 消费者驱动的契约测试
import { Pact } from '@pact-foundation/pact';

const provider = new Pact({
  consumer: 'OrderService',
  provider: 'PaymentService'
});

describe('PaymentService Contract', () => {
  it('应该处理支付请求', async () => {
    await provider
      .given('payment exists')
      .uponReceiving('a request to process payment')
      .withRequest({
        method: 'POST',
        path: '/payments',
        body: {
          orderId: '123',
          amount: 100
        }
      })
      .willRespondWith({
        status: 200,
        body: {
          id: Matchers.string('payment-123'),
          status: 'success',
          amount: 100
        }
      });
  });
});

E2E 测试:验证用户场景

E2E 测试的原则

  • 用户视角:模拟真实用户操作
  • 关键路径:只测核心业务流程
  • 独立性:不依赖其他测试的状态

E2E 测试示例

// Cypress 示例
describe('购物流程', () => {
  beforeEach(() => {
    // 使用测试账号
    cy.login('test@example.com', 'password');
  });

  it('用户应该能完成完整购买流程', () => {
    // 1. 浏览商品
    cy.visit('/products');
    cy.get('[data-testid="product-1"]').click();

    // 2. 加入购物车
    cy.get('[data-testid="add-to-cart"]').click();
    cy.get('[data-testid="cart-count"]').should('contain', '1');

    // 3. 结算
    cy.get('[data-testid="checkout"]').click();
    cy.url().should('include', '/checkout');

    // 4. 填写地址
    cy.get('[data-testid="shipping-address"]').type('123 Test St');
    cy.get('[data-testid="city"]').type('Test City');

    // 5. 选择支付方式
    cy.get('[data-testid="payment-credit-card"]').click();

    // 6. 提交订单
    cy.get('[data-testid="place-order"]').click();

    // 7. 验证订单成功
    cy.url().should('include', '/order-success');
    cy.get('[data-testid="order-number"]').should('be.visible');

    // 8. 验证邮件发送(可选)
    cy.task('checkEmail', { to: 'test@example.com' })
      .should('contain', '订单确认');
  });

  it('购物车为空时不应能结算', () => {
    cy.visit('/cart');
    cy.get('[data-testid="checkout"]').should('be.disabled');
    cy.get('[data-testid="empty-cart-message"]').should('be.visible');
  });
});

E2E 最佳实践

1. 使用 data-testid,不要用 CSS 选择器

<!-- ❌ 不要这样 -->
<button class="btn btn-primary">提交</button>

<!-- ✅ 应该这样 -->
<button data-testid="submit-order" class="btn btn-primary">提交</button>

2. 每个测试独立设置数据

beforeEach(() => {
  cy.task('db:seed', { scenario: 'user-with-cart' });
});

3. 只测可见行为,不测实现细节

// ❌ 不好的测试
it('应该调用 createOrder API', () => {
  cy.intercept('POST', '/api/orders').as('createOrder');
  // ... 操作
  cy.wait('@createOrder'); // 测的是实现
});

// ✅ 好的测试
it('应该创建订单', () => {
  // ... 操作
  cy.get('[data-testid="order-confirmation"]').should('be.visible'); // 测的是结果
});

测试策略:我们的实践经验

覆盖率策略

不要盲目追求 100% 覆盖率

我们的策略:

模块类型单元测试集成测试E2E目标覆盖率
核心业务逻辑90%+
工具函数70%+
API 路由⚠️80%+
数据库访问⚠️-
UI 组件⚠️70%+

测试运行策略

本地开发(每次保存):

# 只运行相关单元测试
npm test -- --watch --testPathPattern="cart"

# 运行时间 < 5s

Pre-commit

# 运行受影响的测试
npm test -- --changedSince=main

# 运行时间 < 30s

CI/CD(每次 Push):

# 运行全部测试
npm test:unit
npm test:integration
npm test:e2e

# 运行时间 < 10min

** nightly**(每天凌晨):

# 运行完整回归测试
npm test:full-regression

# 运行时间无限制

测试数据管理

策略:Factory + Builder 模式

// factories/user.factory.ts
export const userFactory = Factory.define<User>(() => ({
  id: faker.string.uuid(),
  email: faker.internet.email(),
  name: faker.person.fullName(),
  createdAt: new Date()
}));

// 在测试中使用
const user = userFactory.build({ email: 'test@example.com' });
const admin = userFactory.build({ role: 'admin' });

测试即文档

好的测试就是最好的文档:

describe('OrderService - 订单状态流转', () => {
  it('pending -> paid: 支付成功后变为已支付', async () => {
    // ...
  });

  it('paid -> shipped: 发货后变为已发货', async () => {
    // ...
  });

  it('shipped -> delivered: 签收后变为已送达', async () => {
    // ...
  });

  it('任何状态 -> cancelled: 用户可取消未发货订单', async () => {
    // ...
  });

  it('delivered -> cannot cancel: 已送达订单不可取消', async () => {
    // ...
  });
});

常见反模式与解决方案

反模式 1:测试代码重复

问题:每个测试都重复设置代码

解决:使用 fixtures 和 factories

// fixtures/orders.ts
export const createSampleOrder = (overrides = {}) => ({
  id: 'order-1',
  items: [/* ... */],
  status: 'pending',
  ...overrides
});

反模式 2:测试顺序依赖

问题:测试 A 改变了数据,导致测试 B 失败

解决:每个测试独立设置和清理

beforeEach(async () => {
  await db.truncate(); // 清空所有表
  await seedBasicData(); // 重新设置基础数据
});

反模式 3:测试私有方法

问题:测试 _calculateDiscount 而不是公共接口

解决:通过公共行为测试,或重构让逻辑可独立测试

反模式 4:忽略 flaky tests

问题:“这个测试有时失败,不管了”

解决

  1. 找出不稳定原因
  2. 修复或删除
  3. 不要忽视, flaky test 会削弱对测试的信心

测试驱动开发(TDD)

TDD 循环

1. Red: 写一个失败的测试
2. Green: 写最少的代码让测试通过
3. Refactor: 重构,保持测试通过

TDD 示例

需求:实现一个函数,检查密码强度

// Step 1: 写失败的测试
describe('checkPasswordStrength', () => {
  it('应该返回 weak 当密码少于 8 位', () => {
    expect(checkPasswordStrength('abc')).toBe('weak');
  });
});

// Step 2: 最简单的实现
function checkPasswordStrength(password: string): string {
  if (password.length < 8) return 'weak';
  return 'strong';
}

// Step 3: 添加更多测试,逐步完善
it('应该返回 weak 当密码没有数字', () => {
  expect(checkPasswordStrength('abcdefgh')).toBe('weak');
});

it('应该返回 weak 当密码没有大写字母', () => {
  expect(checkPasswordStrength('abcdefgh1')).toBe('weak');
});

it('应该返回 strong 当密码满足所有条件', () => {
  expect(checkPasswordStrength('Abcdefgh1')).toBe('strong');
});

// Step 4: 重构实现
function checkPasswordStrength(password: string): 'weak' | 'strong' {
  const hasMinLength = password.length >= 8;
  const hasNumber = /\d/.test(password);
  const hasUpperCase = /[A-Z]/.test(password);

  return hasMinLength && hasNumber && hasUpperCase ? 'strong' : 'weak';
}

何时不用 TDD?

TDD 不是银弹,以下情况可以跳过:

  • 探索性代码(不确定最终设计)
  • 纯 UI 代码(适合用 Storybook + Visual Testing)
  • 配置代码
  • 一次性的脚本

测试基础设施

CI/CD 集成

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

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm run test:unit -- --coverage

      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Upload coverage
        uses: codecov/codecov-action@v3

测试报告

// jest.config.js
module.exports = {
  reporters: [
    'default',
    ['jest-junit', {
      outputDirectory: './reports',
      outputName: 'junit.xml'
    }],
    ['jest-html-reporter', {
      pageTitle: 'Test Report',
      outputPath: './reports/test-report.html'
    }]
  ]
};

总结:测试策略 checklist

设计阶段

  • 确定测试金字塔各层比例
  • 定义关键路径(必须覆盖的 E2E 场景)
  • 选择测试工具和框架
  • 设置 CI/CD 流水线

开发阶段

  • 单元测试覆盖率 > 80%(核心业务)
  • 集成测试覆盖主要 API
  • E2E 测试覆盖关键用户路径
  • 每个 Bug 修复伴随回归测试

维护阶段

  • 定期审查和删除无用测试
  • 修复 flaky tests
  • 更新测试数据 factories
  • 优化测试执行时间

文化

  • 测试是 feature,不是 chore
  • 测试失败不能合并
  • 团队共享测试维护责任
  • 测试即文档,保持可读性

参考资源

书籍

  • 《Unit Testing Principles, Practices, and Patterns》Vladimir Khorikov
  • 《Test Driven Development》Kent Beck
  • 《Growing Object-Oriented Software, Guided by Tests》Steve Freeman

工具

  • Jest / Vitest:单元测试
  • Testing Library:React/Vue 组件测试
  • Playwright / Cypress:E2E 测试
  • MSW (Mock Service Worker):API Mocking

文章


你的测试策略是什么样的?遇到过哪些测试难题?欢迎分享。

本文的示例代码基于真实项目经验,测试策略帮助我们减少了 70% 的生产环境 Bug。

---

评论