XcodeSelectiveTesting という OSS のツールを活用して、変更箇所に依存するテストターゲットのみを実行することができます。
このツールは Git の変更履歴から差分を分析してどのモジュールに対して変更があったのかを検知し、モジュール間の依存関係をもとにして、あるモジュールの変更が他のどのモジュールに影響を与え得るかを検出してくれます。パッケージ間の依存関係は swift package dump-package コマンドを使い、 Xcodeプロジェクトとターゲット間の依存関係は tuist/XcodeProj を使って解析しています。
マルチターゲット、マルチモジュール構成のプロジェクトであることが前提で、利用方法は README に書いてある通りなんですが、実際にプロジェクトに導入を進める際にいくつかハマりポイントがあったので掻い摘んで紹介します。
XcodeGen との併用
導入しようとするプロジェクトが XcogeGen を利用している場合、上述した tuist/XcodeProj を両方のツールが利用しておりバージョン解決ができません。Package.swift を分けるか、 artifactbundle をサポート済みなのでそちらを使うことをお勧めします。
ただ、本家のほうはpre-built なバイナリを使った Command Plugin をまだ用意してくれていないので、自前で用意したものを使っています。
上記を使うことで XcodeGen と同じ Package.swift で管理しつつ、ビルド時間の削減も実現できるので一石二鳥というわけです。
fastlane との連携
テストの実行で fastlane を使っている場合は一手間が必要です。 TestPlan を利用せずに fastlane の run_tests で意図したターゲットのみを実行するには only_testing のパラメーターにターゲットの情報を渡す必要があります。only_testing には Test Bundle/Test Suite/Test Cases の文字列の配列を渡すことができるので、その情報を渡せるよう lane を調整する必要があります。
lane :selective_test do |options| if options[:targets].nil? then UI.user_error!("targets parameter is invalid or missing.") end if options[:targets].empty? then UI.user_error!("targets parameter is empty") end run_tests( ... only_testing: options[:targets], ) end
fastlane ではパラメーターを渡すことができ、配列データを渡す場合はカンマ区切りの文字列として渡すことで実現できます。
$ bundle exec fastlane selective_test targets:"ATests,BTests"
最終的にはここに XcodeSelectiveTesting の実行で得たターゲット情報を渡すことで意図したテストのみを実行することが fastlane 経由でも可能になります。
TestPlan で管理していると、 XcodeSelectiveTesting がスキップするべきテストを認識し xctestplan ファイルを更新してくれます。TestPlan を使っていない場合は、そちらに移行することをまず検討してみてもいいかもしれません。
JSON 出力の取り扱い
XcodeSelectiveTesting は JSON 形式の出力をサポートしています。 fastlane や xcodebuild コマンドに情報を与えていく場合は jq を使って JSON から必要な情報を抽出&整形して活用する必要があります。
swift run xcode-selective-test --json で実行したとして、出力されるJSONは変更箇所に依存するターゲットがすべて含まれるため、以下のようになっています。
[ { "name" : "YourApp", "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj", "type" : "target", "testTarget" : false }, { "name" : "YourUITests", "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj", "type" : "target", "testTarget" : true }, { "name" : "YourTests", "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj", "type" : "target", "testTarget" : true } ]
ここからテストターゲットのみを抽出したいので、 jq の select を使います。最終的には YourUITests,YourTests というカンマ区切りの文字列が欲しいので name で map した上で join するといい感じに欲しい形にできました。
jq -r "[.[] | select(.testTarget == true)] | map(.name) | join(\",\")"
-r オプションでRAW出力にしておいてから、"" で囲むとうまく配列のデータとしてfastlaneに渡すことができたので、最終系はこのような感じになりました。
bundle exec fastlane selective_test targets:"$(swift run xcode-selective-test --json | jq -r "[.[] | select(.testTarget == true)] | map(.name) | join(\",\")")"
Command Plugin での実行
XcodeGen との併用のところで自前で用意した Command Plugin を利用していると書きましたが、Command Plugin 経由で実行する場合は少し込み入ったことをするはめになったのでそれを最後に紹介します。
SPM の問題*1なのか詳細はわかっていないのですが、JSON 出力以外の実行中に発生したログもすべて stdout として認識されており jq のパースで失敗してしまいます。JSON 箇所だけが欲しいので、その部分だけを抽出する一手間が必要になりました。JSON の出力は必ず [{ ... }] なのでそこだけ抜き出すようにします。
# OUTPUTS は xcode-selective-test の実行結果
#
# -- OUTPUTS はこのような感じです --
# Finding changeset for repository at YourApps.xcworkspace
# [WARN]: Path xxx is mentioned from package at xxx but does not exist
# [WARN]: File without path: self=XcodeProj.PBXBuildFile,
# self.file=Optional(XcodeProj.PBXVariantGroup),
# self.product=nil
# [WARN]: Path xxx is mentioned from package at xxx but does not exist
# [ <-- ここから
# {
# "name" : "YourTests",
# "path" : "\/Users\/fxwx23\/Projects\/hoge\/foo\/YourApps.xcodeproj",
# "type" : "target",
# "testTarget" : true
# }
# ] <-- ここまでが欲しい
# -----------
#
# ref: https://github.com/mikeger/XcodeSelectiveTesting/blob/0.9.4/Sources/SelectiveTestLogger/Logger.swift
#
OUTPUT_JSON=$(echo "$OUTPUTS" | grep -v '^\[WARN' | grep -v '^\[ERROR' | sed -n '/\[/,/\]/p')
出力されたログには [WARN] や [ERROR] が混ざっている場合がありそれらも対象になってしまうので事前に grep しておくと最後のJSONの配列部分だけを抜き出すことができます。
ここまでするなら swift run でもいい気もしますが、どうしても Command Plugin でという方には参考になるかもしれません。
最後に
XcodeSelectiveTesting の導入を実際のプロダクトに導入する際の Tips を紹介しました。コードベースが大きくテストターゲットがたくさんあるプロジェクトでは少なからず効果がありそうな印象です。都度すべてのテストが実行されてしまうのは時間やCIリソースの面で改善余地があるので、そういった面でもどれほどの効果があるかはしばらく運用してみたで紹介しようと思います!
*1:この辺が関係しているかも? https://github.com/apple/swift-package-manager/issues/7589