Skip to content

Instantly share code, notes, and snippets.

@y-hirakaw
Created October 5, 2025 02:08
Show Gist options
  • Select an option

  • Save y-hirakaw/66dcfdd1dda6d25ba01387a6070fba3b to your computer and use it in GitHub Desktop.

Select an option

Save y-hirakaw/66dcfdd1dda6d25ba01387a6070fba3b to your computer and use it in GitHub Desktop.
モック一元化ガイド

モック一元化ガイド(軽量版)

目的

テストコード内に散在しているモック定義を一箇所に集約し、以下の問題を解決します:

  • ❌ 同じRepositoryのモックが複数のテストファイルに重複定義されている
  • ❌ モックの存在を知らずに新しいメンバーがローカルで再定義してしまう
  • ❌ モックの仕様変更時に複数箇所を修正する必要がある

アプローチ

パターン1: Mocks列挙型を採用します。このパターンは:

  • ✅ 既存のStore/ViewModelコードを変更不要
  • ✅ 今日から段階的に移行可能
  • ✅ 学習コストが低い
  • ✅ DependencyProtocolへの将来的な移行も可能

実装手順

Step 1: Mocks.swift ファイルを作成

テストターゲット内に Mocks.swift ファイルを作成します:

import Foundation
@testable import YourApp

/// すべてのテスト用モック実装を集約する列挙型
/// - Note: 新しいモックを追加する際は必ずここに定義すること
enum Mocks {
    // この中にすべてのモック実装を配置
}

配置場所例:

YourAppTests/
  ├── Mocks/
  │   └── Mocks.swift          # ← ここに作成
  ├── UserStoreTests.swift
  └── RoomStoreTests.swift

Step 2: 既存モックを Mocks 配下に移行

各テストファイルに散在しているモック定義を Mocks 列挙型の中に移動します。

Before(移行前):

// UserStoreTests.swift
class MockUserRepository: UserRepositoryProtocol {
    var stubbedUser: User?
    func fetch() async throws -> User {
        return stubbedUser ?? User(id: 1, name: "Test")
    }
}

class UserStoreTests: XCTestCase {
    func testFetchUser() async throws {
        let mock = MockUserRepository()
        // ...
    }
}
// ProfileViewTests.swift
// 同じモックが重複定義されている!
class MockUserRepository: UserRepositoryProtocol {
    var stubbedUser: User?
    func fetch() async throws -> User {
        return stubbedUser ?? User(id: 1, name: "Default")
    }
}

After(移行後):

// Mocks.swift
enum Mocks {
    class UserRepository: UserRepositoryProtocol {
        var stubbedUser: User?
        var fetchCallCount = 0  // 呼び出し回数検証用

        func fetch() async throws -> User {
            fetchCallCount += 1
            return stubbedUser ?? User(id: 1, name: "Test User")
        }
    }
}
// UserStoreTests.swift
class UserStoreTests: XCTestCase {
    func testFetchUser() async throws {
        let mock = Mocks.UserRepository()  // ← Mocks配下から取得
        mock.stubbedUser = User(id: 1, name: "Test")

        let store = UserStore(repository: mock)
        try await store.loadUser()

        XCTAssertEqual(mock.fetchCallCount, 1)
    }
}
// ProfileViewTests.swift
class ProfileViewTests: XCTestCase {
    func testShowProfile() async throws {
        let mock = Mocks.UserRepository()  // ← 同じモックを再利用
        mock.stubbedUser = User(id: 2, name: "Alice")
        // ...
    }
}

Step 3: すべてのRepositoryモックを定義

プロジェクト内のすべてのRepositoryに対応するモックを Mocks 配下に定義します:

// Mocks.swift
enum Mocks {
    // MARK: - Repositories

    /// ユーザー情報取得用モック
    class UserRepository: UserRepositoryProtocol {
        var stubbedUser: User?
        var fetchCallCount = 0

        func fetch() async throws -> User {
            fetchCallCount += 1
            return stubbedUser ?? User(id: 1, name: "Test User")
        }
    }

    /// ルーム一覧取得用モック
    class RoomRepository: RoomRepositoryProtocol {
        var stubbedRooms: [Room] = []
        var fetchListCallCount = 0

        func fetchList() async throws -> [Room] {
            fetchListCallCount += 1
            return stubbedRooms
        }
    }

    /// メッセージ取得用モック
    class MessageRepository: MessageRepositoryProtocol {
        var stubbedMessages: [Message] = []
        var fetchMessagesCallCount = 0

        func fetchMessages(roomId: Int) async throws -> [Message] {
            fetchMessagesCallCount += 1
            return stubbedMessages
        }
    }

    // ... 他のRepository
}

Step 4: テストファイルの更新

既存のテストファイルを順次更新していきます:

// RoomStoreTests.swift
class RoomStoreTests: XCTestCase {
    func testFetchRoomList() async throws {
        // 1. Mocks配下からモックを取得
        let mock = Mocks.RoomRepository()

        // 2. テストデータをスタブ
        mock.stubbedRooms = [
            Room(id: 1, name: "General"),
            Room(id: 2, name: "Random")
        ]

        // 3. Storeにモックを注入
        let store = RoomStore(repository: mock)

        // 4. テスト実行
        try await store.loadRooms()

        // 5. 検証
        XCTAssertEqual(mock.fetchListCallCount, 1)
        XCTAssertEqual(store.rooms.count, 2)
    }
}

