Problem(s)
スナップショットテストとはUIが意図せず変更された時にそれを検知するためのテストです。目視確認ではなくツールで行うのが一般的です。CIと組み合わせて自動で差分を検知するワークフローを構築することもできますがいちチームメンバーが容易に始められるものではありません。そこで手軽にスクリーンショットの差分を検知する仕組み構築する手順を紹介したいと思います。
TL;DR スクリプト全体を手っ取り早く確認したい場合は記事の最後を参照ください
Solution
本記事ではUITest時にスクリーンショットの撮影を追加し、それをリポジトリに含めるようにします。これであれば意図しないUIの変更があった際にはその画像が差分としてgitが検知するようになります。また本件ではiOSSnapshotTestCaseを使用しない平易な方法で進めます。それではどういった手順でそのテストが行われるかを整理したいと思います。
- UIテストを行う
- スクリーンショットを撮影し保存する
- 保存されたスクリーンショットをリポジトリへアップロードする
ここではUIテストに関する設定はすべて完了しているものとし、UITests.swift
というUIテストファイルが作成されていることを前提として話を進めます。
スクリーンショットの保存
スクリーンショットの保存は以下の通りですがいくつか注意点があるため順を追って説明していきたいと思います。
class UITestCase: XCTestCase {
func testScreenCaptures() throws {
// アプリ起動
let app = XCUIApplication()
app.launch()
// スクリーンショット撮影
let screenshot = XCTAttachment(screenshot: app.windows.firstMatch.screenshot())
// 必須: スクリーンショットはテスト後も残す
screenshot.lifetime = .keepAlways
// 推奨: ファイル名
screenshot.name = "(1)メイン画面"
// スクリーンショット保存
add(screenshot)
}
}
-
スクリーンショット撮影 アプリのウィンドウを指定してスクリーンショットを撮影しています。大抵の場合はウィンドウは一つであるため
firstMatch
を使って最初に見つけられたウィンドウを指定してスクリーンショットを撮るのが良いでしょう。もしウィンドウが複数ある場合はIDを指定するelement(matching:identifier:)やビューの階層を指定するelement(boundBy:)を使うこともできます。こういった当該UIエレメントへのアクセスを提供するクラスはXCUIElementQueryが責任を持っており、その指定方法は様々なものがありますので説明は割愛しますが、とりわけビューのツリー階層を知らずしてアクセスすることは難しいためまずはUIテスト中にブレイクポイントで止め以下のコマンドでビュー階層を確認することをお勧めします。
po app.windows
-
ファイル名 スクリーンショットのファイル名で注意すべき点はこれはファイル名のプレフィクスとなるということです。
ファイル名_{番号}_{ユニークなID}.png
となります。スネークケースで意味が区切られいるためファイル名にアンダーバーを含めないのが賢明でしょう。
スクリーンショットの保存先
スクリーンショットはXcode上からアクセスすることができます。
しかし実体はDerivedDataの中にあり、このままだとgitで管理することができません。そこでDerivedDataの出力先をgitで管理しているディレクトリの配下に指定するスクリプトを書きましょう。本記事ではプロジェクトと同等の階層にsnaptest.sh
という名前でスクリプトファイルを作成します。
project="UITestSample.xcodeproj"
scheme="UITestSample"
simulatorname="iPhone SE (3rd generation)"
os="16.2"
work=".temp"
deriveddata="$work/derivedData"
# テスト結果は毎度ファイルが追加されるためワークディレクトリは毎回削除します。
rm -rf $work
xcodebuild test\
-project "$project"\
-scheme "$scheme"\
-destination "platform=iOS Simulator,name=$simulatorname,OS=$os"\
-derivedDataPath "$deriveddata"
イコールの間にスペースがないこと変数を出力するときは念押しでダブルクォートで囲むことを忘れないでください。シミュレータ名(simulatorname
)はxcrun simctl list
やXcode > Window > Devices and Simulators > Simulators
から設定・確認が可能です。
ワークスペース(.workspace
)を使っている場合は-project
の代わりに-workspace
を使用してください
一旦これでUITestが成功することを確認してください。
bash snaptest.sh
.temp/derivedData/Logs/Test
の中にTest-{Scheme}-{yyyy.MM.dd_HH-mm-ss-+000Z.xcresult
という形式でUITestの結果が出力されています。ここに先ほどXcodeから見た相当のデータが含まれているのです。
スクリーンショットのエクスポート
ですがこのxcresultファイルはApple独自の形式で構成されており、スクリーンショットへアクセスするにはもう一段階手順を踏む必要がありそうです。実際にはxcrun xcresulttool
を使って添付ファイルをエクスポートする必要がありますがこの記事にあるように相当困難な道のりです。今回はその記事を頼りxcparseを使ってこの煩雑さから目を背けることとしましょう。xcparseはxcresultファイルへのよりユーザーフレンドリーなCUIを開発者に提供しその背後にある複雑なxcresulttoolへのアクセスを隠蔽してくれます。Homebrew, Mint両方でのインストール方法がありますのでご自身のプロジェクトに合ったパッケージマネージャを利用してください。本記事ではMintで進めていくものとします。
それでは早速snaptest.sh
に以下のスクリプトを追加してxcresultファイルからスクリーンショットをエクスポートしましょう。
xcresults="$deriveddata/Logs/Test/*.xcresult"
screenshots="$work/screenshots"
for xcresult in $xcresults; do
mint run ChargePoint/xcparse xcparse screenshots --os --model --test-plan-config $xcresult $screenshots
done
この状態でもう一度スクリプトを実行してください。スクリーンショットが以下のディレクトリに配置されています。
スクリーンショット
スクリーンショットのファイル名を変更する
さていよいよスクリーンショットが見えるようになりましたがスクリーンショットのファイル名にユニークなIDが振られています。これだとファイル名が固定ではないためgitで同じファイルと見做されないという問題がありますのでファイル名を修正していきましょう。また.temp
ディレクトリは作業場所のため作業後はクリアしスクリーンショットだけをSnapshots
というディレクトリに移していきたいと思います。
- Before:
.temp/screenshots/iPhone SE (3rd generation) (16.2)/(1)メイン画面_1_83CD2EDB-E157-46EA-B7B5-E30A5F482AB8.png
- After:
Snapshots/(1)メイン画面.png
例によってsnaptest.sh
に以下のスクリプトを追記していきます。
screenshotDetails="$screenshots/$simulatorname ($os)/Test Scheme Action"
output="Snapshots"
# アウトプット先のディレクトリを作成
mkdir -p $output
for png in "$screenshotDetails/"*".png"; do
# ファイル名だけを抜き出す xxxx/yyyy_zzz.png => yyyy_zzz.png
name=`basename "$png"`
# アンダーバー区切りにして最初に見つかった文字列を抜き出す yyyy_zzz.png => yyyy
prefix=`echo "$name" | awk -F'_' '{print $1}'`
# ドット区切りにして2番目に見つかった文字列を抜き出す yyyy_zzz.png => png
ext=`echo "$name" | awk -F'.' '{print $2}'`
# 変更後ののファイスパス Snapshots/yyyy.png へ移動する
newPath="$output/$prefix.$ext"
mv "$png" "$newPath"
done
この状態で実行すると以下のようにSnapshots
に整形されたファイル名でスクリーンショットが移動しているのが確認できます。
ステータスバーの時刻と差分
しかしいざこれを運用しようとすると常に画像に差分が出るという問題が出ます。それはスクリーンショットに映った時刻が常に異なるため差分として検知されてしまうからです。
シミュレータは使用しているMacの時刻を参照し、いうまでもなくテスト中にも刻一刻と変化します。そこでその時刻がテスト中に変更されなようにxcrun simctl status_bar
サブコマンドを使用します。このサブコマンドでステータスバーに表示される情報を固定値に書き換えることができます。
シミュレータを指定する
といきたいところですが、そのコマンドの性質上、シミュレータを指定してサブコマンドを送信するため、チーム内で同じシミュレータを共有していないと後々問題になりそうです。そこでまずはxcrun simctl
を使ってシミュレータを生成するスクリプトを書いてきましょう。
project="UITestSample.xcodeproj"
scheme="UITestSample"
+ simulatornameForScreenShot="iPhone SE (3rd generation)"
+ simulatorname="$simulatornameForScreenShot for UITest"
- simulatorname="iPhone SE (3rd generation)"
+ modeltype="com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation"
+ runtime="com.apple.CoreSimulator.SimRuntime.iOS-16-2"
os="16.2"
work=".temp"
deriveddata="$work/derivedData"
# テスト結果は毎度ファイルが追加されるためワークディレクトリは毎回削除します。
rm -rf $work
+ xcrun simctl shutdown all
+ xcrun simctl delete "$simulatorname"
+ xcrun simctl create "$simulatorname" "$modeltype" "$runtime"
+ xcrun simctl boot "$simulatorname"
xcodebuild test\
-project "$project"\
-scheme "$scheme"\
-destination "platform=iOS Simulator,name=$simulatorname,OS=$os"\
-derivedDataPath "$deriveddata"
~以下略~
各サブコマンドの意味は以下のとおりです。
-
shotdown
: シミュレータを終了する- all を指定して起動中のすべてのシミュレータを終了しています。これはバックグラウンドで動いているシミュレータを念の為終了しておくためのものです。
-
delete
: シミュレータを削除する- シミュレータ名を指定して削除しています。シュミレータ名は "iPhone SE (3rd generation) for UITest" という名前にしてありますので名前がかぶることはないでしょう。名前が被った場合はOSが高い方が暗黙的に指定されてしまうためユニークな名前をつけることを推奨します。ここでdeleteをする理由もあくまで念の為です。
-
create
: シミュレータを生成する- 第一引数: シミュレータ名
- 第二引数: シミュレータ種別
xcrun simctl list devicetypes
を実行しインストールしたいものを選んでください。 - 第三引数: ランタイム(OS)
xcrun simctl list runtimes
を実行しインストールしたいOSを設定してください。
-
boot
: シミュレータを起動する
これでどんな開発環境でも同じシュミレータを使用してUITestの確認ができますね。
xcrun simctl status_bar
で時刻を固定
本題に戻って時刻の固定表示を行いたいと思います。シミュレータ起動後に時刻を固定で設定します。
~中略~
xcrun simctl boot "$simulatorname"
+ xcrun simctl status_bar "$simulatorname" override --time "2022-12-01T10:30:00+09:00"
xcodebuild test\
-project "$project"\
-scheme "$scheme"\
-destination "platform=iOS Simulator,name=$simulatorname,OS=$os"\
-derivedDataPath "$deriveddata"
~以下略~
iOS 16.1, 16.2ではxcrun simctl status_bar が正常動作しないバグがありますので 最大iOS 16.0を指定するのがよさそうです。
- runtime="com.apple.CoreSimulator.SimRuntime.iOS-16-2"
+ runtime="com.apple.CoreSimulator.SimRuntime.iOS-16-0"
- os="16.2"
+ os="16.0"
また最新のXcode(14.2)でUITestのスキームを作ると上記のOSに変えても時刻が固定されない場合があります。その場合はUITestSample.xcscheme
をテキストエディタ開き、parallelizable = "YES"
を削除してください。
~中略~
<TestableReference
+ skipped = "NO">
- skipped = "NO"
- parallelizable = "YES">
~以下略~
これで実行すると時刻が以下のように固定されます。何回かsnaptest.sh
を回してみても差分として検知されなくなっています。
まとめ
手軽にスクリーンショットを抜き出すにはxcparse
、simctl
を使って専用シミュレータをスクリプトで作ればどんな環境でも再現性のあるスナップショットができることがわかりました。status_bar
コマンドにはバグがあり(2023年1月11日現在)、OSバージョンを下げるか別の方法を模索する必要があります。
スクリプト全体
project="UITestSample.xcodeproj"
scheme="UITestSample"
simulatornameForScreenShot="iPhone SE (3rd generation)"
simulatorname="$simulatornameForScreenShot for UITest"
modeltype="com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation"
runtime="com.apple.CoreSimulator.SimRuntime.iOS-16-0"
os="16.0"
work=".temp"
deriveddata="$work/derivedData"
xcresults="$deriveddata/Logs/Test/*.xcresult"
screenshots="$work/screenshots"
screenshotDetails="$screenshots/$simulatornameForScreenShot ($os)/Test Scheme Action"
output="Snapshots"
rm -rf $work
xcrun simctl shutdown all
xcrun simctl delete "$simulatorname"
xcrun simctl create "$simulatorname" "$modeltype" "$runtime"
xcrun simctl boot "$simulatorname"
xcrun simctl status_bar "$simulatorname" override --time "2022-12-01T10:30:00+09:00"
xcodebuild test\
-project "$project"\
-scheme "$scheme"\
-destination "platform=iOS Simulator,name=$simulatorname,OS=$os"\
-derivedDataPath "$deriveddata"
for xcresult in $xcresults; do
mint run ChargePoint/xcparse xcparse screenshots\
--os --model --test-plan-config $xcresult $screenshots
done
mkdir -p $output
for png in "$screenshotDetails/"*".png"; do
name=`basename "$png"`
prefix=`echo "$name" | awk -F'_' '{print $1}'`
ext=`echo "$name" | awk -F'.' '{print $2}'`
newPath="$output/$prefix.$ext"
mv "$png" "$newPath"
done
# 後始末
xcrun simctl delete "$simulatorname"
rm -rf $work