Test

なぜUnitTestが書けないのか?〜今日から始める品質改善:応用編(モック利用)〜

1. はじめに

(この記事は、UnitTestの基本的な書き方を習得済みの方を対象としています。)

UnitTestを書いていくと、外部APIやデータベースなど、
外部のシステムに依存するコードをテストする必要が出てきます。

モックを使って外部システムとの連携を伴う複雑な処理をテストする方法を見ていきましょう。

2. モックの利用シチュエーション

  • たとえば以下のような状況下で、外部システムに依存する部分の単体テストを行いたいとします
    • バックエンド側でまだAPIを開発中。試験したいAPIが利用できない。
    • APIの特殊なエラーなどで、再現させるのが難しいパターンがある。
  • このような場合、モック(Mock)使用することで、外部システムの状態に左右されずに、関数自体の動作を検証できます。

3. モックの使用サンプル

  • 前提

    • ユーザー情報を取得するUserRepositoryというクラスがあり、そのクラスはAPIクライアントに依存しているとします。
    • このUserRepositoryのgetUser関数をテストしたいのですが、実際のAPIを毎回呼び出すのは都合が悪い場合 (例えばバックエンド側のAPIが完成していないなど)、モックを使います。
  • 要点

    以下の要点について、サンプルコード内の該当部分に詳細な解説を入れています

    • 【①】UserRepositoryはAPIClientプロトコルに依存するように設計されています。
    • 【②】MockAPIClientは、APIClientプロトコルに準拠したモックオブジェクトです。
    • 【③】UserRepositoryの初期化時に、本物のRealAPIClientではなく、MockAPIClientを渡しています。

    結論:疎結合な状態のため、UnitTest用に用意したモックデータを使用してUserRepositoryの単体テストが可能になっています。

// ユーザーリポジトリ(テスト対象)
class UserRepository {
    // APIクライアントへの依存
    // 【①】解説:UserRepositoryが具体的なRealAPIClientではなく、抽象的なAPIClientに依存しているので、
    //      テスト時にモックを簡単に注入できます。(「融通が利く」「柔軟に対応できる」状態)
    private let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }

    func getUser(id: Int) async throws -> User {
        let data = try await apiClient.fetch(path: "/users/\(id)")
        // APIのレスポンスからUserオブジェクトを生成する処理
        guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
              let id = json["id"] as? Int,
              let name = json["name"] as? String else {
            throw APIError.invalidResponse
        }
        return User(id: id, name: name)
    }
}

// APIクライアントのプロトコル
protocol APIClient {
    func fetch(path: String) async throws -> Data
}

// 本物のAPIクライアント(ここでは簡略化)
class RealAPIClient: APIClient {
    func fetch(path: String) async throws -> Data {
        // 実際のAPI呼び出しを行う処理
        // 例: URLSession.shared.data(from: url)
        // 今回は実装を省略
        print("RealAPIClient: APIを呼び出しています (\(path))")
        return Data() // 仮の空のDataを返す
    }
}

// ユーザー情報
struct User {
    let id: Int
    let name: String
}

// APIエラー
enum APIError: Error {
    case invalidResponse
}


// モックAPIクライアント:解説②
// 【②】解説:fetchメソッドは、事前に設定されたmockDataまたはmockErrorを返すように動作します。
class MockAPIClient: APIClient {
    var mockData: Data?
    var mockError: Error?

    func fetch(path: String) async throws -> Data {
        if let error = mockError {
            throw error
        }
        return mockData ?? Data()
    }
}

import XCTest
@testable import YourProjectName // プロジェクト名に合わせて変更

final class UserRepositoryTests: XCTestCase {

    func testGetUser_Success() async throws {
        // モックAPIクライアントの準備(正常系データを設定)
        let mockAPIClient = MockAPIClient()
        mockAPIClient.mockData = """
        {
            "id": 123,
            "name": "John Doe"
        }
        """.data(using: .utf8)! // テスト用のJSONデータ

        // UserRepositoryの初期化(モックAPIクライアントを注入)
        // 【③】解説:これは「依存性の注入」と呼ばれる方法で、
        // 疎結合な状態(UserRepositoryが具体的なRealAPIClientではなく、抽象的なAPIClientに依存している)のため実現できています。
        // もしも密結合な状態(UserRepositoryがRealAPIClientに直接依存する形)だった場合、
        // UserRepositoryをテストするためには、実際にAPIを呼び出す必要があり、
        // UnitTestで RealAPIClient の代わりに MockAPIClient を使用することができません。
        let userRepository = UserRepository(apiClient: mockAPIClient)

        // テスト対象のメソッドを実行
        let user = try await userRepository.getUser(id: 123)

        // 結果の検証
        XCTAssertEqual(user.id, 123)
        XCTAssertEqual(user.name, "John Doe")
    }

    func testGetUser_InvalidResponse() async throws {
        // モックAPIクライアントの準備(無効なJSONデータを設定)
        let mockAPIClient = MockAPIClient()
        mockAPIClient.mockData = """
        {
            "invalid": "data"
        }
        """.data(using: .utf8)!

        // UserRepositoryの初期化(モックAPIクライアントを注入)
        // 【③】解説:正常系と同じ考え方です
        let userRepository = UserRepository(apiClient: mockAPIClient)

        // エラーが発生することを確認
        await XCTAssertThrowsErrorAsync(try await userRepository.getUser(id: 123)) { error in
            XCTAssertEqual(error as? APIError, APIError.invalidResponse)
        }
    }
}

// async/await環境でのXCTAssertThrowsError
// ※XCTestにはasync/await環境でエラーが発生することを検証する
// XCTAssertThrowsErrorのasync版が用意されていないため、拡張関数として定義
func XCTAssertThrowsErrorAsync<T>(
    _ expression: @autoclosure () async throws -> T,
    _ message: @autoclosure () -> String = "",
    file: StaticString = #filePath,
    line: UInt = #line,
    _ errorHandler: (_ error: Error) -> Void = { _ in }
) async {
    do {
        _ = try await expression()
        XCTFail("No error thrown", file: file, line: line)
    } catch {
        errorHandler(error)
    }
}

4. まとめ

  • 設計と保守性向上
    スムーズにUnitTestが書けることは、再利用性・保守性の高い設計につながります。
    UnitTestが書きやすい状態にしていくことで、テスト工程の効率化だけでなく、プロジェクト内の構成の改善にも貢献できます。

モックを使うことで、外部システムの状態に左右されず、
API連携部分など、今までテストが難しかった箇所も効率的にUnitTestを実行できるようになりました。
UnitTestのスキルをさらに向上させて、テスト工程の効率化&より品質の高いコードを目指しましょう!


Latest最新記事

Popular人気の記事