はじめに

TDD(テスト駆動開発)を実践していると、「どこからテストを書き始めればよいのか」という疑問に直面することがあります。小さな関数から始めるべきか、それともシステム全体の振る舞いから始めるべきか。この問いに対する答えの一つがOutside-In TDDです。

Outside-In TDDは、ユーザーに最も近い「外側」から開発を始め、段階的に内部の実装へと進んでいくアプローチです。受け入れテスト(Acceptance Test)を起点として、必要なコンポーネントを「発見」しながら設計と実装を同時に進めていきます。

本記事では、Outside-In TDDの基本概念から実践的なワークフロー、そしてInside-Out TDDとの比較まで、体系的に解説します。この記事を読み終える頃には、要件駆動のテスト設計と、モックを活用した効果的な開発アプローチを習得できているでしょう。

Outside-In TDDとは

Outside-In TDDは、システムの「外側」つまりユーザーインターフェースやAPIエンドポイントからテストを開始し、内部の実装へと段階的に進んでいく開発アプローチです。London School TDDまたはMockist TDDとも呼ばれます。

基本的な考え方

Outside-In TDDでは、以下の順序で開発を進めます。

  1. ユーザーストーリーや要件から始める
  2. 受け入れテスト(外側のテスト)を書く
  3. そのテストを通すために必要なコンポーネントを「発見」する
  4. 発見したコンポーネントに対する単体テストを書く
  5. 実装を完成させ、すべてのテストをパスさせる
graph TD
    subgraph "Outside(外側)"
        A[受け入れテスト]
        B[コントローラー/API]
    end
    
    subgraph "Middle(中間層)"
        C[サービス層]
        D[ビジネスロジック]
    end
    
    subgraph "Inside(内側)"
        E[リポジトリ]
        F[外部API連携]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E
    D --> F
    
    style A fill:#e3f2fd,stroke:#1565c0,color:#000000
    style B fill:#e3f2fd,stroke:#1565c0,color:#000000
    style C fill:#fff3e0,stroke:#e65100,color:#000000
    style D fill:#fff3e0,stroke:#e65100,color:#000000
    style E fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style F fill:#e8f5e9,stroke:#2e7d32,color:#000000

このアプローチの核心は、実装の詳細を考える前に、「何が必要か」を明確にすることにあります。

London School と Detroit School

TDDのアプローチには大きく分けて2つの流派があります。

観点 London School(Outside-In) Detroit School(Inside-Out)
起点 ユーザーストーリー/受け入れテスト ドメインモデル/単体テスト
進行方向 外側から内側へ 内側から外側へ
テストダブル 積極的に使用 最小限に抑える
設計へのアプローチ 発見的(Emergent) ボトムアップ
検証方法 振る舞い検証(Behavior) 状態検証(State)

London Schoolの名前は、ロンドンのXP(エクストリーム・プログラミング)コミュニティで発展したことに由来します。一方、Detroit Schoolは、XPが最初に実践されたデトロイト(クライスラーのC3プロジェクト)にちなんで名付けられました。

Double Loop TDD

Outside-In TDDの実践において中心的な概念がDouble Loop TDDです。これは、外側のループ(受け入れテスト)と内側のループ(単体テスト)の2つのフィードバックループを使い分ける開発手法です。

Double Loopの構造

graph LR
    subgraph "外側のループ(Acceptance Test)"
        A1[Red: 受け入れテスト失敗] --> A2[機能実装を開始]
        A2 --> A3[Green: 受け入れテスト成功]
        A3 --> A4[Refactor: 全体の改善]
        A4 --> A1
    end
    
    subgraph "内側のループ(Unit Test)"
        U1[Red: 単体テスト失敗] --> U2[最小限の実装]
        U2 --> U3[Green: 単体テスト成功]
        U3 --> U4[Refactor: コード改善]
        U4 --> U1
    end
    
    A2 -.-> U1
    U3 -.-> A3
    
    style A1 fill:#ffcdd2,stroke:#c62828,color:#000000
    style A3 fill:#c8e6c9,stroke:#2e7d32,color:#000000
    style A4 fill:#e1bee7,stroke:#7b1fa2,color:#000000
    style U1 fill:#ffcdd2,stroke:#c62828,color:#000000
    style U3 fill:#c8e6c9,stroke:#2e7d32,color:#000000
    style U4 fill:#e1bee7,stroke:#7b1fa2,color:#000000

外側のループ(Outer Loop)

外側のループでは、ユーザーの視点からシステム全体の振る舞いを検証します。

  • 目的: ユーザーストーリーの完了を確認
  • スコープ: エンドツーエンドまたは統合レベル
  • 頻度: 機能単位で1回
  • 実行時間: 比較的長い

内側のループ(Inner Loop)

内側のループでは、個々のコンポーネントの振る舞いを検証します。

  • 目的: 各コンポーネントの正確性を確認
  • スコープ: 単体テストレベル
  • 頻度: 実装の各ステップで複数回
  • 実行時間: 非常に短い

Double Loopの流れ

実際の開発では、以下のような流れになります。

  1. 受け入れテストを書く(外側のループ: Red)
  2. 受け入れテストは失敗する
  3. 最初のコンポーネントの単体テストを書く(内側のループ: Red)
  4. 単体テストをパスさせる最小限の実装(内側のループ: Green)
  5. 必要に応じてリファクタリング(内側のループ: Refactor)
  6. 次のコンポーネントへ進む(内側のループを繰り返す)
  7. すべてのコンポーネントが完成し、受け入れテストがパスする(外側のループ: Green)
  8. 全体をリファクタリング(外側のループ: Refactor)

Outside-In TDDの実践

ここからは、具体的なコード例を使ってOutside-In TDDのワークフローを体験していきましょう。題材として、ユーザー登録機能を実装します。

ユーザーストーリー

まず、実装する機能のユーザーストーリーを明確にします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ユーザーストーリー:
「新規ユーザーとして、メールアドレスとパスワードで登録できる。
 登録後、ウェルカムメールを受け取る。」

受け入れ基準:
- メールアドレスとパスワードで登録できる
- パスワードは8文字以上必須
- 登録成功時、ユーザー情報がデータベースに保存される
- 登録成功時、ウェルカムメールが送信される
- 既存のメールアドレスでは登録できない

ステップ1: 受け入れテストを書く(外側のループ)

まず、機能全体の振る舞いを検証する受け入れテストを書きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// tests/acceptance/userRegistration.test.js
describe('ユーザー登録', () => {
  it('新規ユーザーを登録し、ウェルカムメールを送信する', async () => {
    // Arrange
    const registrationData = {
      email: 'test@example.com',
      password: 'securePassword123',
    }

    // Act
    const response = await request(app)
      .post('/api/users/register')
      .send(registrationData)

    // Assert
    expect(response.status).toBe(201)
    expect(response.body).toEqual({
      id: expect.any(String),
      email: 'test@example.com',
      createdAt: expect.any(String),
    })

    // ウェルカムメールが送信されたことを確認
    const sentEmails = await getTestEmails()
    expect(sentEmails).toHaveLength(1)
    expect(sentEmails[0].to).toBe('test@example.com')
    expect(sentEmails[0].subject).toContain('ようこそ')
  })
})

このテストは当然失敗します。まだ何も実装していないからです。これが外側のループの「Red」状態です。

ステップ2: コントローラーの設計と単体テスト

受け入れテストを通すために、まずコントローラー(エントリーポイント)から設計を始めます。Outside-In TDDでは、**まだ存在しない依存オブジェクトを「発見」**しながら進めます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// tests/unit/userController.test.js
describe('UserController', () => {
  describe('register', () => {
    it('有効なデータで登録し、201を返す', async () => {
      // Arrange
      const mockUserService = {
        registerUser: jest.fn().mockResolvedValue({
          id: 'user-123',
          email: 'test@example.com',
          createdAt: new Date('2026-01-05'),
        }),
      }
      
      const controller = new UserController(mockUserService)
      const req = {
        body: { email: 'test@example.com', password: 'securePassword123' },
      }
      const res = {
        status: jest.fn().mockReturnThis(),
        json: jest.fn(),
      }

      // Act
      await controller.register(req, res)

      // Assert
      expect(mockUserService.registerUser).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'securePassword123',
      })
      expect(res.status).toHaveBeenCalledWith(201)
      expect(res.json).toHaveBeenCalledWith({
        id: 'user-123',
        email: 'test@example.com',
        createdAt: expect.any(Date),
      })
    })
  })
})

ここで重要なのは、UserServiceというまだ存在しないクラスを「発見」していることです。コントローラーは「何をしたいか」だけを知っており、「どうやるか」はUserServiceに委譲します。

ステップ3: コントローラーの実装

単体テストをパスさせる最小限の実装を書きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// src/controllers/UserController.js
class UserController {
  constructor(userService) {
    this.userService = userService
  }

  async register(req, res) {
    const { email, password } = req.body
    
    const user = await this.userService.registerUser({ email, password })
    
    res.status(201).json({
      id: user.id,
      email: user.email,
      createdAt: user.createdAt,
    })
  }
}

module.exports = { UserController }

ステップ4: サービス層の設計と単体テスト

