Skip to content
Published on

ソフトウェアテストパターン完全ガイド: Stub、Mock、Spy、Fake

Authors

概要

ソフトウェアテストを書く際に最も混同されやすい概念が、Stub、Mock、Spy、Fakeです。これらの用語はしばしば混用されますが、Gerard Meszarosが著書「xUnit Test Patterns」で確立した**テストダブル(Test Double)**の分類体系では、それぞれが明確に異なる役割を担っています。

このガイドでは、各テストダブルの正確な意味、適切な使用タイミング、言語別の実装例、TDD/BDD戦略まで詳しく解説します。


1. テストダブルの5種類

**テストダブル(Test Double)**とは、映画撮影で危険なシーンを代演するスタントダブルに由来する用語です。ソフトウェアでは、テストを可能にするために実際の依存関係を置き換えるオブジェクトの総称です。

Gerard Meszarosの分類体系

Test Double
├── Dummy Object      (渡されるが使われないオブジェクト)
├── Test Stub         (あらかじめ決められた値を返す - 状態検証)
├── Test Spy          (呼び出し情報を記録するStub)
├── Mock Object       (期待値が事前定義されたオブジェクト - 振る舞い検証)
└── Fake Object       (実際に動作するが本番には不適切な実装)

1.1 Dummy Object

Dummyはメソッドシグネチャを満たすために渡されますが、テスト中に実際には使われないオブジェクトです。

// 例: Loggerが必要だがテストでは使用しない場合
class DummyLogger implements Logger {
  log(message: string): void {}
  error(message: string): void {}
  warn(message: string): void {}
}

test('ユーザー作成 - メール検証', () => {
  const dummyLogger = new DummyLogger()
  const userService = new UserService(dummyLogger)
  expect(userService.validateEmail('test@example.com')).toBe(true)
})

Dummyは単にコンパイルエラーやnullポインタ例外を避けるために使います。

1.2 Stub

Stubは**あらかじめ設定された値(canned answer)を返すオブジェクトです。設定した値だけを返し、他の動作は行いません。主に状態検証(State Verification)**に使用されます。

// UserRepository Stub
class StubUserRepository implements UserRepository {
  findById(id: string): User | null {
    if (id === 'user-123') {
      return { id: 'user-123', name: '山田太郎', email: 'yamada@example.com' }
    }
    return null
  }

  save(user: User): void {}
}

test('ユーザー取得サービス', () => {
  const stub = new StubUserRepository()
  const service = new UserService(stub)
  const user = service.getUser('user-123')
  expect(user?.name).toBe('山田太郎')
})

1.3 Spy

SpyはStubのように動作しながら、呼び出し情報を記録します。後でどのように呼ばれたかを検証できます。

class SpyEmailService implements EmailService {
  public sentEmails: Array<{ to: string; subject: string }> = []

  sendEmail(to: string, subject: string, body: string): void {
    this.sentEmails.push({ to, subject })
    // 実際の送信はしない
  }
}

test('注文完了時にメール送信', () => {
  const spyEmail = new SpyEmailService()
  const orderService = new OrderService(spyEmail)

  orderService.completeOrder('order-456')

  expect(spyEmail.sentEmails).toHaveLength(1)
  expect(spyEmail.sentEmails[0].to).toBe('customer@example.com')
})

1.4 Mock Object

Mockは**期待値(expectations)**が事前に定義されたオブジェクトです。テスト実行後、期待通りに呼ばれたかを自動的に検証します。**振る舞い検証(Behavior Verification)**に使用されます。

Spyが事後にアサーションを行うのに対し、Mockは期待値を事前に設定して検証を自動化します。

// JestのMock例
test('注文完了時に正確に1回メール送信', () => {
  const mockEmailService = {
    sendEmail: jest.fn(),
  }
  const orderService = new OrderService(mockEmailService)

  orderService.completeOrder('order-456')

  expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1)
  expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
    'customer@example.com',
    '注文完了',
    expect.stringContaining('order-456')
  )
})

1.5 Fake Object

Fakeは実際に動作する実装を持っていますが、本番環境では使用に不適切な簡略化されたバージョンです。インメモリデータベース、インメモリキュー、ローカルファイルベースのストレージなどが該当します。

class FakeUserRepository implements UserRepository {
  private store: Map<string, User> = new Map()

  findById(id: string): User | null {
    return this.store.get(id) ?? null
  }

  save(user: User): void {
    this.store.set(user.id, user)
  }

  findByEmail(email: string): User | null {
    for (const user of this.store.values()) {
      if (user.email === email) return user
    }
    return null
  }
}

test('重複メール登録防止', () => {
  const fake = new FakeUserRepository()
  const service = new UserService(fake)

  service.register({ id: '1', name: '山田太郎', email: 'test@example.com' })

  expect(() => service.register({ id: '2', name: '鈴木花子', email: 'test@example.com' })).toThrow(
    'このメールアドレスは既に使用されています'
  )
})

2. Stub 深掘り

2.1 Stubを使うべき場面

Stubは以下の状況で有効です:

  • 外部サービスに依存している場合(決済API、SMSサービス)
  • 非決定的(non-deterministic)な結果を返す場合(現在時刻、ランダム値)
  • 遅い依存関係を置き換える場合(DBクエリ、ネットワーク)
  • 特定のエラー状況を再現する場合

2.2 インメモリ Repository Stubパターン

// Go言語の例
package repository

type UserRepository interface {
    FindByID(id string) (*User, error)
    Save(user *User) error
}

// テスト用Stub
type StubUserRepository struct {
    users map[string]*User
    err   error
}

func NewStubUserRepository() *StubUserRepository {
    return &StubUserRepository{
        users: make(map[string]*User),
    }
}

func (s *StubUserRepository) FindByID(id string) (*User, error) {
    if s.err != nil {
        return nil, s.err
    }
    user, ok := s.users[id]
    if !ok {
        return nil, ErrUserNotFound
    }
    return user, nil
}

func (s *StubUserRepository) WithError(err error) *StubUserRepository {
    s.err = err
    return s
}

func (s *StubUserRepository) AddUser(user *User) *StubUserRepository {
    s.users[user.ID] = user
    return s
}

2.3 時刻Stub(Clockインターフェース)

時刻に依存するコードはテストが困難です。Clockインターフェースで抽象化します。

// Clockインターフェース
type Clock interface {
    Now() time.Time
}

// 本番実装
type SystemClock struct{}

func (c *SystemClock) Now() time.Time {
    return time.Now()
}

// テスト用Stub
type StubClock struct {
    fixedTime time.Time
}

func NewStubClock(t time.Time) *StubClock {
    return &StubClock{fixedTime: t}
}

func (c *StubClock) Now() time.Time {
    return c.fixedTime
}

// 使用例
func TestTokenExpiry(t *testing.T) {
    fixedTime := time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC)
    stubClock := NewStubClock(fixedTime)

    tokenService := NewTokenService(stubClock)
    token := tokenService.CreateToken("user-123")

    // 2時間後の有効期限チェック
    expiredClock := NewStubClock(fixedTime.Add(2 * time.Hour))
    tokenService2 := NewTokenService(expiredClock)

    if tokenService2.IsValid(token) {
        t.Error("期限切れのトークンが有効と判定された")
    }
}

2.4 外部API Stub(TypeScript)

// 決済ゲートウェイインターフェース
interface PaymentGateway {
  charge(amount: number, cardToken: string): Promise<PaymentResult>
  refund(paymentId: string): Promise<RefundResult>
}

// テスト用Stub
class StubPaymentGateway implements PaymentGateway {
  private shouldFail = false

  setToFail(): void {
    this.shouldFail = true
  }

  async charge(amount: number, cardToken: string): Promise<PaymentResult> {
    if (this.shouldFail) {
      return { success: false, error: 'CARD_DECLINED' }
    }
    return { success: true, paymentId: `PAY-${Date.now()}` }
  }

  async refund(paymentId: string): Promise<RefundResult> {
    return { success: true, refundId: `REF-${paymentId}` }
  }
}

// Java (Mockito) の例
// @ExtendWith(MockitoExtension.class)
// class OrderServiceTest {
//     @Mock
//     PaymentGateway paymentGateway;
//
//     @Test
//     void 決済失敗時に注文キャンセル() {
//         when(paymentGateway.charge(anyDouble(), anyString()))
//             .thenReturn(new PaymentResult(false, "CARD_DECLINED"));
//         ...
//     }
// }

3. Mock 深掘り

3.1 MockとStubの違いを明確に理解する

この違いは非常に重要です。Martin Fowlerの「Mocks Aren't Stubs」で明確に区別されています。

区分StubMock
検証方式状態検証振る舞い検証
アサーション対象テスト対象オブジェクトの状態ダブルへの呼び出し
期待値設定戻り値のみ呼び出し回数・引数を事前設定
失敗タイミングテスト終了後期待値不一致で即時失敗
// Stub方式 - 状態検証
test('注文完了状態検証(Stub)', async () => {
  const stubPayment = new StubPaymentGateway()
  const orderService = new OrderService(stubPayment)

  const order = await orderService.placeOrder(items, cardToken)

  // 注文オブジェクトの状態を検証
  expect(order.status).toBe('COMPLETED')
  expect(order.paymentId).toBeDefined()
})

// Mock方式 - 振る舞い検証
test('注文完了時に決済API呼び出し検証(Mock)', async () => {
  const mockPayment = { charge: jest.fn().mockResolvedValue({ success: true, paymentId: 'PAY-1' }) }
  const orderService = new OrderService(mockPayment)

  await orderService.placeOrder(items, 'card-token')

  // 決済APIが正しい引数で呼ばれたか検証
  expect(mockPayment.charge).toHaveBeenCalledWith(totalAmount, 'card-token')
})

3.2 Mock過剰使用の危険性

Mockを乱用すると実装の詳細に結合されます。機能が同じでも内部実装が変わるとテストが壊れます。

// 悪い例 - 実装の内部手順を過剰に検証
test('ユーザー保存 - 過度なMock使用', async () => {
  const mockRepo = {
    beginTransaction: jest.fn(),
    save: jest.fn(),
    commitTransaction: jest.fn(),
    closeConnection: jest.fn(),
  }
  // 内部呼び出し順序に依存 - リファクタリングで壊れやすい
  expect(mockRepo.beginTransaction).toHaveBeenCalledBefore(mockRepo.save)
})

// 良い例 - 意味のある振る舞いのみ検証
test('ユーザー保存成功', async () => {
  const mockRepo = { save: jest.fn().mockResolvedValue(undefined) }
  const service = new UserService(mockRepo)

  await service.createUser({ name: '山田太郎', email: 'yamada@example.com' })

  expect(mockRepo.save).toHaveBeenCalledTimes(1)
})

3.3 "Don't mock what you don't own" 原則

外部ライブラリやサードパーティコードを直接Mockせず、それをラップするアダプターやラッパーインターフェースを作ってMockしましょう。

// 悪い例 - axiosを直接Mock
jest.mock('axios')
test('API呼び出し', async () => {
  ;(axios.get as jest.Mock).mockResolvedValue({ data: { name: '山田太郎' } })
  // ...
})

// 良い例 - アダプターを定義してアダプターをMock
interface HttpClient {
  get<T>(url: string): Promise<T>
  post<T>(url: string, data: unknown): Promise<T>
}

class AxiosHttpClient implements HttpClient {
  async get<T>(url: string): Promise<T> {
    const response = await axios.get<T>(url)
    return response.data
  }
  async post<T>(url: string, data: unknown): Promise<T> {
    const response = await axios.post<T>(url, data)
    return response.data
  }
}

// テストではHttpClientインターフェースをMock

4. Spyパターン

4.1 SpyとMockの違い

区分SpyMock
期待値設定タイミングテスト後に検証テスト前に設定
実際の実装選択的に実呼び出し可常にモック動作
用途実際の動作を維持しながら監視完全な置き換え

4.2 Jest spyOnの例

// spyOnは実際のオブジェクトのメソッドを監視
class EmailService {
  sendWelcomeEmail(user: User): void {
    console.log(`メール送信: ${user.email}`)
  }
}

test('会員登録時にウェルカムメール送信', () => {
  const emailService = new EmailService()
  const spy = jest.spyOn(emailService, 'sendWelcomeEmail')
  spy.mockImplementation(() => {}) // 実際の送信を防止

  const userService = new UserService(emailService)
  userService.register({ name: '山田太郎', email: 'yamada@example.com' })

  expect(spy).toHaveBeenCalledTimes(1)
  expect(spy).toHaveBeenCalledWith(expect.objectContaining({ email: 'yamada@example.com' }))

  spy.mockRestore() // 元の実装を復元
})

