CI でモックやスタブの実装をせず API レスポンスを差し替える

はじめに

iOS アプリの UI テストや結合テストを CI で実行するとき、外部 API への依存がよく問題になります。テスト実行時に API がダウンしていたり、レスポンスが変わっていたりすると、テストが不安定になりがちです。

この記事では、HTTP/HTTPS プロキシ & モックツール「Mimicry」の CLI スタンドアロンモード (mimicry serve) を使い、Xcode Cloud 上で API モックを動かす方法を紹介します。

mimicry serve コマンド

Mimicry はもともと GUI (Tauri + React) ベースのデスクトップアプリですが、mimicry serve サブコマンドを使えば GUI なしでプロキシ & モックが完結します。

# JSON ファイルからモックルールを読み込んでプロキシを起動
mimicry serve --rules ./mock-rules.json --port 8080

# iOS シミュレーターのプロキシ設定も自動構成(ローカル開発向け)
mimicry serve --rules ./mock-rules.json --port 8080 --configure-simulator

HTTPS インターセプトにも対応していて、CA 証明書の自動生成や指定が可能です。

Xcode Cloud での利用

スクリプト構成

Xcode Cloud では ci_pre_xcodebuild.sh の中で Mimicry のダウンロード・インストールとプロキシ起動をまとめて行います。このスクリプトは各 xcodebuild アクションの直前に実行されるため、テスト開始時にプロキシが確実に動作しています。

ci_pre_xcodebuild.sh はビルドフェーズでもテストフェーズでも呼ばれるため、CI_XCODEBUILD_ACTION 環境変数でテスト実行時のみに絞り込む必要があります。

ci_pre_xcodebuild.sh

CI_XCODEBUILD_ACTIONtest-without-building のときだけ Mimicry のインストールとプロキシ起動を行います。Xcode Cloud のテストフェーズではリポジトリのファイルが存在しないため、モックルールの JSON もスクリプト内で生成しています。

#!/bin/bash
set -euo pipefail

if [[ "${CI_XCODEBUILD_ACTION:-}" != "test-without-building" ]]; then
    exit 0
fi

MIMICRY_VERSION="5.6.4"
MIMICRY_DIR="${CI_PRIMARY_REPOSITORY_PATH:-/Volumes/workspace/repository}/.mimicry"
MIMICRY_BIN="$MIMICRY_DIR/Mimicry.app/Contents/MacOS/Mimicry"

if [ ! -f "$MIMICRY_BIN" ]; then
    mkdir -p "$MIMICRY_DIR"
    curl -sL -o "$MIMICRY_DIR/Mimicry.dmg" \
      "https://github.com/kusumotoa/mimicry-releases/releases/download/v${MIMICRY_VERSION}/Mimicry_${MIMICRY_VERSION}_universal.dmg"
    hdiutil attach "$MIMICRY_DIR/Mimicry.dmg" -nobrowse -quiet
    cp -R /Volumes/Mimicry/Mimicry.app "$MIMICRY_DIR/Mimicry.app"
    hdiutil detach /Volumes/Mimicry -quiet
fi

# テストフェーズではリポジトリのファイルが存在しないためスクリプト内で生成
cat > "$MIMICRY_DIR/mock-rules.json" <<'RULES'
{
  "rules": [
    {
      "id": "test-hello",
      "urlPattern": "/hello",
      "method": "GET",
      "statusCode": 200,
      "responseBody": "{\"message\":\"Hello from Mimicry!\"}",
      "headers": { "Content-Type": "application/json" },
      "enabled": true,
      "domains": ["kusumoto-test-no-domain"]
    }
  ],
  "allowedDomains": ["kusumoto-test-no-domain"],
  "noCaching": true
}
RULES

nohup "$MIMICRY_BIN" serve \
  --rules "$MIMICRY_DIR/mock-rules.json" \
  --port 8080 > "$MIMICRY_DIR/mimicry.log" 2>&1 &

sleep 3

実際の Xcode Cloud のログを見ると、ビルドフェーズでは Skipping (action: build-for-testing) と表示されてスキップされます。

ビルド時は ci_pre_xcodebuild.sh の処理をスキップ

テストフェーズでは Mimicry のダウンロードからプロキシ起動、モックレスポンスの検証まで一連の処理が実行されます。

テスト時は ci_pre_xcodebuild.sh でプロキシを起動

APIClient のセッション生成

アプリの APIClient で URLSession を生成する箇所に #if DEBUG でプロキシ設定を追加します。リリースビルドでは通常の通信になるので、プロダクションへの影響はありません。

Mimicry は HTTPS 通信をインターセプトする際に自己署名の CA 証明書を使うため、URLSessionDelegate で証明書検証をスキップする必要があります。#if DEBUG で囲むのでリリースビルドには影響しません。