次に、コントローラーから「発見」されたUserServiceの設計に進みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// tests/unit/userService.test.js
describe('UserService', () => {
  describe('registerUser', () => {
    it('ユーザーを保存し、ウェルカムメールを送信する', async () => {
      // Arrange
      const mockUserRepository = {
        findByEmail: jest.fn().mockResolvedValue(null),
        save: jest.fn().mockResolvedValue({
          id: 'user-123',
          email: 'test@example.com',
          passwordHash: 'hashed-password',
          createdAt: new Date('2026-01-05'),
        }),
      }
      
      const mockEmailService = {
        sendWelcomeEmail: jest.fn().mockResolvedValue(true),
      }
      
      const mockPasswordHasher = {
        hash: jest.fn().mockResolvedValue('hashed-password'),
      }
      
      const userService = new UserService(
        mockUserRepository,
        mockEmailService,
        mockPasswordHasher
      )

      // Act
      const result = await userService.registerUser({
        email: 'test@example.com',
        password: 'securePassword123',
      })

      // Assert
      expect(mockUserRepository.findByEmail).toHaveBeenCalledWith('test@example.com')
      expect(mockPasswordHasher.hash).toHaveBeenCalledWith('securePassword123')
      expect(mockUserRepository.save).toHaveBeenCalledWith({
        email: 'test@example.com',
        passwordHash: 'hashed-password',
      })
      expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith('test@example.com')
      expect(result.id).toBe('user-123')
    })

    it('既存のメールアドレスの場合、エラーをスローする', async () => {
      // Arrange
      const mockUserRepository = {
        findByEmail: jest.fn().mockResolvedValue({ id: 'existing-user' }),
      }
      
      const userService = new UserService(
        mockUserRepository,
        {},
        {}
      )

      // Act & Assert
      await expect(
        userService.registerUser({
          email: 'existing@example.com',
          password: 'password123',
        })
      ).rejects.toThrow('このメールアドレスは既に登録されています')
    })
  })
})

ここでまた新たな依存オブジェクトが「発見」されました。

  • UserRepository: ユーザーの永続化を担当
  • EmailService: メール送信を担当
  • PasswordHasher: パスワードのハッシュ化を担当

ステップ5: サービス層の実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/services/UserService.js
class UserService {
  constructor(userRepository, emailService, passwordHasher) {
    this.userRepository = userRepository
    this.emailService = emailService
    this.passwordHasher = passwordHasher
  }

  async registerUser({ email, password }) {
    // 既存ユーザーの確認
    const existingUser = await this.userRepository.findByEmail(email)
    if (existingUser) {
      throw new Error('このメールアドレスは既に登録されています')
    }

    // パスワードのハッシュ化
    const passwordHash = await this.passwordHasher.hash(password)

    // ユーザーの保存
    const user = await this.userRepository.save({
      email,
      passwordHash,
    })

    // ウェルカムメールの送信
    await this.emailService.sendWelcomeEmail(email)

    return user
  }
}

module.exports = { UserService }

ステップ6: 内側のコンポーネントへ

同様のプロセスで、UserRepositoryEmailServicePasswordHasherの各コンポーネントを実装していきます。これらは「内側」のコンポーネントであり、最終的には実際のデータベースや外部サービスと連携します。

graph TD
    AT[受け入れテスト] --> UC[UserController]
    UC --> US[UserService]
    US --> UR[UserRepository]
    US --> ES[EmailService]
    US --> PH[PasswordHasher]
    
    UR --> DB[(データベース)]
    ES --> SMTP[SMTPサーバー]
    
    style AT fill:#e3f2fd,stroke:#1565c0,color:#000000
    style UC fill:#e3f2fd,stroke:#1565c0,color:#000000
    style US fill:#fff3e0,stroke:#e65100,color:#000000
    style UR fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style ES fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style PH fill:#e8f5e9,stroke:#2e7d32,color:#000000

Outside-In TDDのメリット

Outside-In TDDには、従来のTDDアプローチと比較して以下のようなメリットがあります。

ユーザー視点を常に意識できる

受け入れテストから開始することで、実装の詳細に埋没することなく、常にユーザーにとっての価値を意識した開発が可能になります。

必要なコンポーネントを「発見」できる

実装を始める前にすべての設計を決める必要がありません。テストを書く過程で、自然と必要なインターフェースや責務の分離が明確になります。

APIファーストの設計

依存オブジェクトのインターフェースを、実装前に定義できます。これにより、使いやすいAPIを持つコンポーネントが自然と生まれます。

過剰設計の防止