4.3 Go言語 Spyパターン

type SpyNotificationService struct {
    Calls    []NotificationCall
    delegate NotificationService
}

type NotificationCall struct {
    UserID  string
    Message string
    Type    string
}

func (s *SpyNotificationService) Notify(userID, message, notifType string) error {
    s.Calls = append(s.Calls, NotificationCall{
        UserID:  userID,
        Message: message,
        Type:    notifType,
    })
    if s.delegate != nil {
        return s.delegate.Notify(userID, message, notifType)
    }
    return nil
}

func (s *SpyNotificationService) WasCalledWith(userID, notifType string) bool {
    for _, call := range s.Calls {
        if call.UserID == userID && call.Type == notifType {
            return true
        }
    }
    return false
}

func TestOrderCompletion_SendsNotification(t *testing.T) {
    spy := &SpyNotificationService{}
    service := NewOrderService(spy)

    service.CompleteOrder("user-123", "order-456")

    if !spy.WasCalledWith("user-123", "ORDER_COMPLETE") {
        t.Error("注文完了通知が送信されていない")
    }
}

4.4 Java Mockito @Spy

// @Spyは実際のオブジェクトを監視
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Spy
    EmailService emailService = new EmailService();

    @InjectMocks
    UserService userService;

    @Test
    void 会員登録時にウェルカムメール送信() {
        userService.register(new User("山田太郎", "yamada@example.com"));

        verify(emailService, times(1)).sendWelcomeEmail(
            argThat(user -> user.getEmail().equals("yamada@example.com"))
        );
    }
}

5. Fakeオブジェクト

5.1 InMemoryDatabase Fake実装

Fakeは実際のデータベースと同様の動作をしますが、メモリ上に保存します。実際のDBなしで複数のサービスを統合テストできます。

// Go言語 インメモリFake Repository
type InMemoryUserRepository struct {
    mu    sync.RWMutex
    store map[string]*User
    seq   int64
}

func NewInMemoryUserRepository() *InMemoryUserRepository {
    return &InMemoryUserRepository{
        store: make(map[string]*User),
    }
}

func (r *InMemoryUserRepository) FindByID(id string) (*User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    user, ok := r.store[id]
    if !ok {
        return nil, ErrUserNotFound
    }
    copied := *user
    return &copied, nil
}

func (r *InMemoryUserRepository) Save(user *User) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if user.ID == "" {
        r.seq++
        user.ID = fmt.Sprintf("user-%d", r.seq)
    }
    copied := *user
    r.store[user.ID] = &copied
    return nil
}

func (r *InMemoryUserRepository) FindByEmail(email string) (*User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    for _, user := range r.store {
        if user.Email == email {
            copied := *user
            return &copied, nil
        }
    }
    return nil, ErrUserNotFound
}

func (r *InMemoryUserRepository) Reset() {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.store = make(map[string]*User)
    r.seq = 0
}

5.2 Fake EmailService実装

class FakeEmailService implements EmailService {
  private emails: Email[] = []

  async send(email: Email): Promise<void> {
    this.emails.push({ ...email, sentAt: new Date() })
  }

  // テストヘルパーメソッド
  getEmailsSentTo(address: string): Email[] {
    return this.emails.filter((e) => e.to === address)
  }

  getLastEmail(): Email | undefined {
    return this.emails[this.emails.length - 1]
  }

  clear(): void {
    this.emails = []
  }

  hasEmailWithSubject(subject: string): boolean {
    return this.emails.some((e) => e.subject.includes(subject))
  }
}

const fakeEmail = new FakeEmailService()

beforeEach(() => fakeEmail.clear())

test('会員登録時にウェルカムメール送信', async () => {
  const service = new UserService(fakeEmail)
  await service.register({ name: '山田太郎', email: 'yamada@example.com' })

  const emails = fakeEmail.getEmailsSentTo('yamada@example.com')
  expect(emails).toHaveLength(1)
  expect(emails[0].subject).toBe('ようこそ!')
})

test('パスワードリセットメール送信', async () => {
  const service = new UserService(fakeEmail)
  await service.requestPasswordReset('yamada@example.com')

  expect(fakeEmail.hasEmailWithSubject('パスワードリセット')).toBe(true)
})

5.3 Fake Clock実装

interface Clock {
  now(): Date
  advance(ms: number): void
}