struct APIClient {
    static func makeSession() -> URLSession {
        let config = URLSessionConfiguration.default
        #if DEBUG
        config.connectionProxyDictionary = [
            "HTTPSEnable": true,
            "HTTPSProxy": "localhost",
            "HTTPSPort": 8080,
        ]
        return URLSession(configuration: config, delegate: ProxyTrustDelegate(), delegateQueue: nil)
        #else
        return URLSession(configuration: config)
        #endif
    }
}

#if DEBUG
private final class ProxyTrustDelegate: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async
        -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        (.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
    }
}
#endif

テストコード

import Testing
import Foundation

struct MimicryProxyTests {

    @Test("Mimicry プロキシ経由でモックレスポンスが返る")
    func mockResponseThroughProxy() async throws {
        let session = APIClient.makeSession()
        let url = URL(string: "https://kusumoto-test-no-domain/hello")!

        let (data, response) = try await session.data(for: URLRequest(url: url))
        let httpResponse = try #require(response as? HTTPURLResponse)

        #expect(httpResponse.statusCode == 200)

        struct HelloResponse: Decodable {
            let message: String
        }

        let body = try JSONDecoder().decode(HelloResponse.self, from: data)
        #expect(body.message == "Hello from Mimicry!")
    }
}

モックルールの JSON 形式

mimicry serve は 2 種類の JSON 形式に対応しています。

シンプル形式はルール配列だけを記述します。

[
  {
    "id": "rule-1",
    "urlPattern": "/api/users",
    "method": "GET",
    "statusCode": 200,
    "responseBody": "{\"users\":[]}",
    "headers": { "Content-Type": "application/json" },
    "enabled": true,
    "domains": ["api.example.com"]
  }
]

フル形式はドメインフィルタやキャッシュ設定を含められます。

{
  "rules": [...],
  "allowedDomains": ["api.example.com"],
  "ignoredDomains": ["*.apple.com"],
  "noCaching": true
}

ハマったポイント

Xcode Cloud で動かすまでに遭遇した問題を紹介します。

1. フェーズ間でファイルもプロセスも保持されない

最初は ci_post_clone.sh で Mimicry をインストールしてプロキシを起動していましたが、テストフェーズでは /tmp のファイルもバックグラウンドプロセスも消えていました。Xcode Cloud はフェーズごとに環境がリセットされるようです。

インストール先をワークスペース内 ($CI_PRIMARY_REPOSITORY_PATH/.mimicry/) にし、プロキシの起動を ci_pre_xcodebuild.sh に移動することで解決しました。CI_XCODEBUILD_ACTION でテスト実行時のみに限定しています。

if [[ "${CI_XCODEBUILD_ACTION:-}" != "test-without-building" ]]; then
    exit 0
fi

2. シミュレーターにプロキシ設定が反映されない

プロキシは起動しているのに、テストの HTTP リクエストがプロキシを経由しない問題に当たりました。

NetworkProxy.plist の書き込みや networksetup によるシステムプロキシ設定、launchctl setenv http_proxy による環境変数設定など、思いつく方法を一通り試しましたが、いずれも効きません。

CI 環境の診断ログを見てみると、Xcode Cloud は QEMU ベースの仮想マシンで動作しており、Wi-Fi サービスが存在しないことがわかりました。

[Serve] No network services found

--- Wi-Fi HTTP proxy ---
** Error: Unable to find item in network database.
方式結果
NetworkProxy.plist 書き込み効かない
networksetup効かない(サービスが存在しない)
launchctl setenv http_proxy効かない
connectionProxyDictionary動作する

結局、テストコードで URLSessionConfiguration.connectionProxyDictionary を設定するのが唯一の方法でした。

3. HTTPS の CA 証明書が信頼されない

プロキシは経由するようになったものの、HTTPS 通信で証明書エラーが発生しました。Mimicry は HTTPS インターセプト時に自己署名の CA 証明書を使うため、シミュレーターにその CA を信頼させる必要があります。

simctl keychain add-root-cert はシミュレーターが起動していないと使えません。調べてみると、シミュレーターの証明書ストアは TrustStore.sqlite3 というファイルに保存されているようだったので、直接書き込みも試しました。しかし、シミュレーターが起動した時点でストアの内容がメモリにキャッシュされるらしく、後から書き換えても反映されませんでした。

最終的に URLSessionDelegate#if DEBUG 時のみ証明書検証をスキップする方式で解決しました。リリースビルドではコンパイルされないので、本番への影響はありません。具体的なコードは前述の APIClient セクションを参照してください。

動作確認

すべての問題を解決した後、Xcode Cloud 上でテストが通りました。

iPhone 17 Pro Max シミュレーターでテスト成功

Swift Testing のテスト結果で、iPhone 17 Pro Max シミュレーター上でモックレスポンスの検証が 0.1 秒で完了しています。

まとめ

今まで CI でのテストといえば、テストコード上でモックやスタブを用意するものだと思っていましたが、プロキシで API レスポンスを丸ごと差し替えるやり方もありだなと思いました。プロダクションコードに手を入れなくて済むのが気に入っています。CI 環境での API モックに悩んでいる方は、検討してみてください。

参考リンク