Xcode Cloud で変更したモジュールのテストだけ走らせる方法

やりたかったこと

マルチモジュールの iOS アプリを Xcode Cloud で回していると、PR のたびに無関係なモジュールのテストまで全部走るのが重荷になってきます。差分から影響範囲を割り出すツール mikeger/XcodeSelectiveTesting を使い、変更モジュールに関係するテストだけ実行する構成を組みました。ツール自体の概要は公式 README に任せ、ここでは Xcode Cloud で動かすときの具体的な構成に絞って書きます。

検証に使ったプロジェクトは app / feature / shared の 3 階層で、各モジュールに test target が紐付いています。

プロジェクトのモジュール構成と依存関係

FeatureA 内のソースを 1 ファイル触った PR では、FeatureATests と推移依存先の AppTests だけが走り、FeatureBTests NetworkingTests UIComponentsTests はスキップされる。これを実現したい構成です。

XcodeSelectiveTesting は .xctestplan 専用

XcodeSelectiveTesting は .xctestplan の各 testTarget の enabled フラグを切り替えて、非該当ターゲットを skip します。書き換え対象になるのはスキームから参照されている Test Plan だけです。SelectiveTestingTool.swift の該当ロジックを抜粋するとこうなっています。

let plansToUpdate = testPlans.isEmpty
    ? workspaceInfo.candidateTestPlans
    : testPlans.map { plan in /* ... */ }

if !plansToUpdate.isEmpty {
    for testPlan in plansToUpdate {
        try enableTests(at: Path(testPlan), targetsToTest: affectedTargets)
    }
} else if !printJSON {
    // 影響を受けるターゲットを log に出して終わる
}

Test Plan が見つからないと plansToUpdate が空になり、影響範囲を log に出すだけで enableTests(...) を呼ばずに exit 0 します。Test Plan を参照していないスキームを渡すと絞り込みが起きず、ログだけ出るので動いているように見えるのが厄介です。

普段の開発で Cmd+U から全テストを回すために全 test target を入れて Shared 運用しているメインスキームを、そのまま Xcode Cloud のテストアクションに指定すれば動きます。新たにスキームを作る必要はありません。

最低限の構成

普段の開発で使っているメインスキームが Test Plan 構成で、xcshareddata/xcschemes/ 配下に共有されていれば、それをそのまま Xcode Cloud に渡すだけで済みます。Test Plan を持っていないなら、絞り込み対象にしたい test target をすべて含む .xctestplan を 1 つ用意してスキームに紐付けます。

.xctestplan 自体は JSON で、自前で用意する場合の構造はこうなります。

{
  "configurations" : [
    { "id" : "AC78...", "name" : "Configuration 1", "options" : {} }
  ],
  "defaultOptions" : { },
  "testTargets" : [
    {
      "enabled" : true,
      "parallelizable" : false,
      "target" : { "containerPath" : "container:App.xcodeproj",
                   "identifier" : "ABCD1234", "name" : "FeatureATests" }
    },
    {
      "enabled" : true,
      "parallelizable" : false,
      "target" : { "containerPath" : "container:App.xcodeproj",
                   "identifier" : "EFGH5678", "name" : "FeatureBTests" }
    }
  ],
  "version" : 1
}

identifier は target の BlueprintIdentifier と一致している必要があります。自前で書くなら、対応する .xcscheme から値を拾います。

grep -A1 BlueprintIdentifier \
    App.xcodeproj/xcshareddata/xcschemes/FeatureATests.xcscheme

ただ Xcode が用意してくれる .xctestplan をそのままコミットしておけば事足りるケースが大半です。

次に CLI 本体を CI から呼べる状態にします。xcode-selective-test は SPM の executable product として配布されているので、リポジトリに小さな Swift Package を 1 つ足すだけです。たとえば Tools/Package.swift を次の内容で置きます。

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "Tools",
    platforms: [.macOS(.v12)],
    dependencies: [
        .package(url: "https://github.com/mikeger/XcodeSelectiveTesting", from: "0.14.6"),
    ]
)

xcode-selective-test は依存パッケージ側の executable product なので、ここで再定義する必要はありません。swift run --package-path Tools xcode-selective-test ... の形で呼び出します。

ci_post_clone.sh で selective testing を呼びます。このスクリプトは Xcode Cloud で PR トリガーのワークフローから動かす前提です(CI_PULL_REQUEST_TARGET_BRANCH がセットされるのは PR トリガーのときに限られます)。

#!/bin/bash
set -e

cd "$CI_PRIMARY_REPOSITORY_PATH"

if [ -n "$CI_PULL_REQUEST_TARGET_BRANCH" ]; then
    git fetch --depth=200 origin \
        "+refs/heads/$CI_PULL_REQUEST_TARGET_BRANCH:refs/remotes/origin/$CI_PULL_REQUEST_TARGET_BRANCH"

    swift run --package-path Tools -c release xcode-selective-test \
        App.xcodeproj \
        --base-branch "origin/$CI_PULL_REQUEST_TARGET_BRANCH" \
        --verbose
fi

Xcode Cloud のチェックアウトは shallow + single-branch なので、何もしないと origin/<base-branch> のリモート追跡 ref がローカルに存在しません。xcode-selective-test は base ブランチとの diff を取るのでこの ref が必要で、明示 refspec +refs/heads/<branch>:refs/remotes/origin/<branch> で強制的に作っています。--depth=200 は merge base が辿れる程度に履歴を確保するためです。

非 PR ビルドでは何もしないので、Test Plan の中身がそのまま全テスト実行されます。-c releaseswift run のビルド構成指定で xcode-selective-test 自体に渡すわけではありません。*.xcworkspace で運用しているなら引数を App.xcworkspace に変えるだけで、CLI 側の挙動は同じです。

まとめ

マルチモジュール構成でモジュールごとにテストケースがある場合は参考にしてみてください。

参考リンク