URLProtocolで再現可能なiOS APIオフラインテストを作る

URLProtocolで再現可能なiOS APIオフラインテストを作る

リモートビルドが実際のテストAPIに依存していると、同じコミットでも午前中は通り、午後には失敗するといった状況が起こりがちです。テストデータの書き換え、トークンの期限切れ、ゲートウェイのレート制限、あるいはAPIの一時的な遅延が原因になります。さらに厄介なのは、失敗時にデコードエラーしか残らないことが多く、クライアントが実際に何を受信したのか確認できない点です。解決策はリトライを増やすことではありません。ネットワーク層の検証を2種類に分け、大半の回帰テストではローカルの固定レスポンスを使い、少数の契約テストだけを実環境に接続します。

オフラインテストの境界を明確にする

URLProtocolはURLSessionのリクエスト処理経路に入り、リクエストが実際に送信される前にカスタムレスポンスを返せます。これにより、リクエストメソッドとパスが正しいか、ヘッダーとボディが揃っているか、モデルをデコードできるか、ビジネス層がステータスコードを明確なエラーへマッピングできるか、という4点を検証できます。

一方で、実際のサービスへ現在アクセスできることを証明したり、ゲートウェイ設定の変更を検出したりするものではありません。そのため、テストは次のように階層化することを推奨します。

レイヤー データソース 主な検証項目 実行頻度
単体回帰テスト ローカルJSONフィクスチャ デコードとエラーマッピング コミットごと
ネットワーク層回帰テスト URLProtocol リクエストとレスポンスの一連の処理 コミットごと
契約テスト 管理されたテストAPI フィールドと認証の契約 定期実行またはリリース前

オフライン回帰テストの目的は無関係な変動を排除することであり、必ず成功するリクエストを作り上げることではありません。404、429、500、空のレスポンス、破損したJSONも、すべて固定テストケースに含めるべきです。

独立したURLSessionを注入する

テストでプロトコルをグローバル登録せず、ビジネスコードからURLSession.sharedへ直接アクセスさせないようにします。APIClientにはセッションを注入し、本番環境ではデフォルト構成、テスト環境ではephemeral構成を使用します。これにより、ディスクキャッシュ、Cookie、過去の接続状態を引き継がずに済みます。

final class StubURLProtocol: URLProtocol {
    static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        request.url?.host == "api.test.invalid"
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }

    override func startLoading() {
        guard let handler = Self.handler else {
            client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
            return
        }

        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {}
}

func makeTestSession() -> URLSession {
    let configuration = URLSessionConfiguration.ephemeral
    configuration.protocolClasses = [StubURLProtocol.self]
    configuration.timeoutIntervalForRequest = 3
    return URLSession(configuration: configuration)
}

canInitでは、必ずホストまたはカスタムリクエストヘッダーによって対象を限定します。無条件にtrueを返すと、テストプロセス内にある別のURLSessionまで横取りされる可能性があり、エラーの原因を特定しにくくなります。

レスポンスフィクスチャをコードとして管理する

フィクスチャはテストTargetのFixtures/API/v1/配下に置き、リソースとシナリオに応じて命名することを推奨します。たとえば、projects-success.jsonprojects-empty.jsonprojects-malformed.jsonです。動的トークン、メールアドレス、内部アドレスを含む完全なパケットキャプチャを、そのまま保存してはいけません。

コミット前にキーの順序を統一し、構文を検証できます。

find Tests/Fixtures -name '*.json' -print0 |
while IFS= read -r -d '' file; do
  tmp="${file}.tmp"
  jq -S . "$file" > "$tmp" && mv "$tmp" "$file"
done

成功ケースでも、リクエスト自体を必ずアサートします。最終的なモデルだけを確認していると、クライアントがGETPOSTと誤って実装している、バージョンヘッダーを送っていない、クエリパラメータを二重にエンコードしている、といった問題を見逃すおそれがあります。

