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 release は swift run のビルド構成指定で xcode-selective-test 自体に渡すわけではありません。*.xcworkspace で運用しているなら引数を App.xcworkspace に変えるだけで、CLI 側の挙動は同じです。
まとめ
マルチモジュール構成でモジュールごとにテストケースがある場合は参考にしてみてください。