LESSON 25分

ストーリー

高橋アーキテクト
アーキテクチャの価値は、テストのしやすさに表れる
高橋アーキテクト
DBに接続しないとテストできない、外部APIが動いていないとテストできない。こういう状態は、アーキテクチャが正しく設計されていない証拠だ。クリーンアーキテクチャなら、各層を独立してテストできる

テストピラミッドとアーキテクチャ

          /\
         /  \       E2Eテスト(少数)
        /    \      → 全層を結合して検証
       /──────\
      /        \    統合テスト(中程度)
     /          \   → Adapter層の結合を検証
    /────────────\
   /              \  ユニットテスト(多数)
  /                \ → Entity, Use Caseを独立検証
 /──────────────────\
テスト種別対象層DB必要速度
ユニットテストEntity, Value Object不要極めて高速
Use CaseテストUse Case層不要(InMemory)高速
AdapterテストAdapter層必要(テストDB)中程度
E2Eテスト全層必要低速

Layer 1: Entityのテスト

Entityは純粋なビジネスロジックなので、外部依存なしでテストできます。

describe('Order', () => {
  it('注文の合計金額を計算できる', () => {
    const order = Order.create('customer-1', [
      OrderItem.create('prod-1', 'TypeScript入門', 2, Money.of(3000, 'JPY')),
      OrderItem.create('prod-2', 'React実践', 1, Money.of(4500, 'JPY')),
    ]);

    expect(order.totalAmount.equals(Money.of(10500, 'JPY'))).toBe(true);
  });

  it('PENDING以外のステータスからはキャンセルできない', () => {
    const order = Order.create('customer-1', [
      OrderItem.create('prod-1', '商品', 1, Money.of(1000, 'JPY')),
    ]);
    order.confirm();
    order.ship();

    expect(() => order.cancel()).toThrow('この注文はキャンセルできません');
  });
});

describe('Money', () => {
  it('異なる通貨の加算はエラー', () => {
    const jpy = Money.of(1000, 'JPY');
    const usd = Money.of(10, 'USD');

    expect(() => jpy.add(usd)).toThrow('異なる通貨の加算はできません');
  });

  it('負の金額は作成できない', () => {
    expect(() => Money.of(-100, 'JPY')).toThrow('金額は0以上');
  });
});

Layer 2: Use Caseのテスト

テストダブル(Stub, Spy, InMemory)を使って全依存を差し替えます。

// テストダブルの定義
class StubPaymentGateway implements PaymentGateway {
  private shouldSucceed = true;

  setSuccess(value: boolean): void { this.shouldSucceed = value; }

  async charge(amount: Money, method: PaymentMethod): Promise<PaymentResult> {
    return this.shouldSucceed
      ? PaymentResult.success('pay_test_123', amount)
      : PaymentResult.failure('決済エラー');
  }

  async refund(paymentId: string, amount: Money): Promise<RefundResult> {
    return RefundResult.of('ref_test_123', 'succeeded');
  }
}

class SpyNotificationSender implements NotificationSender {
  private calls: Array<{ order: Order }> = [];

  async sendOrderConfirmation(order: Order): Promise<void> {
    this.calls.push({ order });
  }

  async sendShippingNotification(): Promise<void> {}

  wasCalled(): boolean { return this.calls.length > 0; }
  callCount(): number { return this.calls.length; }
}

// Use Caseのテスト
describe('CreateOrderUseCase', () => {
  let useCase: CreateOrderUseCase;
  let orderRepo: InMemoryOrderRepository;
  let paymentGw: StubPaymentGateway;
  let notifier: SpyNotificationSender;

  beforeEach(() => {
    orderRepo = new InMemoryOrderRepository();
    paymentGw = new StubPaymentGateway();
    notifier = new SpyNotificationSender();
    const inventoryChecker = new StubInventoryChecker(true);
    useCase = new CreateOrderUseCase(
      orderRepo, inventoryChecker, paymentGw, notifier
    );
  });

  it('注文作成後に通知が送られる', async () => {
    paymentGw.setSuccess(true);

    await useCase.execute({
      customerId: 'c-1',
      items: [{ productId: 'p-1', quantity: 1 }],
      paymentMethod: { type: 'credit_card', token: 'tok' },
      shippingAddress: '東京都',
    });

    expect(notifier.wasCalled()).toBe(true);
    expect(notifier.callCount()).toBe(1);
  });

  it('決済失敗時は注文が保存されない', async () => {
    paymentGw.setSuccess(false);

    await expect(useCase.execute({
      customerId: 'c-1',
      items: [{ productId: 'p-1', quantity: 1 }],
      paymentMethod: { type: 'credit_card', token: 'tok' },
      shippingAddress: '東京都',
    })).rejects.toThrow('決済に失敗しました');

    expect(orderRepo.size()).toBe(0);
  });
});

Layer 3: Adapterのテスト

Adapterのテストでは、実際のインフラストラクチャ(テストDB等)を使います。

// Prisma Repositoryの統合テスト
describe('PrismaOrderRepository', () => {
  let repo: PrismaOrderRepository;
  let prisma: PrismaClient;

  beforeAll(async () => {
    prisma = new PrismaClient({
      datasources: { db: { url: process.env.TEST_DATABASE_URL } },
    });
    repo = new PrismaOrderRepository(prisma);
  });

  afterEach(async () => {
    await prisma.order.deleteMany();
  });

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

  it('注文を保存して取得できる', async () => {
    const order = Order.create('customer-1', [
      OrderItem.create('p-1', '商品A', 2, Money.of(1000, 'JPY')),
    ]);

    await repo.save(order);
    const found = await repo.findById(order.id);

    expect(found).not.toBeNull();
    expect(found!.id.equals(order.id)).toBe(true);
    expect(found!.totalAmount.amount).toBe(2000);
  });
});

テスタビリティのチェックリスト

確認項目OK条件
Entityのテスト外部依存なしで実行可能
Use CaseのテストInMemory/Stubで実行可能
テスト実行時間ユニットテストは1秒以内
テストの独立性テスト間で状態が共有されない
カバレッジビジネスロジックのカバレッジ80%以上

まとめ

ポイント内容
テストピラミッドユニット(多) → 統合(中) → E2E(少)
Entityテスト純粋なビジネスロジック、外部依存なし
Use CaseテストStub/Spy/InMemoryで全依存を差し替え
AdapterテストテストDB等の実インフラを使用
設計の指標テストしにくい = アーキテクチャに問題がある

チェックリスト

  • テストピラミッドと各テスト種別の対応を理解した
  • Stub、Spy、InMemoryの使い分けを説明できる
  • 各層のテスト方法を理解した
  • テスタビリティがアーキテクチャの品質指標であることを理解した

次のステップへ

次は演習です。クリーンアーキテクチャの4層構造でアプリケーションを再構築し、各層のテストを書いてみましょう。


推定読了時間: 25分