目次を表示する

TDD実践ガイド 2026

副作用をイベントに切り出す ── 通知とテストの独立性

本章の方針

Subscription がキャンセルされたら、メールを送りたい。支払いが失敗したら、Slack に通知したい。プランが変更されたら、課金額を再計算したい。

これらは全て副作用だ。メインのビジネスロジック(状態遷移)とは別の「反応」として発生する。

副作用を直接コードに埋め込むと、テストが副作用に巻き込まれる。本章では、テストの独立性を守りながら副作用を扱う方法を学ぶ。そして、テストがその方法を自然に発見する過程を追体験する。


問題:副作用がテストを汚染する

素朴な実装を見てみよう。

// ❌ 副作用がビジネスロジックに直接埋め込まれている
class CancelSubscription {
  constructor(
    private repo: SubscriptionRepository,
    private emailService: EmailService,
    private slackNotifier: SlackNotifier,
    private billingService: BillingService,
  ) {}

  async execute(subscriptionId: string): Promise<void> {
    const sub = await this.repo.findById(subscriptionId)
    if (!sub) throw new Error('契約が見つかりません')

    sub.cancel()
    await this.repo.save(sub)

    // 副作用がここに直接書かれている
    await this.emailService.sendCancellationEmail(sub.customerId)
    await this.slackNotifier.notify(`契約 ${sub.id} がキャンセルされました`)
    await this.billingService.stopRecurringCharge(sub.id)
  }
}

このコードのテストを書くと、こうなる。

// ❌ 副作用のために Stub/Mock が3つも必要
test('アクティブな契約をキャンセルできる', async () => {
  const repo = new InMemorySubscriptionRepository()
  const emailService = { sendCancellationEmail: vi.fn() }
  const slackNotifier = { notify: vi.fn() }
  const billingService = { stopRecurringCharge: vi.fn() }

  // ... セットアップ ...

  const useCase = new CancelSubscription(
    repo, emailService, slackNotifier, billingService,
  )
  await useCase.execute(sub.id)

  // 何をアサートする?
  expect(updated!.status).toBe('canceled')
  expect(emailService.sendCancellationEmail).toHaveBeenCalledWith('customer-1')
  expect(slackNotifier.notify).toHaveBeenCalled()
  expect(billingService.stopRecurringCharge).toHaveBeenCalledWith(sub.id)
})

問題は3つある。

1. テストのアレンジが巨大
   → 3つの Mock を用意するだけでテストの半分を消費

2. テストが脆い
   → 新しい副作用(例:監査ログ)を追加するたびに全テストが壊れる

3. テストが「何を検証しているか」が不明確
   → 「キャンセルのビジネスロジック」と「副作用の発火」が混在

テストが教えてくれること

ここで立ち止まって考える。なぜテストが書きにくいのか。

テストが書きにくいのは、コードの設計に問題があるシグナルだ。テストの困難さは設計のフィードバックとして受け取るべきものだ。

テストの困難さが示す設計の問題:
  ・Mock が3つ必要 → 依存が多すぎる
  ・副作用が増えるとテストが壊れる → 変更に弱い設計
  ・キャンセルと通知が混在 → 責務が分離されていない

TDD でテストを先に書くことの価値は、こうした設計のフィードバックを早期に得られることにある。テスト後書きでは、実装が終わった後に「テストが書きにくいな」と感じるだけで、設計は変えづらい。


解決策:イベントで副作用を分離する

テストの困難さに導かれて、設計を変えよう。

核心的なアイデア: Subscription がキャンセルされたとき、Subscription 自身は「キャンセルされた」という事実(イベント)を記録するだけにする。メールを送るか、Slack に通知するかは、Subscription の責務ではない。

ステップ1:イベントのテストを書く

// subscription.test.ts
test('キャンセルするとSubscriptionCanceledイベントが記録される', () => {
  const sub = Subscription.startTrial('customer-1', 'plan-basic')
  sub.activate()
  sub.cancel()

  const events = sub.pullDomainEvents()
  expect(events).toHaveLength(1)
  expect(events[0]).toEqual({
    type: 'SubscriptionCanceled',
    subscriptionId: sub.id,
    customerId: 'customer-1',
    canceledAt: expect.any(Date),
  })
})

🔴 Red。pullDomainEvents メソッドは存在しない。

ステップ2:イベント記録の実装

// domain-event.ts
export type DomainEvent = {
  type: string
  [key: string]: unknown
}

// subscription.ts に追加
export class Subscription {
  private _domainEvents: DomainEvent[] = []

  // ... 既存のコード ...

  cancel(): void {
    this.assertStatusIn('trial', 'active', 'past_due')
    this._status = 'canceled'
    this._canceledAt = new Date()

    // イベントを記録する(副作用を直接実行しない)
    this._domainEvents.push({
      type: 'SubscriptionCanceled',
      subscriptionId: this.id,
      customerId: this.customerId,
      canceledAt: this._canceledAt,
    })
  }

  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents]
    this._domainEvents = []
    return events
  }
}

🟢 Green。

pullDomainEvents は「溜まったイベントを取り出して空にする」操作だ。イベントを取り出した後にリストが空になることで、同じイベントが二重に処理されることを防ぐ。

ステップ3:他の状態遷移にもイベントを追加

test('アクティベートするとSubscriptionActivatedイベントが記録される', () => {
  const sub = Subscription.startTrial('customer-1', 'plan-basic')
  sub.activate()

  const events = sub.pullDomainEvents()
  expect(events).toHaveLength(1)
  expect(events[0].type).toBe('SubscriptionActivated')
})

