fastlane snapshotの並列実行についてまとめた

[「iOS Test Night #6」](https://www.slideshare.net/tarappo/fastlane-snapshot-83380124)で発表した内容のQiita版になります。

fastlaneに[snapshot](https://docs.fastlane.tools/actions/snapshot/)という機能がありますが、Xcode9以降を利用した場合に並列実行ができるようになりました。

その内容をiOS Test Nightの発表資料より少し詳しく以下にまとめてみました。

# snaspshotとは
[fastlane](https://docs.fastlane.tools/)は知っている人も多いと思うので説明を省きます。

fastlaneの中の機能の1つにsnapshotという便利な機能があります。
本機能をつかえば、異なる端末や言語をまたいでアプリのスクリーンショットを簡単にとることができます。

以下が公式ドキュメントの一文です。

>snapshot generates localized iOS and tvOS screenshots for different device types and languages for the App Store and can be uploaded using

参考：https://docs.fastlane.tools/actions/snapshot/


この機能の利用用途の1つとしてApp Storeのためというのもありますが、他の用途でも利用できます。

私の所属するチームでは、PRの前後におけるUIの差分を見るためにスクリーンショットをgithubで管理していますが、その用途のためにsnapshotを利用しています。

また、それ以外にもlocalization周りの対応をしている人にとっては有益なツールと言えます。
iOS Test Nightでは、[snapshot ではじめる ローカライズ検証](https://www.slideshare.net/moko_ist/snapshot-71181346)のような発表もありました。

# 導入手順
snapshotを利用するまでの手順について簡単に紹介します。
とはいっても今までにいくつか記事になっていますので、そちらを参考にするほうが良いかもしれません。

 - [はじめてのfastlane Snapshot編](https://qiita.com/tamaki/items/f5e9f9985a91fb6a0f06)
 - [公式ドキュメント](https://docs.fastlane.tools/actions/snapshot/)

## snapshot init
前提としてfaslaneを既に導入済みとします。
fastlaneはバージョンアップの頻度も高いですし、Gemfile管理をしているほうが良いと思います。

```shell-session
bundle exec fastlane snapshot init
```

上記コマンドをおこなうと以下の2ファイルが生成されます。

```
fastlane/Snapfile
fastlane/SnapshotHelper.swift
```

SnapshotHelper.swiftを、次の項目でおこなうUIテストのターゲットにすることにより`snaphost'を利用することが出来るようになります。


## UIテストの用意
UIテストを用意し、スクリーンショットを撮りたい画面において`snapshot`を呼びます。

```swift
func testExample() {
  move(page: "category_page") //自身で実装が必要
  snapshot("category_page") 
  // deviceName-fileName.pngという名前でファイル保存されます
  // 例) iPhone 5s-category_page.png
}
```

対象アプリがUIテストを書きやすい状態になっているかどうかという問題もあるので、ここが一番面倒になるかと思いますので、場合によっては`Recorder機能`を利用するという手もあります。

ただし、Recorder機能で生成されるUIテストのコードは、修正が必要な場合が多いです。
機能として不十分な側面もありますが、アプリ側の問題であるケースもあるので、ここから最初の一歩をはじめてsnapshot以外のUIテストを始めるきっかけになると嬉しいです。

## Fastfile/Snapfileの用意
UIテストを用意した後、Fastfileまたは`fastlane snapshot init`で生成されたSnapfileに、実行したい端末と言語などの情報を書きます。

今回はFastfileの記述例を以下に明記します。

```rb
snapshot(
  workspace: "yourproject.xcworkspace",
  scheme: 'yourproject-scheme',
  devices: [ 'iPhone SE', 'iPhone 5s', ],
  languages: [ 'ja-JP', 'en-US' ],
  concurrent_simulators: true,
  output_directory: './screenshots',
  output_simulator_logs: false
)
```

languagesについては[Language and Locale IDs](https://developer.apple.com/library/content/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html)のドキュメントを一読すると良いと思います。

上記の場合、`iPhone SE`と`iPhone 5s`で`ja-JP`と`en-US`の環境でシミュレータを起動し、実行するようになっています。
従って、合計4種類の画像が生成されることになります。

また、上記で登場したパラメータ以外にも色々あるので公式サイトのParametersの箇所を一読すると楽しいと思います。


## 実行
Fastfileに書いた場合は、以下のような感じでsnapshotを実行します。

```shell-session
bundle exec fastlane lane名
```

無事に終了すると上記のFastfileで`output_directory`で指定したディレクトリに以下のような感じにlanguage毎にディレクトリが生成され、スクリーンショットが保存されます。

```
 - en-US/
   - iPhone 5s-category_page.png
   - iPhone SE-category_page.png
 - ja-JP/
   - iPhone 5s-category_page.png
   - iPhone SE-category_page.png
```


# 課題と解決策
## 課題
非常に便利なsnapshotですが、この手のUIテストを利用したサービスの課題としてあるのが、実行時間です。

シミュレーターの起動数はXcode8までは(基本的に)1Macにつき1つです。そのため、端末数と言語数を増やすと実行時間は右肩上がりで増えてきます。

今までの対応方法としてありえたのが、テストを動かす実行環境の並列化と実行するテストの最適化です。
実行環境であるMacを増やし、実行する環境毎に何を実行するのかを適切に切り分けることにより実行時間の短縮をおこなうことが出来ます。

この行為自体はsnapshotに限らず他の自動テストにおいても有効ではあります。

しかし、テストを動かす実行環境を増やすという行為は簡単には出来ませんし、何を実行するかを切り分けるのもなかなかコストが高い作業です。


## 解決策
上記のような課題がありましたが、Xcode9の登場によりこの課題の一部は解決したともいえます。

### Xcode9の登場
Xcode9からiOSシミュレーターの多重起動によるテストの並列化をおこなうことが出来るようになりました。

このテストの並列化ですが、snapshotはその対応をいち早く入れていて、該当のPRは以下になります。
Xcode9の正式リリースより前に対応をおこなっています。

 - https://github.com/fastlane/fastlane/pull/9570
   - 2017/8/22
   - fastlane 2.54.0

このsnapshotの中身を見る前に、シミュレーターを多重起動しテストを並列で実行する際のコマンドはどのようなものなのかをコマンドを例に紹介します。

```shell-session
$ xcodebuild -workspace yourproject.xcworkspace -scheme yourproject-scheme /
  -derivedDataPath '/derivedDataPath' \
  -destination 'platform=iOS Simulator,name=iPhone SE,OS=11.1' \
  -destination 'platform=iOS Simulator,name=iPhone 5s,OS=11.1' \
  -destination 'platform=iOS Simulator,name=iPhone 6s,OS=11.1' \
  -destination 'platform=iOS Simulator,name=iPhone 8,OS=11.1' \
  -destination 'platform=iOS Simulator,name=iPhone 7,OS=11.1' \
  test
```

上記のように`destination`の数を増やすことにより複数のシミュレーターでテストを並列に実行することが出来るようになっています。
上記の例であれば、5つのシミュレーターを起動することになります。


### snaphostでテストの並列化を利用する方法
snaphostでテストの並列化は非常に簡単にできるようになっています。

snapshotのオプションである`concurrent_simulators`をtrueにするだけで終わりです。
このオプションを設定するだけで、端末の起動数を指定する必要もありません。

#### 端末の起動数
端末の起動数はどのように決まっているのかは、以下のコードにその実装内容が書いています。

```rb:simulator_launcher.rb
# With Xcode 9's ability to run tests on multiple concurrent simulators,
# this method sets the maximum number of simulators to run simultaneously
# to avoid overloading your machine.
def default_number_of_simultaneous_simulators
  cpu_count = CPUInspector.cpu_count
  if cpu_count <= 2
    return cpu_count
  end

  return cpu_count - 1
end
```    
 - 参考：https://github.com/fastlane/fastlane/blob/master/snapshot/lib/snapshot/simulator_launchers/simulator_launcher.rb

cpu_countはどのように決まるかというと、以下のようなコマンドを用いています。

```rb
(hwprefs_available? ? `hwprefs thread_count` : `sysctl -n hw.physicalcpu_max`).to_i
```

今回の実験で利用したマシンですと、この値は6になるのでシミュレーターの起動数は1引いた5になります。

この値はどのように利用されるのかと、下記にあるようなdevicesの数に利用します。

```
devices: [ 'iPhone SE', 'iPhone 5s' ],
languages: [ 'ja-JP', 'en-US' ],
```

上記の場合だと、指定しているdeviceを2種類とも起動し`ja-JP`と`en-US`での実行をおこなうので、今までは4回動いていたのが、2回に減ります。

仮にdevicesで指定している数が6種類あった場合は、5種類を実行したのちに1種類を実行することになります。


## 蛇足的な話 - スクリーンショットの画像について
Xcode8までは実は任意のタイミングでスクリーンショットをとることが出来ませんでした。
UIテストが実行されている間は、`derivedDataPath`の`Logs/Test/Attachments/`以下にスクリーンショットが保存されていました。
snapshotはその画像を使っています。


蛇足的な話になりますが、Xcode9から任意のタイミングでスクリーンショットをとることが出来るようになりました。

```swift
func testTakeScreenshots() {
    // Take a screenshot of the current device's main screen.
    let mainScreenScreenshot = XCUIScreen.main.screenshot()
    
    // Take a screenshot of an app's first window.
    let app = XCUIApplication()
    app.launch()
    let windowScreenshot = app.windows.firstMatch.screenshot()
}
```
 - 参考：https://developer.apple.com/documentation/xctest/xcuiscreenshot


# 実験
このテストの並列化がどれぐらい効果があるのかを実験をおこないました。
実行環境と実行結果については下記に記載したとおりです。

## 実行環境
今回実行した環境は以下のとおりです。

 - Mac Pro (Late 2013) 3.5 GHz 6コア / 16GB
   - OS: 10.12.6
 - Xcode 9.1

上記の環境では、起動するシミュレーター数は5となります。


## 実行結果
 - 試行回数：5回
 - languages：3種類
 - devices：1台〜6台まで

|端末数  |実行時間(ON)|実行時間(OFF)|
|:------|:--------- |:----------|
|1台    |250.2      |251        |
|2台    |332.2      |492        |
|3台    |416.2      |725        |
|4台    |496.2      |981.6      |
|5台    |558        |1148.6     |
|6台    |764.6      |1344.8     |


実行時間はテストの並列化を有効にしているとき(ON)と、無効にしているとき(OFF)の2種類を試行回数5回の平均(秒)で出しています。

端末数を1台以上にした場合においては、当然ながらテストの並列化を有効にしている方がメリットがあります。今回の場合、シミュレーターの起動数が5なので端末数5が一番有効にしている状態の効果が大きいと言えます。

snapshotにおけるテストの並列化は設定項目は1つしかなく簡単に利用できます。是非とも利用するのをオススメします。


これよりも早くしたい場合は、最初の解決策にあった実行環境の並列化と実行するテストの最適化をおこなうのが良いと思います。