本章の方針
ここまでの Money と Subscription は、外部依存がなかった。メモリ上で完結するオブジェクトだ。しかし、実際のアプリケーションではデータベースに保存し、取得する必要がある。
外部依存が入った瞬間、TDD の難易度は一段上がる。テストが遅くなり、セットアップが複雑になり、「テストのために本番 DB を動かすのか?」という問いが立ちはだかる。
本章では、テストダブル(Test Double)を使って外部依存を制御する方法と、テストが「永続化の境界」を発見する過程を扱う。
問題:テストがデータベースに依存している
Subscription を保存・取得するコードを素朴に書くとこうなる。
// ❌ DB に直接依存したコード
import { prisma } from './db'
async function cancelSubscription(subscriptionId: string): Promise<void> {
const row = await prisma.subscription.findUnique({
where: { id: subscriptionId },
})
if (!row) throw new Error('契約が見つかりません')
const sub = Subscription.fromRow(row)
sub.cancel()
await prisma.subscription.update({
where: { id: subscriptionId },
data: { status: sub.status, canceledAt: new Date() },
})
}
このコードのテストを書こうとすると、問題が噴出する。
テストの障壁:
1. テスト実行にDBが必要(セットアップが重い)
2. テストごとにデータをリセットする必要がある
3. テストが遅い(DB I/Oが毎回走る)
4. CI環境でDB接続が不安定な場合、テストがフレーキーになる
5. 「キャンセルのビジネスロジック」と「DBアクセス」が混ざっている
5番目が最も本質的な問題だ。テストしたいのは**「キャンセルのビジネスロジック」であって、「Prisma が正しく動くか」**ではない。
テストダブルの分類
外部依存を制御するための道具を、テストダブル(Test Double)と呼ぶ。5種類ある。
| 種類 | 役割 | 使いどころ |
|---|---|---|
| Dummy | 引数を埋めるだけ。呼ばれない | テスト対象が使わない引数を満たすとき |
| Stub | 決まった値を返す | 外部サービスのレスポンスを固定したいとき |
| Spy | 呼ばれたかどうかを記録する | メソッドが正しく呼ばれたことを検証したいとき |
| Mock | 期待する呼び出しを事前に定義し、違反で失敗する | 呼び出し順序や引数の正確さを検証したいとき |
| Fake | 簡略化した実装を持つ | DBの代わりにインメモリで動作するものなど |
この5種類のうち、TDD で最も価値が高いのは Fake だ。
Fake で永続化を抽象化する
ステップ1:テストから始める
TDD のルールに従い、テストから書く。「Subscription を保存して、取得できる」ことをテストしたい。
// subscription-repository.test.ts
import { describe, test, expect } from 'vitest'
describe('SubscriptionRepository', () => {
test('保存したSubscriptionをIDで取得できる', async () => {
const repository = new InMemorySubscriptionRepository()
const sub = Subscription.startTrial('customer-1', 'plan-basic')
await repository.save(sub)
const found = await repository.findById(sub.id)
expect(found).not.toBeNull()
expect(found!.customerId).toBe('customer-1')
})
})
🔴 Red。InMemorySubscriptionRepository は存在しない。
ここで重要な判断をした。テストで使う永続化先をインメモリにした。DB ではなく、メモリ上のオブジェクトに保存する。これが Fake だ。
ステップ2:インターフェースの発見
テストを通すために、InMemorySubscriptionRepository を実装する必要がある。だが、その前に考える。本番では Prisma を使う。テストではインメモリを使う。同じインターフェースで差し替え可能であるべきだ。
// subscription-repository.ts
export interface SubscriptionRepository {
save(subscription: Subscription): Promise<void>
findById(id: string): Promise<Subscription | null>
}
このインターフェースはどこから来たか? テストが要求したのだ。テストが「保存して取得する」操作を書いたから、save と findById というインターフェースが生まれた。
ステップ3:Fake の実装
// in-memory-subscription-repository.ts
export class InMemorySubscriptionRepository implements SubscriptionRepository {
private store = new Map<string, Subscription>()
async save(subscription: Subscription): Promise<void> {
this.store.set(subscription.id, subscription)
}
async findById(id: string): Promise<Subscription | null> {
return this.store.get(id) ?? null
}
}
🟢 Green。
ステップ4:本番実装の差し替え
本番では同じインターフェースを Prisma で実装する。
// prisma-subscription-repository.ts
export class PrismaSubscriptionRepository implements SubscriptionRepository {
constructor(private prisma: PrismaClient) {}
async save(subscription: Subscription): Promise<void> {
await this.prisma.subscription.upsert({
where: { id: subscription.id },
create: { /* ... */ },
update: { /* ... */ },
})
}
async findById(id: string): Promise<Subscription | null> {
const row = await this.prisma.subscription.findUnique({
where: { id },
})
return row ? Subscription.fromRow(row) : null
}
}
graph TD
Test[テスト] --> Interface[SubscriptionRepository<br/>interface]
App[アプリケーション] --> Interface
Interface --> InMemory[InMemorySubscriptionRepository<br/>Fake]
Interface --> Prisma[PrismaSubscriptionRepository<br/>本番]
テストは InMemorySubscriptionRepository を使い、本番は PrismaSubscriptionRepository を使う。どちらも同じ SubscriptionRepository インターフェースを実装しているから、ビジネスロジックのコードは変更不要だ。
ビジネスロジックのテスト:DB なし
インターフェースが分離されたことで、ビジネスロジックのテストから DB が完全に消えた。
// cancel-subscription.test.ts
describe('cancelSubscription', () => {
test('アクティブな契約をキャンセルできる', async () => {
// Arrange:インメモリリポジトリにアクティブな契約を用意
const repo = new InMemorySubscriptionRepository()
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
await repo.save(sub)
// Act:キャンセルを実行
const useCase = new CancelSubscription(repo)
await useCase.execute(sub.id)
// Assert:ステータスが canceled になっている
const updated = await repo.findById(sub.id)
expect(updated!.status).toBe('canceled')
})
test('存在しない契約のキャンセルはエラー', async () => {
const repo = new InMemorySubscriptionRepository()
const useCase = new CancelSubscription(repo)
await expect(useCase.execute('nonexistent')).rejects.toThrow('契約が見つかりません')
})
})
テストの実行速度はミリ秒単位。DB セットアップも、テスト間のデータクリアも不要。TDD のサイクルを高速に回せる。
ここで CancelSubscription クラスが自然に生まれた。テストが「契約をキャンセルする操作」を記述するために、操作を受け持つクラスが必要だったのだ。
// cancel-subscription.ts
export class CancelSubscription {
constructor(private repo: SubscriptionRepository) {}
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)
}
}
Stub と Spy の使いどころ
Fake が最も有用だが、Stub と Spy にも出番がある。
Stub:外部APIの応答を固定する
決済サービスのAPIに依存するテスト。
test('決済成功時にSubscriptionをactivateする', async () => {
// Stub:決済APIが常に成功を返す
const paymentGateway: PaymentGateway = {
charge: async () => ({ success: true, transactionId: 'tx-123' }),
}
const repo = new InMemorySubscriptionRepository()
const sub = Subscription.startTrial('customer-1', 'plan-basic')
await repo.save(sub)
const useCase = new ProcessPayment(repo, paymentGateway)
await useCase.execute(sub.id, Money.of(1000, 'JPY'))
const updated = await repo.findById(sub.id)
expect(updated!.status).toBe('active')
})
Stub は「外部サービスがこう応答したら」という前提条件を固定する。テストの焦点を「自分たちのコードの振る舞い」に絞れる。
Spy:正しい引数で呼ばれたか検証する
test('決済時に正しい金額が請求される', async () => {
// Spy:呼び出しを記録する
const charges: Array<{ amount: Money }> = []
const paymentGateway: PaymentGateway = {
charge: async (amount) => {
charges.push({ amount })
return { success: true, transactionId: 'tx-123' }
},
}
const repo = new InMemorySubscriptionRepository()
const sub = Subscription.startTrial('customer-1', 'plan-basic')
await repo.save(sub)
const useCase = new ProcessPayment(repo, paymentGateway)
await useCase.execute(sub.id, Money.of(1000, 'JPY'))
// Assert:正しい金額で charge が呼ばれたことを検証
expect(charges).toHaveLength(1)
expect(charges[0].amount.equals(Money.of(1000, 'JPY'))).toBe(true)
})
Mock の罠:Over-Mocking
テストダブルの中で最も危険なのが Mock の過剰使用だ。
// ❌ Over-Mocking:実装の詳細をテストしている
test('cancelSubscription は正しいメソッドを正しい順序で呼ぶ', () => {
const mockRepo = {
findById: vi.fn().mockResolvedValue(mockSubscription),
save: vi.fn().mockResolvedValue(undefined),
}
const mockSub = {
cancel: vi.fn(),
status: 'active',
}
mockRepo.findById.mockResolvedValue(mockSub)
const useCase = new CancelSubscription(mockRepo)
await useCase.execute('sub-1')
expect(mockRepo.findById).toHaveBeenCalledWith('sub-1')
expect(mockSub.cancel).toHaveBeenCalledOnce()
expect(mockRepo.save).toHaveBeenCalledWith(mockSub)
})
このテストは何を検証しているか? 「findById → cancel → save の順で呼ばれること」 だ。これは実装の手順であって、ビジネスの振る舞いではない。
実装をリファクタリングしたらテストが壊れる。テストが実装に密結合しているからだ。
// ✅ 振る舞いをテストする(Fake を使う)
test('アクティブな契約をキャンセルできる', async () => {
const repo = new InMemorySubscriptionRepository()
const sub = Subscription.startTrial('customer-1', 'plan-basic')
sub.activate()
await repo.save(sub)
const useCase = new CancelSubscription(repo)
await useCase.execute(sub.id)
const updated = await repo.findById(sub.id)
expect(updated!.status).toBe('canceled') // 結果を検証
})
このテストは「キャンセル後にステータスが canceled になる」という振る舞いを検証している。内部で findById → cancel → save の順に呼ばれるか findById → save の順に呼ばれるかは気にしない。
テストダブル選択の指針:
・まず Fake を検討する(最も実装に依存しない)
・外部APIの応答制御には Stub を使う
・「正しい引数で呼ばれたか」の検証には Spy を使う
・Mock は最後の手段。使う場合は「実装への結合」を意識する
テストが発見した「境界」
本章でテストが発見したものを振り返る。
テストが駆動した設計:
1. SubscriptionRepository インターフェース
→ テストが「保存と取得」を要求したから生まれた
2. InMemorySubscriptionRepository
→ テストが「DBなしで高速に動くこと」を要求したから生まれた
3. CancelSubscription ユースケースクラス
→ テストが「操作を独立してテストすること」を要求したから生まれた
4. 永続化の境界(インターフェース)
→ テストが「ビジネスロジックとDBアクセスの分離」を要求したから生まれた
テストが「永続化の境界を分離しろ」と明示的に言ったわけではない。テストを高速に、独立に実行したいという要求が、結果として永続化の抽象化層を生んだのだ。
本章のまとめ
| 要点 | 内容 |
|---|---|
| テストダブル5種 | Dummy, Stub, Spy, Mock, Fake |
| 推奨順 | Fake → Stub → Spy → Mock(Mock は最後の手段) |
| Over-Mocking | 実装の手順をテストすると、リファクタリングでテストが壊れる |
| 永続化の境界 | テストが要求した結果、インターフェースによる抽象化が生まれた |
| Fake の価値 | DBなしでミリ秒単位のテスト。TDD のサイクルを高速に回せる |
次章では、もう一つの外部依存──副作用に取り組む。Subscription がキャンセルされたとき、メールを送りたい。でもテストからメールを送りたくない。この問題を、テストはどう解決するのか。