Test Pyramid: Unit/Integration/E2E Ratios & Implementation
Anti-patterns (all-E2E or all-Mock) produce slow, flaky test suites. The classic pyramid solves this with layered structure: exponentially more tests as you go down, faster execution, tighter feedback loops - battle-proven methodology for large teams over a decade.
Three-Layer Responsibilities and Recommended Ratios
Unit tests at the bottom 70%: cover pure logic, edge cases, error branches. Mock all external deps; runs in milliseconds. Middle integration tests 20%: spin up real DB/Redis/message brokers via TestContainers. Verifies real service collaboration, no fakes. Top E2E 10%: Playwright/Cypress simulate real users on only critical business paths (login→order→pay), skip edge branches entirely.
// ====== 单元测试 (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 });
});
});
Coverage Metrics and Adoption Cadence
Don't worship 100% line coverage. Practical targets: unit line coverage 70-85%, branch coverage 90%+ on critical modules; integration tests stress writes (order submission, payment callbacks) and consistency; E2E is driven by business P0 checklist. Follow the 2/3/5 rule: all unit tests under 2s, integration done under 3 min, core E2E within 5 min.
| Layer | Volume Ratio | Per-Run Speed | Failure Indicates | Owner |
|---|---|---|---|---|
| Unit | 70% | <50ms | Function-level logic bug | Dev self-test, CI gate |
| Integration | 20% | <3s | Service collaboration / DB schema drift | Dev + QA |
| E2E | 10% | <1min | End-to-end regression / build broken | QA + release gate |
Best Practices
Define contracts before implementation (TDD). New features start with failing E2E spec, drill down layer by layer. CI stages: PR runs unit+integration immediately, release branch runs E2E only so daily iteration stays unblocked.