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

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

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

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

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

ここでは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:)](https://developer.apple.com/documentation/xctest/xcuielementquery/1500715-element)やビューの階層を指定する[element(boundBy:)](https://developer.apple.com/documentation/xctest/xcuielementquery/1500842-element)を使うこともできます。こういった当該UIエレメントへのアクセスを提供するクラスは[XCUIElementQuery](https://developer.apple.com/documentation/xctest/xcuielementquery)が責任を持っており、その指定方法は様々なものがありますので説明は割愛しますが、とりわけビューのツリー階層を知らずしてアクセスすることは難しいためまずはUIテスト中にブレイクポイントで止め以下のコマンドでビュー階層を確認することをお勧めします。

```
po app.windows 
```
- **ファイル名** スクリーンショットのファイル名で注意すべき点はこれはファイル名のプレフィクスとなるということです。`ファイル名_{番号}_{ユニークなID}.png`となります。スネークケースで意味が区切られいるためファイル名にアンダーバーを含めないのが賢明でしょう。

### スクリーンショットの保存先
スクリーンショットはXcode上からアクセスすることができます。

![screen1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2919966/82943afa-b508-5787-61dc-4b1dc343a163.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 list`や`Xcode > Window > Devices and Simulators > Simulators` から設定・確認が可能です。


:::note warn
ワークスペース(`.workspace`)を使っている場合は`-project`の代わりに`-workspace`を使用してください
:::

一旦これでUITestが成功することを確認してください。
```
bash snaptest.sh
```
以下のようにディレクトリが生成されています。
![ディレクトリ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2919966/254dd49e-17a5-2698-d6ea-6921be1ff134.png)

`.temp/derivedData/Logs/Test` の中に`Test-{Scheme}-{yyyy.MM.dd_HH-mm-ss-+000Z.xcresult`という形式でUITestの結果が出力されています。ここに先ほどXcodeから見た相当のデータが含まれているのです。

### スクリーンショットのエクスポート
ですがこのxcresultファイルはApple独自の形式で構成されており、スクリーンショットへアクセスするにはもう一段階手順を踏む必要がありそうです。実際には`xcrun xcresulttool`を使って添付ファイルをエクスポートする必要がありますがこの記事にあるように[相当困難な道のり](https://www.chargepoint.com/engineering/xcparse)です。今回はその記事を頼り[xcparse](https://github.com/ChargePoint/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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2919966/497f60e6-5b0c-083f-0006-f532c8f3a8e4.png)
スクリーンショット
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2919966/1f0f85a1-941a-3606-c008-64d7c87674dc.png" width="30%" />

### スクリーンショットのファイル名を変更する
さていよいよスクリーンショットが見えるようになりましたがスクリーンショットのファイル名にユニークな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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2919966/9d96d573-4cd4-0fc1-1ab3-49f6077c138a.png)

### ステータスバーの時刻と差分
しかしいざこれを運用しようとすると常に画像に差分が出るという問題が出ます。それはスクリーンショットに映った時刻が常に異なるため差分として検知されてしまうからです。
![(1)メイン画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2919966/d0b5c804-8630-267f-8719-7ab787d96163.png)
シミュレータは使用しているMacの時刻を参照し、いうまでもなくテスト中にも刻一刻と変化します。そこでその時刻がテスト中に変更されなように`xcrun simctl status_bar`サブコマンドを使用します。このサブコマンドでステータスバーに表示される情報を固定値に書き換えることができます。

#### シミュレータを指定する
といきたいところですが、そのコマンドの性質上、シミュレータを指定してサブコマンドを送信するため、チーム内で同じシミュレータを共有していないと後々問題になりそうです。そこでまずは`xcrun simctl` を使ってシミュレータを生成するスクリプトを書いてきましょう。

```diff_shell
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` で時刻を固定
本題に戻って時刻の固定表示を行いたいと思います。シミュレータ起動後に時刻を固定で設定します。

```diff_shell
~中略~
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"
~以下略~
```

:::note warn
iOS 16.1, 16.2では[xcrun simctl status_bar が正常動作しない](https://developer.apple.com/forums/thread/720610)バグがありますので 最大iOS 16.0を指定するのがよさそうです。
```diff_shell
- 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"` を削除してください。
```diff_xml
~中略~
<TestableReference
+            skipped = "NO">
-            skipped = "NO"
-            parallelizable = "YES">
~以下略~
```
:::

これで実行すると時刻が以下のように固定されます。何回か`snaptest.sh`を回してみても差分として検知されなくなっています。
![(1)メイン画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2919966/7d362137-735a-8203-cdcd-035ddfab1f3d.png)



## まとめ
手軽にスクリーンショットを抜き出すには`xcparse`、`simctl`を使って専用シミュレータをスクリプトで作ればどんな環境でも再現性のあるスナップショットができることがわかりました。`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
```