class FakeClock implements Clock {
  private currentTime: Date

  constructor(initialTime?: Date) {
    this.currentTime = initialTime ?? new Date('2026-03-17T09:00:00Z')
  }

  now(): Date {
    return new Date(this.currentTime)
  }

  advance(ms: number): void {
    this.currentTime = new Date(this.currentTime.getTime() + ms)
  }

  advanceBy(options: { hours?: number; minutes?: number; days?: number }): void {
    const ms =
      (options.hours ?? 0) * 3600000 +
      (options.minutes ?? 0) * 60000 +
      (options.days ?? 0) * 86400000
    this.advance(ms)
  }
}

test('セッショントークンの有効期限テスト', () => {
  const clock = new FakeClock()
  const authService = new AuthService(clock)

  const token = authService.createSession('user-123')
  expect(authService.isSessionValid(token)).toBe(true)

  // 25時間後
  clock.advanceBy({ hours: 25 })

  expect(authService.isSessionValid(token)).toBe(false)
})

5.4 FakeとMock: どちらを選ぶか

Fakeを選ぶ場合:
- 複数のテストで同じ依存関係を再利用する
- 依存関係が複雑な状態を持つ場合(例: データベース)
- テスト間の相互作用が重要な場合
- リファクタリングに強いテストが必要な場合

Mockを選ぶ場合:
- 特定の呼び出しシナリオを正確に検証する必要がある場合
- 外部サービスの副作用(side effect)をブロックしたい場合
- シンプルなユニットテストで素早くセットアップしたい場合

6. テスト戦略

6.1 テストピラミッド (Testing Pyramid)

Mike Cohanが提唱したクラシックなテスト戦略です。

           /\
          /  \
         / E2E\        少数、コスト高
        /------\
       /統合テスト\     中程度
      /------------\
     /ユニットテスト \  多数、コスト低
    /--------------\
  • ユニットテスト: 高速で独立したテスト。テストダブルを最も多く使用
  • 統合テスト: 複数のコンポーネント間の相互作用をテスト
  • E2Eテスト: 実際のユーザーシナリオ全体のフローをテスト

6.2 テストトロフィー (Kent C. Dodds)

React Testing Libraryの作者 Kent C. Doddsが提唱したモデルです。

        /\
       /e2e\
      /------\
     /統合テスト\   <- 中心
    /------------\
   /ユニットテスト \
  /--------------\
 / 静的型チェック  \  <- 基盤
/------------------\

「できるだけ統合テストを書こう」という哲学で、テストダブルを最小化して実際の動作に近いテストを推奨します。

"Write tests. Not too many. Mostly integration." - Kent C. Dodds

6.3 アイスクリームコーンアンチパターン

        /\
       /  \
      / E2E \        多数 - 遅くて不安定
     /--------\
    /統合テスト  \
   /------------\
  /ユニットテスト \   少数 - ユニットテスト不足
 /----------------\

E2Eテストに過度に依存するアンチパターンです。遅く、不安定で、失敗原因の特定が難しいです。

6.4 ハニカムテスト (Spotifyモデル)

Spotifyエンジニアリングチームがマイクロサービス環境で提唱したモデルです。

統合テストを中心に:
  - サービス内部統合テスト (Service Integration)
  - ユーザージャーニーテスト (User Journey)
  - ユニットテスト (Unit)

マイクロサービス環境でのサービス間コントラクトと内部統合に注力します。


7. TDD (テスト駆動開発)

7.1 Red-Green-Refactorサイクル

1. RED      - 失敗するテストを書く
2. GREEN    - テストを通過する最小限のコードを書く
3. REFACTOR - コード品質を改善する (テストは引き続き通過)

7.2 Outside-In TDD vs Inside-Out TDD

Londonスクール (Outside-In, Mockist)

  • 外部インターフェースから始めて内部へ進む
  • Mockを積極的に活用して協力オブジェクトを定義
  • ユーザーシナリオから始めてトップダウンで設計

Chicagoスクール (Inside-Out, Classicist)

  • 内部ドメインモデルから始めて外部へ拡張
  • 実際のオブジェクトを最大限使用、Mockを最小化
  • 安定したユニットテストを積み上げてから統合

7.3 TDD実践例 (GoでFizzBuzz)