StubURLProtocol.handler = { request in
    XCTAssertEqual(request.httpMethod, "GET")
    XCTAssertEqual(request.url?.path, "/v1/projects")
    XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")

    let data = try Data(contentsOf: fixtureURL("projects-success"))
    let response = HTTPURLResponse(
        url: try XCTUnwrap(request.url),
        statusCode: 200,
        httpVersion: "HTTP/1.1",
        headerFields: ["Content-Type": "application/json"]
    )!
    return (response, data)
}

エラー分岐を種類ごとに検証する

少なくとも、通信エラー、HTTPエラー、コンテンツエラーを網羅します。通信エラーはURLError(.timedOut)をthrowさせて再現できます。HTTPエラーでは実際のステータスコードと構造化されたエラーボディを返します。コンテンツエラーでは200を返しつつ、必須フィールドがないJSON、型が誤っているJSON、途中で切れたJSONを渡します。ビジネス層では、この3種類をすべて同じ「不明なエラー」に変換してはいけません。

タイムアウトテストで実際に数十秒待つ必要はありません。handlerから対応するエラーを直接throwし、ビューモデルが再試行可能な状態へ移行するかを検証します。429については、クライアントがRetry-Afterを読み取っていることも確認します。ただし、テスト内で実際に待機してはいけません。バックオフ計算を純粋関数として切り出し、入力と結果を個別に検証します。

各テストの終了後はhandlerをnilに戻します。テストスイートを並列実行すると、単一の静的handlerが別のテストケースによって上書きされる可能性があります。最も堅実な出発点は、このスイートを直列実行することです。並列実行が必要な場合は、リクエストIDからレスポンスクロージャへのマッピングを使用し、ロックまたはactorでレジストリを保護します。

クラウドMac上で安定して実行する

DplyMiniのクラウドMacでは、まず使用するXcodeを固定し、同じworkspace、scheme、テストプランで実行します。専有物理マシンは仮想マシンではありませんが、異なるブランチ間で中間生成物を共有しないよう、テストではDerivedDataを明示的に分離する必要があります。

set -euo pipefail

sudo xcode-select -s /Applications/Xcode.app
rm -rf "$PWD/.derived-data"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -testPlan NetworkRegression \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$PWD/.derived-data" \
  -resultBundlePath "$PWD/TestResults/NetworkRegression.xcresult"

初回実行前にxcodebuild -showdestinationsを使い、現在の実行環境に存在するシミュレータ名を確認してください。個人用マシンのデバイス名を、そのままスクリプトへコピーしてはいけません。失敗時にはxcresult、テストログ、対応するフィクスチャのバージョンを保存します。コンソール出力の末尾数行だけを切り取って残すのは避けてください。

最終的には、外部ネットワークを切断してもオフラインスイートが完走すること、連続実行しても結果が一致すること、フィクスチャの変更をコードレビューで明確に確認できること、実際の契約テストが失敗してもクライアントロジックに回帰があるかどうかの判断を妨げないこと、という4点を満たす必要があります。これらを実現して初めて、ネットワークテストは「APIの運任せ」から、保守可能なエンジニアリング資産へ変わります。

よくある質問

URLProtocolのテストだけで実APIとの結合テストを置き換えられますか?

置き換えられません。リクエスト生成、デコード、エラー変換には有効ですが、認証、ゲートウェイ、サーバースキーマの変化を検出する少数の実環境契約テストも必要です。

テスト専用のURLSessionを注入する理由は何ですか?

ephemeral構成を注入すると差し替え範囲を対象コードに限定でき、共有キャッシュ、Cookie、グローバルなプロトコル登録による別テストへの影響を防げます。

並列テストで共有handlerの上書きを防ぐにはどうしますか?

単純な実装は直列実行にします。並列化する場合はリクエスト識別子ごとに応答を保存し、ロックまたはactorでレジストリを保護します。

専有物理Mac mini

タスクの長さに合わせてクラウドMacのレンタル期間を選択

2種類のM4構成を日単位、週単位、月単位、または四半期単位でレンタルできます。ノードと実際の利用可能状況は、コンソールからリアルタイムで返される情報をご確認ください。

構成を選択して注文