test('プラン変更するとPlanChangedイベントが記録される', () => {
  const sub = Subscription.startTrial('customer-1', 'plan-basic')
  sub.activate()
  sub.pullDomainEvents()  // アクティベートのイベントをクリア

  sub.changePlan('plan-premium')

  const events = sub.pullDomainEvents()
  expect(events).toHaveLength(1)
  expect(events[0]).toEqual({
    type: 'PlanChanged',
    subscriptionId: sub.id,
    oldPlanId: 'plan-basic',
    newPlanId: 'plan-premium',
    changedAt: expect.any(Date),
  })
})

イベントハンドラーのテスト

副作用はイベントハンドラーとして実装する。各ハンドラーは独立してテストできる。

// send-cancellation-email.test.ts
describe('SendCancellationEmailHandler', () => {
  test('SubscriptionCanceledイベントでメールを送信する', async () => {
    const sentEmails: Array<{ to: string; subject: string }> = []
    const emailService: EmailService = {
      send: async (to, subject, body) => {
        sentEmails.push({ to, subject })
      },
    }

    const handler = new SendCancellationEmailHandler(emailService)
    await handler.handle({
      type: 'SubscriptionCanceled',
      subscriptionId: 'sub-1',
      customerId: 'customer-1',
      canceledAt: new Date(),
    })

    expect(sentEmails).toHaveLength(1)
    expect(sentEmails[0].subject).toContain('キャンセル')
  })
})

このテストに Subscription は登場しない。SubscriptionRepository も不要だ。テスト対象は「イベントを受け取ってメールを送る」というハンドラーだけ。

// send-cancellation-email-handler.ts
export class SendCancellationEmailHandler {
  constructor(private emailService: EmailService) {}

  async handle(event: SubscriptionCanceled): Promise<void> {
    await this.emailService.send(
      event.customerId,
      '契約キャンセルのお知らせ',
      `契約 ${event.subscriptionId} がキャンセルされました。`,
    )
  }
}

ユースケースの簡素化

イベントで副作用を分離した結果、ユースケースがシンプルになる。

// ✅ After:副作用はイベント経由
class CancelSubscription {
  constructor(
    private repo: SubscriptionRepository,
    private eventPublisher: EventPublisher,
  ) {}

  async execute(subscriptionId: string): Promise<void> {
    const sub = await this.repo.findById(subscriptionId)
    if (!sub) throw new Error('契約が見つかりません')

    sub.cancel()
    await this.repo.save(sub)

    // イベントを発行する(副作用の実行は別の場所で)
    const events = sub.pullDomainEvents()
    for (const event of events) {
      await this.eventPublisher.publish(event)
    }
  }
}

テストも簡素化される。

test('キャンセル時にSubscriptionCanceledイベントが発行される', async () => {
  const repo = new InMemorySubscriptionRepository()
  const publishedEvents: DomainEvent[] = []
  const eventPublisher: EventPublisher = {
    publish: async (event) => { publishedEvents.push(event) },
  }

  const sub = Subscription.startTrial('customer-1', 'plan-basic')
  sub.activate()
  await repo.save(sub)

  const useCase = new CancelSubscription(repo, eventPublisher)
  await useCase.execute(sub.id)

  expect(publishedEvents).toHaveLength(1)
  expect(publishedEvents[0].type).toBe('SubscriptionCanceled')
})

Mock は不要。Stub が1つ(eventPublisher)だけ。テストの焦点は「キャンセルが実行され、正しいイベントが発行されること」に絞られている。


Before / After の比較

graph LR
  subgraph Before
    UC1[CancelSubscription] --> Email1[EmailService]
    UC1 --> Slack1[SlackNotifier]
    UC1 --> Billing1[BillingService]
  end

  subgraph After
    UC2[CancelSubscription] --> EP[EventPublisher]
    EP --> H1[EmailHandler]
    EP --> H2[SlackHandler]
    EP --> H3[BillingHandler]
  end
Before:
  ・ユースケースが3つの副作用サービスに直接依存
  ・副作用を追加するたびにユースケースを変更
  ・テストに Mock が3つ必要

After:
  ・ユースケースは EventPublisher にだけ依存
  ・副作用の追加はハンドラーを追加するだけ(ユースケース変更不要)
  ・テストは Spy 1つで済む
  ・各ハンドラーは独立してテスト可能

テストが発見したアーキテクチャ

本章を振り返ろう。テストの困難さに導かれて、以下の設計が自然に生まれた。

テストが駆動した設計:
  1. DomainEvent 型
     → テストが「何が起きたかを記録する」ことを要求したから

  2. pullDomainEvents メソッド
     → テストが「イベントを検証可能な形で取り出す」ことを要求したから

  3. EventPublisher インターフェース
     → テストが「副作用の発行を制御可能にする」ことを要求したから

  4. ハンドラーの分離
     → テストが「各副作用を独立してテストする」ことを要求したから

「副作用をイベントとして分離し、ハンドラーで処理する」──このアーキテクチャは、テストの要求に応えた結果として自然に生まれたものだ。最初から「イベント駆動にしよう」と決めたわけではない。

テストを書きにくいと感じるたびに設計を改善し続けた結果、副作用が「起きた事実の記録」と「事実への反応」に分離された。


本章のまとめ

要点内容
副作用の問題ビジネスロジックに直接書くと、テストが副作用に巻き込まれる
テストのフィードバックテストの書きにくさは設計の問題のシグナル
イベントによる分離「何が起きたか」を記録し、「何をするか」はハンドラーに委ねる
ハンドラーの独立テスト各副作用ハンドラーを独立してテストできる
設計の発見テストの要求に応えた結果、イベント駆動アーキテクチャが自然に生まれた

次章では方向転換する。ここまで「TDD の正しいやり方」を見てきたが、次章では**「TDD の間違ったやり方」**──アンチパターンを体系的に分類する。