// ステップ1: 失敗するテストを書く (RED)
func TestFizzBuzz(t *testing.T) {
    tests := []struct {
        input    int
        expected string
    }{
        {1, "1"},
        {3, "Fizz"},
        {5, "Buzz"},
        {15, "FizzBuzz"},
        {9, "Fizz"},
        {10, "Buzz"},
    }

    for _, tt := range tests {
        t.Run(fmt.Sprintf("input=%d", tt.input), func(t *testing.T) {
            got := FizzBuzz(tt.input)
            if got != tt.expected {
                t.Errorf("FizzBuzz(%d) = %q, want %q", tt.input, got, tt.expected)
            }
        })
    }
}

// ステップ2: 最小限のコードでテストを通過させる (GREEN)
func FizzBuzz(n int) string {
    switch {
    case n%15 == 0:
        return "FizzBuzz"
    case n%3 == 0:
        return "Fizz"
    case n%5 == 0:
        return "Buzz"
    default:
        return strconv.Itoa(n)
    }
}

// ステップ3: 必要に応じてリファクタリング (REFACTOR)

7.4 ショッピングカート TDD例

func TestCart_AddItem(t *testing.T) {
    cart := NewCart()
    item := Item{ID: "book-1", Name: "Clean Code", Price: 2500}

    cart.AddItem(item, 2)

    if cart.TotalItems() != 2 {
        t.Errorf("expected 2 items, got %d", cart.TotalItems())
    }
    if cart.TotalPrice() != 5000 {
        t.Errorf("expected 5000, got %d", cart.TotalPrice())
    }
}

func TestCart_ApplyDiscount(t *testing.T) {
    cart := NewCart()
    cart.AddItem(Item{ID: "item-1", Price: 10000}, 3)

    cart.ApplyDiscount(0.1) // 10%割引

    if cart.TotalPrice() != 27000 {
        t.Errorf("expected 27000 after 10%% discount, got %d", cart.TotalPrice())
    }
}

8. BDD (振る舞い駆動開発)

8.1 Given-When-Then構造

BDDはTDDをビジネス観点から拡張した方法論です。自然言語に近い形式でテストを書きます。

Given (前提条件): 初期コンテキストや事前条件
When  (行動):     ユーザーやシステムの動作
Then  (結果):     期待される結果

8.2 Cucumber/Gherkin文法

Feature: ショッピングカート決済
  As a ショッピングサイトの会員
  I want to 商品をカートに入れて購入する
  So that 商品を手に入れることができる

  Scenario: 正常決済
    Given カートに「Clean Code」が1冊入っている
    And 決済カードが登録されている
    When 決済ボタンを押す
    Then 注文が完了する
    And 注文確認メールが送信される

  Scenario: 在庫不足で決済失敗
    Given カートに「品切れ商品」が1個入っている
    When 決済ボタンを押す
    Then 「在庫が不足しています」というエラーが表示される
    And 注文が作成されない

8.3 TypeScript BDDスタイル (Jest)

describe('ショッピングカート決済', () => {
  describe('正常決済', () => {
    it('注文完了と確認メール送信', async () => {
      // Given
      const fakeEmail = new FakeEmailService()
      const fakePayment = new FakePaymentGateway()
      const cart = new Cart()
      cart.addItem({ id: 'book-1', name: 'Clean Code', price: 2500 })

      const orderService = new OrderService(fakePayment, fakeEmail)

      // When
      const order = await orderService.checkout(cart, 'card-token-123')

      // Then
      expect(order.status).toBe('COMPLETED')
      expect(fakeEmail.hasEmailWithSubject('注文完了')).toBe(true)
    })
  })

  describe('在庫不足', () => {
    it('在庫不足エラーで失敗', async () => {
      // Given
      const stubInventory = new StubInventoryService()
      stubInventory.setOutOfStock('out-of-stock-item')
      const cart = new Cart()
      cart.addItem({ id: 'out-of-stock-item', price: 1000 })

      const orderService = new OrderService(
        new FakePaymentGateway(),
        new FakeEmailService(),
        stubInventory
      )

      // When & Then
      await expect(orderService.checkout(cart, 'card-token')).rejects.toThrow(
        '在庫が不足しています'
      )
    })
  })
})

9. Contract Testing

9.1 Contract Testingとは

マイクロサービス環境でサービス間の**APIコントラクト(Contract)**をテストする手法です。すべてのサービスを起動せずにコントラクトのみを検証します。

