1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

UITest でスナップショットテストをお手軽に始めるスクリプト

Posted at

Problem(s)

スナップショットテストとはUIが意図せず変更された時にそれを検知するためのテストです。目視確認ではなくツールで行うのが一般的です。CIと組み合わせて自動で差分を検知するワークフローを構築することもできますがいちチームメンバーが容易に始められるものではありません。そこで手軽にスクリーンショットの差分を検知する仕組み構築する手順を紹介したいと思います。

TL;DR スクリプト全体を手っ取り早く確認したい場合は記事の最後を参照ください

Solution

本記事ではUITest時にスクリーンショットの撮影を追加し、それをリポジトリに含めるようにします。これであれば意図しないUIの変更があった際にはその画像が差分としてgitが検知するようになります。また本件ではiOSSnapshotTestCaseを使用しない平易な方法で進めます。それではどういった手順でそのテストが行われるかを整理したいと思います。

  1. UIテストを行う
  2. スクリーンショットを撮影し保存する
  3. 保存されたスクリーンショットをリポジトリへアップロードする

ここではUIテストに関する設定はすべて完了しているものとし、UITests.swiftというUIテストファイルが作成されていることを前提として話を進めます。

スクリーンショットの保存

スクリーンショットの保存は以下の通りですがいくつか注意点があるため順を追って説明していきたいと思います。

UITests.swift
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上からアクセスすることができます。

screen1.png

しかし実体はDerivedDataの中にあり、このままだとgitで管理することができません。そこでDerivedDataの出力先をgitで管理しているディレクトリの配下に指定するスクリプトを書きましょう。本記事ではプロジェクトと同等の階層にsnaptest.shという名前でスクリプトファイルを作成します。

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 listXcode > 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ファイルからスクリーンショットをエクスポートしましょう。

snaptest.sh
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

この状態でもう一度スクリプトを実行してください。スクリーンショットが以下のディレクトリに配置されています。
Screenshot 2023-01-10 at 0.38.40.png
スクリーンショット

スクリーンショットのファイル名を変更する

さていよいよスクリーンショットが見えるようになりましたがスクリーンショットのファイル名にユニークな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に以下のスクリプトを追記していきます。

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に整形されたファイル名でスクリーンショットが移動しているのが確認できます。
Screenshot 2023-01-10 at 1.13.55.png

ステータスバーの時刻と差分

しかしいざこれを運用しようとすると常に画像に差分が出るという問題が出ます。それはスクリーンショットに映った時刻が常に異なるため差分として検知されてしまうからです。
(1)メイン画面.png
シミュレータは使用している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を回してみても差分として検知されなくなっています。
(1)メイン画面.png

まとめ

手軽にスクリーンショットを抜き出すにはxcparsesimctlを使って専用シミュレータをスクリプトで作ればどんな環境でも再現性のあるスナップショットができることがわかりました。status_barコマンドにはバグがあり(2023年1月11日現在)、OSバージョンを下げるか別の方法を模索する必要があります。

スクリプト全体

snaptest.sh
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
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?