测试反模式(全是 E2E 全是 Mock)都会导致测试又慢又不稳。经典测试金字塔用分层结构解决:数量从上到下指数增加,速度越来越快,反馈周期越来越短,是大型团队十年验证过的稳定方法论。
三层分工与推荐比例
底层单元测试占 70%:覆盖纯逻辑、边界条件、错误分支,Mock 所有外部依赖,毫秒级跑完。中层集成测试 20%:用 TestContainer 启动真实数据库/Redis/消息队列,测服务之间真实协作不造假。顶层 E2E 10%:Playwright/Cypress 模拟真实用户,只覆盖核心业务路径(登录-下单-支付),不跑边角分支。
// ====== 单元测试 (pactum + vitest + mocking) ======
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OrderService } from './order.service';
import type { PaymentGateway, InventoryRepo, OrderRepo } from './ports';
const makeSUT = () => {
const payment = { charge: vi.fn().mockResolvedValue({ success: true, txId: 'tx_001' }) } as unknown as PaymentGateway;
const inventory = { lockStock: vi.fn().mockResolvedValue(true), releaseStock: vi.fn() } as unknown as InventoryRepo;
const orders = { save: vi.fn().mockImplementation(o => Promise.resolve({ ...o, id: 'ord_42' })) } as unknown as OrderRepo;
return { sut: new OrderService(orders, inventory, payment), payment, inventory, orders };
};
describe('OrderService.placeOrder 单元测试', () => {
it('正常下单: 扣库存-扣款-落库三阶段成功', async () => {
const { sut, orders, inventory, payment } = makeSUT();
const order = await sut.placeOrder({ userId: 'u1', items: [{ sku: 'SK1', qty: 2, unitPrice: 99 }] });
expect(order.id).toBe('ord_42');
expect(inventory.lockStock).toHaveBeenCalledWith('SK1', 2);
expect(payment.charge).toHaveBeenCalledWith(198, expect.any(String));
expect(orders.save).toHaveBeenCalledTimes(1);
});
it('扣款失败时自动回滚库存释放', async () => {
const { sut, payment, inventory } = makeSUT();
payment.charge.mockResolvedValueOnce({ success: false, txId: 'tx_002', reason: 'INSUFFICIENT_FUNDS' });
await expect(sut.placeOrder({ userId: 'u1', items: [{ sku: 'SK1', qty: 1, unitPrice: 99 }] }))
.rejects.toThrow(/PAYMENT_FAILED/);
expect(inventory.releaseStock).toHaveBeenCalledWith('SK1', 1);
});
});
// ====== 集成测试 (TestContainer 启动真实 Postgres + Redis) ======
import { PostgreSqlContainer, RedisContainer } from '@testcontainers/postgresql';
import { createClient } from 'redis';
describe('CartRepository 集成测试', () => {
let pgContainer: PostgreSqlContainer, redisContainer: RedisContainer;
let pool: any, redis: ReturnType<typeof createClient>;
beforeAll(async () => {
pgContainer = await new PostgreSqlContainer('postgres:16-alpine').withDatabase('testdb').start();
redisContainer = await new RedisContainer('redis:7-alpine').start();
pool = require('pg').Pool({ connectionString: pgContainer.getConnectionUri() });
await pool.query('CREATE TABLE carts (user_id TEXT PRIMARY KEY, items JSONB NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW())');
redis = createClient({ url: redisContainer.getConnectionUrl() });
await redis.connect();
}, 60000);
it('写 PG 后读 Redis 双写一致 (100 次并发下)', async () => {
const repo = new (require('./cart.repo').CartRepository)(pool, redis);
const N = 100;
await Promise.all(Array.from({ length: N }, (_, i) => repo.upsert(`user_${i}`, [{ sku: `S${i}`, qty: i % 5 + 1 }])));
for (let i = 0; i < N; i++) {
const cached = JSON.parse(await redis.get(`cart:user_${i}`) || 'null');
const row = (await pool.query('SELECT items FROM carts WHERE user_id=$1', [`user_${i}`])).rows[0];
expect(cached).toMatchObject(row.items);
}
});
afterAll(async () => {
await pool?.end(); await redis?.quit();
await pgContainer?.stop(); await redisContainer?.stop();
});
});
// ====== E2E 测试 (Playwright - 仅核心 happy path) ======
import { test, expect } from '@playwright/test';
test.describe('结账核心链路 E2E', () => {
test('登录→加购→结账→订单成功页完整路径', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'e2e-user@example.com');
await page.fill('input[name="password"]', 'ValidPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/products');
await page.click('[data-testid="product-42-add"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
await page.goto('/checkout');
await page.fill('[data-testid="card-number"]', '4242424242424242');
await page.fill('[data-testid="card-cvc"]', '123');
await page.click('[data-testid="submit-order"]');
await expect(page.locator('[data-testid="order-success"]')).toBeVisible({ timeout: 15000 });
});
});
覆盖率指标与落地节奏
不要迷信 100% 行覆盖率。实际推荐:单元测试行覆盖 70-85%,重点模块分支覆盖 90%+;集成测试重点测写操作(提交订单/支付回调)和一致性;E2E 按业务 P0 清单来。按 2/3/5 法则:2 秒内跑完全部单测,3 分钟内跑完集成,5 分钟内跑完核心 E2E。
| 层级 | 数量比例 | 单次速度 | 失败含义 | 归属负责人 |
|---|---|---|---|---|
| 单元测试 | 70% | <50ms | 函数级逻辑bug | 开发自测CI必过 |
| 集成测试 | 20% | <3s | 服务协作bug/DBSchema不一致 | 开发 + QA |
| E2E 测试 | 10% | <1min | 链路级回归/构建产物损坏 | QA + 发布门禁 |
最佳实践
先写契约再写实现(TDD),新需求从 E2E 失败用例开始驱动,逐层下沉细化。CI 分层执行:PR 提交先跑单测+集成,发布分支再跑 E2E,避免阻塞日常迭代。