目次を表示する

TDD実践ガイド 2026

外部依存を断ち切る ── テストダブルと永続化の境界

本章の方針

ここまでの 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>
}

このインターフェースはどこから来たか? テストが要求したのだ。テストが「保存して取得する」操作を書いたから、savefindById というインターフェースが生まれた。

ステップ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 がキャンセルされたとき、メールを送りたい。でもテストからメールを送りたくない。この問題を、テストはどう解決するのか。