「必要になったら作る」というアプローチにより、YAGNI(You Aren’t Gonna Need It)の原則に沿った開発ができます。

テストの独立性

モックを活用することで、各コンポーネントのテストを完全に独立させることができます。これにより、テストの実行速度が向上し、障害の特定も容易になります。

Outside-In TDDのデメリットと注意点

一方で、Outside-In TDDには注意すべき点もあります。

モックの過剰使用リスク

モックを多用することで、テストが実装の詳細に密結合してしまう可能性があります。リファクタリング時にテストが壊れやすくなることがあります。

1
2
3
4
5
6
7
8
// 問題のあるテスト例:実装の詳細に依存しすぎ
it('正確な順序でメソッドが呼ばれることを検証', () => {
  const order = service.createOrder(data)
  
  expect(mockValidator.validate).toHaveBeenCalledBefore(mockRepository.save)
  expect(mockRepository.save).toHaveBeenCalledBefore(mockNotifier.notify)
  // 実装の順序を変えるとテストが壊れる
})

統合テストの重要性

単体テストだけでは、コンポーネント間の連携が正しく動作するかを検証できません。Outside-In TDDでは、受け入れテストや統合テストが特に重要になります。

学習コスト

モックやスタブの効果的な使い方を習得するには、ある程度の学習と経験が必要です。

Inside-Out TDDとの比較

Outside-In TDDを深く理解するために、Inside-Out TDD(Classic TDD / Detroit School)との比較を見てみましょう。

開発の進め方の違い

graph LR
    subgraph "Outside-In TDD"
        O1[受け入れテスト] --> O2[コントローラー]
        O2 --> O3[サービス]
        O3 --> O4[リポジトリ]
    end
    
    subgraph "Inside-Out TDD"
        I4[ドメインモデル] --> I3[リポジトリ]
        I3 --> I2[サービス]
        I2 --> I1[コントローラー]
    end
    
    style O1 fill:#e3f2fd,stroke:#1565c0,color:#000000
    style I4 fill:#e8f5e9,stroke:#2e7d32,color:#000000

使い分けの指針

シナリオ 推奨アプローチ
要件が明確で、ユーザーストーリーが具体的 Outside-In
ドメインロジックが複雑 Inside-Out
チームにTDD経験者が少ない Inside-Out
マイクロサービスやAPIの開発 Outside-In
既存システムへの機能追加 状況に応じて選択

併用するアプローチ

実際の開発では、両方のアプローチを組み合わせることが効果的な場合もあります。

  1. Outside-Inで全体の構造を設計
  2. Inside-Outでドメインロジックを実装
  3. Outside-Inで統合とAPIの調整

実践のためのヒント

Outside-In TDDを効果的に実践するためのヒントをいくつか紹介します。

テストリストを作成する

開発を始める前に、実装が必要なテストケースをリストアップしておきましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
ユーザー登録機能 テストリスト:
受け入れテスト:
- [ ] 正常な登録フロー
- [ ] 重複メールアドレスでのエラー
- [ ] 無効なパスワードでのエラー

コントローラー:
- [ ] 有効なリクエストで201を返す
- [ ] 無効なリクエストで400を返す
- [ ] サービスエラーで500を返す

サービス:
- [ ] ユーザーを保存する
- [ ] ウェルカムメールを送信する
- [ ] 重複チェックを行う

適切な粒度のモックを使う

モックは「協調オブジェクト」に対して使い、純粋な値オブジェクトには使わないようにしましょう。

1
2
3
4
5
6
7
// 良い例:協調オブジェクトをモック
const mockRepository = { save: jest.fn() }

// 避けるべき例:値オブジェクトをモック
const mockUser = { getName: jest.fn().mockReturnValue('田中') }
// 代わりに実際のオブジェクトを使う
const user = new User('田中')

コントラクトテストを追加する

モックとの整合性を保つため、統合テストやコントラクトテストを追加しておきましょう。

まとめ

Outside-In TDDは、ユーザー視点から開発を進めることで、価値のある機能を確実に実装するためのアプローチです。主なポイントを振り返りましょう。

  • Outside-In TDDは外側(ユーザーインターフェース)から内側(ドメイン)へ開発を進める
  • Double Loop TDDにより、受け入れテストと単体テストの2つのフィードバックループを活用する
  • モックを活用して依存オブジェクトを「発見」し、設計を進める
  • Inside-Out TDDと適切に使い分けることで、より効果的な開発が可能

Outside-In TDDは万能ではありませんが、特にAPIやマイクロサービスの開発、明確なユーザーストーリーがある場合に非常に効果的です。まずは小さな機能から試して、チームに合ったスタイルを見つけていきましょう。

参考リンク