本章の方針
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 の間違ったやり方」**──アンチパターンを体系的に分類する。