モック実装のベストプラクティス

1. スタブ用プロパティの命名規則

class UserRepository: UserRepositoryProtocol {
    // ✅ Good: stubbed[型名]
    var stubbedUser: User?
    var stubbedError: Error?

    // ❌ Bad: 曖昧な命名
    var result: User?
    var testData: User?
}

2. 呼び出し回数の追跡

class UserRepository: UserRepositoryProtocol {
    // ✅ Good: [メソッド名]CallCount
    var fetchCallCount = 0
    var updateCallCount = 0

    func fetch() async throws -> User {
        fetchCallCount += 1
        // ...
    }

    func update(user: User) async throws {
        updateCallCount += 1
        // ...
    }
}

3. 引数の記録

class MessageRepository: MessageRepositoryProtocol {
    var fetchMessagesCallCount = 0
    var capturedRoomIds: [Int] = []  // ✅ 渡された引数を記録

    func fetchMessages(roomId: Int) async throws -> [Message] {
        fetchMessagesCallCount += 1
        capturedRoomIds.append(roomId)
        return stubbedMessages
    }
}

// テストでの利用
func testFetchMessagesWithCorrectRoomId() async throws {
    let mock = Mocks.MessageRepository()
    let store = MessageStore(repository: mock)

    try await store.loadMessages(roomId: 123)

    XCTAssertEqual(mock.capturedRoomIds, [123])  // 正しいIDが渡されたか検証
}

4. エラーケースのテスト

class UserRepository: UserRepositoryProtocol {
    var stubbedUser: User?
    var stubbedError: Error?  // ✅ エラーをスタブ可能に

    func fetch() async throws -> User {
        if let error = stubbedError {
            throw error
        }
        return stubbedUser ?? User(id: 1, name: "Test")
    }
}

// テストでの利用
func testHandleNetworkError() async throws {
    let mock = Mocks.UserRepository()
    mock.stubbedError = NetworkError.timeout  // エラーをスタブ

    let store = UserStore(repository: mock)

    do {
        try await store.loadUser()
        XCTFail("Should throw error")
    } catch {
        XCTAssertTrue(error is NetworkError)
    }
}

移行チェックリスト

  • Mocks.swift ファイルを作成
  • すべてのRepositoryプロトコルに対応するモックを Mocks 配下に定義
  • 各テストファイル内の重複モック定義を削除
  • すべてのテストが Mocks.[Repository名] を使用するよう更新
  • CI/CDが正常に動作することを確認
  • チームメンバーに新しいルールを共有
    • 新しいモックは必ず Mocks.swift に追加
    • ローカルでモックを定義しない

よくある質問

Q1: 既存のテストが動かなくなった場合は?

A: モック定義を Mocks 配下に移動する際、プロパティ名やメソッド名が変わっていないか確認してください。

// Before
class MockUserRepo: UserRepositoryProtocol { ... }

// After
class UserRepository: UserRepositoryProtocol { ... }  // ← クラス名が変わった

// テストの修正
// let mock = MockUserRepo()  // Before
let mock = Mocks.UserRepository()  // After

Q2: モックに新しい機能を追加したい場合は?

A: Mocks.swift のモッククラスを直接編集してください。変更は自動的にすべてのテストに反映されます。

// Mocks.swift
enum Mocks {
    class UserRepository: UserRepositoryProtocol {
        var stubbedUser: User?
        var fetchCallCount = 0
        var shouldSimulateSlowNetwork = false  // ← 新機能追加

        func fetch() async throws -> User {
            fetchCallCount += 1

            if shouldSimulateSlowNetwork {
                try await Task.sleep(nanoseconds: 2_000_000_000)
            }

            return stubbedUser ?? User(id: 1, name: "Test")
        }
    }
}

Q3: Storeのモックも同じように管理すべき?

A: はい、推奨します。同じパターンで Mocks.Store を追加できます:

enum Mocks {
    // MARK: - Repositories
    class UserRepository: UserRepositoryProtocol { ... }

    // MARK: - Stores
    class UserStore: UserStoreProtocol {
        var stubbedUser: User?
        var loadUserCallCount = 0

        func loadUser() async throws {
            loadUserCallCount += 1
        }
    }
}

Q4: 将来的にDependencyProtocolパターンに移行したい場合は?

A: このパターンはDependencyProtocolへの移行パスが確保されています:

// 現在のパターン(軽量版)
let mock = Mocks.UserRepository()
let store = UserStore(repository: mock)

// 将来的にDependencyProtocolパターンへ移行
enum MockDependency: DependencyProtocol {
    typealias UserRepository = Mocks.UserRepository  // ← 既存のモックを再利用
}

let store = UserStore<MockDependency>(repository: .init())

モック実装自体は変更不要で、型システム側だけ変更すれば移行できます。

まとめ

この軽量版アプローチにより:

  • ✅ モック定義の重複を即座に解消
  • ✅ 既存コードへの影響を最小化
  • ✅ チーム全体でモック管理の一貫性を確保
  • ✅ 将来的な高度なパターンへの移行も可能

まずは1つのRepositoryから始めて、効果を確認しながら段階的に展開していくことをお勧めします。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment