提示:本网站现在为维护调试状态

テストピラミッド:単体/結合/E2E 比率と実践

Choyeon
Choyeon
2026年9月24日 1 分で読了 574 閲覧
739 字 4,578 文字 更新日 2026年11月8日
テストピラミッド:単体/結合/E2E 比率と実践
目次

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.

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.

X微博Telegram

筆者について

投票

你最希望 Rosetta 优先加入哪项功能?

社区投票(演示数据,可后台删除)

コメント (3)

0 / 3000
考研加油鸭
考研加油鸭kaoyan-journey.xyz2026年9月22日

哈哈,楼主的踩坑经历和我一模一样!我当时就是没注意到这个配置,排查了整整两天,看到这篇文章泪目了。

自由撰稿人阿秋
自由撰稿人阿秋qiuwrites.blog2026年9月21日

写得太好了,终于搞懂了分布式事务的几种方案区别,收藏了!

独立开发者阿明
独立开发者阿明aming.dev2026年9月21日

请问一下,文中提到的连接池配置,如果是在高并发场景下,一般设置多少比较合适?

関連記事

PostgreSQL クエリ最適化テクニック

PostgreSQL クエリ最適化テクニック

PostgreSQLクエリ最適化:EXPLAIN ANALYZEの読み方、インデックス種別選択、統計情報更新、結合戦略調整、パーティションテーブル設計。

1 分で読了
TypeScript 高度な型体操

TypeScript 高度な型体操

TypeScript の型システムは、完全なプログラミング言語に匹敵します。この記事では、条件型、マッピング型、テンプレートリテラル型、infer による推論、再帰型、分散条件型などの高機能を体系的に解説します。15 以上の実用ユーティリティ型の実装を通じて、type-challenges 中級以上の解法アプローチを習得しましょう。

1 分で読了
Rust 所有権と借用:ライフタイム完全理解

Rust 所有権と借用:ライフタイム完全理解

Rustの最難解概念をマスター:所有権ルール、可変/不変借用の制約、ライフタイム注釈。コンパイルエラー例と修正を通してBorrow Checkerの思考法を習得。

1 分で読了
Zero Trust セキュリティアーキテクチャ入門:決して信用せず、常に検証

Zero Trust セキュリティアーキテクチャ入門:決して信用せず、常に検証

Zero Trust セキュリティモデル体系入門:「決して信用せず、常に検証」を原則に、伝統的 VPN=内部信頼の境界防衛思考を打ち砕く。身元認証(MFA/SAML)、端末健康チェック、最小権限RBAC、マイクロセグメンテーションmTLS、継続的信用スコア、可視化監査ログの6本柱を解説、実装ロードマップ付き。

1 分で読了
WebAssembly 入門:Rust → WASM + JS 呼び出し

WebAssembly 入門:Rust → WASM + JS 呼び出し

Rust→WASM開発環境構築:wasm-pack、wasm-bindgen型バインディング、JSからの呼び出し、メモリ共有による大規模データ処理。

1 分で読了
K8s Pod ライフサイクル + Init コンテナ + Sidecar

K8s Pod ライフサイクル + Init コンテナ + Sidecar

K8s Pod状態遷移Pending→Terminated、PostStart/PreStopフック、Init Container依存保証、Sidecarパターン、グレースフル停止ゼロダウンタイム設定。

1 分で読了