9.2 Pact.io - Consumer-Driven Contract Testing

// ConsumerテストL (注文サービスがユーザーサービスAPIを消費)
import { PactV3, MatchersV3 } from '@pact-foundation/pact'

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'UserService',
  dir: './pacts',
})

describe('ユーザーサービス コントラクト', () => {
  test('ユーザー情報取得', () => {
    return provider
      .addInteraction({
        states: [{ description: 'user-123が存在する' }],
        uponReceiving: 'user-123取得リクエスト',
        withRequest: {
          method: 'GET',
          path: '/users/user-123',
        },
        willRespondWith: {
          status: 200,
          body: {
            id: MatchersV3.string('user-123'),
            name: MatchersV3.string('山田太郎'),
            email: MatchersV3.email('yamada@example.com'),
          },
        },
      })
      .executeTest(async (mockServer) => {
        const client = new UserServiceClient(mockServer.url)
        const user = await client.getUser('user-123')

        expect(user.id).toBe('user-123')
        expect(user.name).toBe('山田太郎')
      })
  })
})

9.3 Provider検証

// Provider側でConsumerが定義したコントラクトを検証
import { Verifier } from '@pact-foundation/pact'

test('UserServiceがOrderServiceのコントラクトを満たす', () => {
  return new Verifier({
    providerBaseUrl: 'http://localhost:3001',
    pactUrls: ['./pacts/OrderService-UserService.json'],
    stateHandlers: {
      'user-123が存在する': async () => {
        await testDatabase.seed({ id: 'user-123', name: '山田太郎', email: 'yamada@example.com' })
      },
    },
  }).verifyProvider()
})

10. クイズ

クイズ1: StubとMockの最大の違いは何ですか?

正解: 検証方式の違いです。Stubは**状態検証(State Verification)に使用され、Mockは振る舞い検証(Behavior Verification)**に使用されます。

解説: Stubはテスト対象オブジェクトの最終状態(戻り値、プロパティの変更など)を検証します。Mockは協力オブジェクト(依存関係)がどのように呼ばれたか(呼び出し回数、引数など)を検証します。Martin Fowlerの「Mocks Aren't Stubs」でこの違いが明確に説明されています。

クイズ2: "Don't mock what you don't own" 原則が意味することは?

正解: 外部ライブラリやサードパーティコードを直接Mockせず、それをラップするアダプターやラッパーインターフェースを作ってMockすべきという原則です。

解説: 外部ライブラリを直接MockするとライブラリのAPI変更時にテストが壊れる可能性があり、Mockがライブラリの実際の動作を正確に反映できない恐れもあります。アダプターパターンで抽象化するとテストの安定性が高まり、置き換えも容易になります。

クイズ3: FakeオブジェクトとStubの違いは?

正解: Fakeは実際に動作する簡略化された実装を持ち、Stubはあらかじめ設定された値を返すオブジェクトです。

解説: FakeUserRepositoryはインメモリマップを使って実際にCRUDを実行します。一方StubUserRepositoryは特定のIDに対してハードコードされた値を返すだけです。Fakeは複数のテストで再利用可能で、複雑な状態を持つ依存関係に適しています。

クイズ4: TDDのLondonスクールとChicagoスクールの違いは?

正解: Londonスクール(Mockist)は外部インターフェースからMockを積極利用してトップダウンで開発し、Chicagoスクール(Classicist)は内部ドメインモデルから実際のオブジェクトを使ってボトムアップで開発します。

解説: Londonスクールはまだ実装されていない協力オブジェクトをMockで先に定義して設計を誘導します。Chicagoスクールはドメインモデルをしっかり作ってから外部へ拡張します。どちらも有効なアプローチで、状況に応じて選択します。

クイズ5: テストトロフィーがテストピラミッドと異なる核心的な主張は?

正解: テストトロフィーは統合テストを最も多く書くべきだと主張します。単にユニットテストを積み上げるピラミッドより、実際の使用方法に近い統合テストがより価値があると考えます。

解説: Kent C. Doddsは「Write tests. Not too many. Mostly integration.」を強調します。Mock過剰使用によりユニットテストが実際の動作を保証できない問題を解決するため、実際の依存関係をより多く使う統合テストを中心に据えるべきだと主張します。