NewsPicks Mobile App Unitでインターンをしているりゅう(@ryu_hu03)です。
NewsPicksのiOSおよびAndroidアプリは基本的に週一で新しいバージョンがリリースされています。 リリース作業の多くは自動化されており、重要な機能についてはUIテストによって品質が担保されるようになっているのですが、見た目上のデザイン崩れが起きていないかの確認はこれまで手動で行っていました。 デザイン崩れがないかどうかの確認は目視でやるしかなく、確認項目もそれなりに多いので、デザイン崩れの見落としがあったりリリース作業自体の負荷が大きいといった課題がありました。
そこで、デザイン崩れが起きていないかどうかの確認を自動化すべく、Visual Regression Testingを実装しました。 本記事では、その実装や効果についての話をしたいと思います。
使用したツールと流れ
ツール
・reg-suit
画像の差分比較を行うためのツールです。比較元の画像と差分比較した結果をHTMLのレポートとして作成してくれます。また、Pluginを使用することでレポート結果をSlackに通知したり、GCSやS3にアップロードしてくれたりします。
github.com
・XCUITest
当初はXCTestを使用して実装しようと考えていたのですが、ログイン操作が必要だったり目的の画面までは操作をしないと到達できません。XCUITestなら特定の記事のみを表示する仕組みが用意されていたので、今回はXCUITestで画面を操作し、目的の画面までいき、スクリーンショットを撮る方法にしました。
・Bitrise
BitriseのSchedule Buildを利用して、定期的に記事がデグレを起こしていないかを確認します。
・Google Cloud Storage
正解画像やreg-suitで差分比較した結果のレポートを保存するのに使用しています。
流れ
今回やりたいことは、メイン画面のこれらのセクションがデザイン崩れを起こしていないかの確認です。
これらの確認作業をVisual Regression Testing(以下VRT)を使用して自動化していきます。
全体の流れはこのようになっています。
BitriseのSchedule Buildという機能を用いてワークフローを定期実行
Bitrise上でXCUITestを実行してスナップショットを撮る
それらを事前に用意した正解画像と差分比較
その結果のレポートをGCSに保存し、Slackに送信
実装
次は具体的な実装について解説していきます。 こちらが全体のBitriseのワークフローです。
スナップショットを取得
はじめにUITestを使用して目的の画面でスナップショットを取得するコードを記述します。 全体のコードがこちらになります。
import Foundation import Quick @MainActor final class SnapshotSpec: QuickSpec { override func spec() { let app = NPUIApplication() var isLoggedIn = false func takeSnapshot(name: String) throws { if let snapshotScreen = app.windows.firstMatch.screenshot().image.removingStatusBar { try saveImage(snapshotScreen, name: name) } } func launch(section: Section) { app.launchEnvironment[LaunchEnvName.feedsV2NewsTopStubJson] = LogSpecHelper.stubJson(fileName: "/path/to/\(section.feedName)") app.launch() } beforeEach { if !isLoggedIn { app.launch() LoginOnFirstLaunchPage(app: app).useImmediately(timeout: 60) isLoggedIn = true } } context("各SectionのSnapshot") { it("seciton_1") { launch(section: .section1) sleep(10) try takeSnapshot(name: "section_1") } it("section_2") { launch(section: .section2) sleep(10) try takeSnapshot(name: "section_2") } ..... } } }
順を追って解説していきます。
func launch(section: Section) { app.launchEnvironment[LaunchEnvName.feedsV2NewsTopStubJson] = LogSpecHelper.stubJson(fileName: "/path/to/\(section.feedName)") app.launch() }
この部分で記事の一覧をスナップショットを取得したいセクションに置き換えてアプリを起動しています。 詳しくはこちらの記事の「ニュースフィードAPIのスタブ化」というセクションをご参照ください。 tech.uzabase.com
そして、こちらが目的の画面でスナップショットを撮るコードです。
@MainActor final class SnapshotSpec: QuickSpec { override func spec() { ... func takeSnapshot(name: String) throws { if let snapshotScreen = app.windows.firstMatch.screenshot().image.removingStatusBar { try saveImage(snapshotScreen, name: name) } } ... } } // 画像の保存 func saveImage( _ image: UIImage, name: String, _ file: String = #file ) throws { let fileManager = FileManager.default let snapshotDirectory = URL(fileURLWithPath: file) .deletingLastPathComponent() .appendingPathComponent("__Snapshots__") let imageUrl = snapshotDirectory .appendingPathComponent(name) .appendingPathExtension("png") try fileManager.createDirectory(at: snapshotDirectory, withIntermediateDirectories: true) try image.pngData()?.write(to: imageUrl) } // statusBarの削除 extension UIImage { var removingStatusBar: UIImage? { guard let cgImage = cgImage else { return nil } let yOffset: CGFloat = 54 * scale // iPhone 14 proのstatus barの高さ let rect = CGRect( x: 0, y: Int(yOffset), width: cgImage.width, height: cgImage.height - Int(yOffset) ) if let croppedCGImage = cgImage.cropping(to: rect) { return UIImage(cgImage: croppedCGImage, scale: scale, orientation: imageOrientation) } return nil } }
removingStatusBar
でステータスバーを削除しているのですが、これはreg-suitで差分を比較した際にステータスバーの時間で必ず差分が出てしまうためです。
また、実行する端末が毎回違うとそこでも差分も出てしまうため、Bitriseで使用する端末をiPhone 14 Proに限定し、ステータスバーの高さもiPhone 14 Proに固定しています。
また、今回はUITestでスクロールをして目的のセクションまで行きスクショを撮る方法ではなく、テスト実行ごとに表示するセクションを切り替えてアプリを起動しています。 スクロールで目的のセクションまで移動する方法ですと、テスト実行ごとにスクロール量が異なってしまうため、このように差分が出てしまいます。
そのため、スクロールが不要である、テストごとに表示するセクションを切り替える方法にしました。
これを実行すると、__Snapshots__
ディレクトリが作成され、その中に各セクションの画像がしっかりと取れていることがわかります。
Xcode Test For iOS
Xcode Test For iOSというステップを使用してBitrise上でUITestを実行します。
Schemeに先ほど作成したテストのみを実行するSchemeを、Device destination specifierは今回はiPhone 14 Proを指定しています。
reg-suitを用いてスナップショットを比較
次にreg-suitを用いてUITestで取得したセクションのスナップショットを正解データと比較していきます。
ExpectedとActualを設定
まず、reg-suitの設定ファイルを記述していきます。
{ "core": { "workingDir": ".reg", "actualDir": "ActualSnapshots", "thresholdRate": 0.01, "addIgnore": true, "ximgdiff": { "invocationType": "client" } }, "plugins": { "reg-notify-slack-plugin": { "webhookUrl": <webhook-url> }, "reg-publish-gcs-plugin": { "bucketName": <bucket-name> }, "reg-simple-keygen-plugin": { "expectedKey": "${EXPECTED_KEY}", "actualKey": "${ACTUAL_KEY}" } } }
reg-suitのpluginが優秀なのでSlackに通知するのも、GCSにアップロードするのもすべてやってくれます。 今回は、Pull Request作成時にVRTを実行するわけではないので、reg-keygen-git-hash-pluginは使用せず、reg-simple-keygen-pluginを使用します。 これにより、正解データを固定でき、いつでも正常な場合のセクションの画像と比較ができます。
expectedKeyとactualKeyは環境変数を指定しているので、Bitrise側で環境変数を設定してあげます。
- script@1: title: ExpectedとActualを設定 inputs: - content: |- EXPECTED="expected" ACTUAL=$(date +'%Y/%m/%d-%H:%M') envman add --key EXPECTED_KEY --value ${EXPECTED} envman add --key ACTUAL_KEY --value ${ACTUAL}
正解データのディレクトリとreg-suitで実行した結果のディレクトリを指定し、envmanで環境変数を設定します。 今回はいつ実行されたものなのかわかりやすいようにACTUAL_KEYを日付と時間にしました。
GCSの認証情報を設定
つぎにGoogle Cloud Storageへ保存するのに必要な認証情報を設定するステップを作成します。
- script@1: title: GCSの認証情報を設定 inputs: - content: |- if [ ! -d $HOME/google-cloud-sdk ]; then curl https://sdk.cloud.google.com | bash fi source $HOME/google-cloud-sdk/path.bash.inc curl -o /tmp/gcloud-service-account-key.json $BITRISEIO_GCLOUD_SERVICE_ACCOUNT_KEY_URL gcloud auth activate-service-account -q --key-file /tmp/gcloud-service-account-key.json
reg-suitを実行
最後にreg-suitを実行するステップを作成します。 こちらはDMMさんの記事を参考にしました🙇
- script@1: title: reg-suitを実行 inputs: - content: |- #!/usr/bin/env bash set -x # gcsにアクセスする際はこの環境変数をセットしないといけない export GOOGLE_APPLICATION_CREDENTIALS="/tmp/gcloud-service-account-key.json" # リモートからすべてのブランチを取得し、ワークフローを実行しているブランチにcheckout git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" git fetch origin git checkout $BITRISE_GIT_BRANCH || git checkout -b $BITRISE_GIT_BRANCH # __Snapshots__ディレクトリを探して./ActualSnapshotsにコピー find . -name "__Snapshots__" -not -path "./.build/*" -exec cp -R {} ./ActualSnapshots \; npm install -g reg-suit # Pluginのインストール npm i reg-notify-slack-plugin reg-suit prepare -p notify-slack npm i reg-publish-gcs-plugin -D reg-suit prepare -p publish-gcs npm i reg-simple-keygen-plugin -D # 実行 reg-suit init reg-suit run
このワークフローを実行すると、しっかりとSlackにreg-suitの実行結果が通知されました!
Google Cloud Storageにアップロードされたレポートも見ることができます。
詰まった点
私たちのチームでは、UITestを iOS Device Testing という Firebase Test Lab を使用したステップで実行していました。 通常のUITestならば問題ないのですが、スナップショットを撮って保存する処理 が記述してある場合、ビルドを行う環境(Bitrise)とテストを行う環境(Firebase)が異なるため、保存先に指定しているパスをFirebase Test Lab側で参照できず、このようなエラーが発生してしまいます。
具体的に説明していきます。
今回はこのようなコードで画像を保存しています。
fileprivate func saveImage( _ image: UIImage, name: String, _ file: String = #file ) throws { let fileManager = FileManager.default let snapshotDirectory = URL(fileURLWithPath: file) .deletingLastPathComponent() .appendingPathComponent("__Snapshots__") let imageUrl = snapshotDirectory .appendingPathComponent(name) .appendingPathExtension("png") try fileManager.createDirectory(at: snapshotDirectory, withIntermediateDirectories: true) try image.pngData()?.write(to: imageUrl) }
引数に#file
を指定していて、これは コンパイル時の ソースファイルのパスを表すものです。
コンパイルした環境は Bitrise なので、#file
はBitriseでのソースファイルのパスを示します。
ですが、テストを実行する環境はFirebase Test Labですので、そのようなファイルパスは存在せず、参照できません。よって先ほどのようなエラーが出てしまいます。
UITestに限らず、Unit Testでもスナップショットを保存する場合は必ず Xcode Test for iOS というステップを使用しましょう。
このことから、このようにSnapshot Testのみを実行するSchemeを作成するか、新たなテストターゲットを作成するのが良いでしょう。
運用して出てきた問題
今回作成したワークフローの運用中に、このように意図しない差分が検出される問題が発生しました
当初設定していたthresholdRate(閾値)は0だったのですが、細かいピクセルのずれまで検出されてしまうため、thresholdRateを0.01に設定しました。 これにより、見た目にほとんど変化のない箇所において、誤検出が減りました。ただしthresholdRateを下げすぎると、差分を見落とす可能性もあるので、設定変更には慎重な判断が必要です。
さいごに
Visual Regression Testingを導入し、NewsPicksのiOSアプリのデザイン崩れの確認作業を自動化する実装について解説をしました。 今回実装したテストはBitriseのスケジュールビルドで毎晩実行するように設定しており、差分が発生したら翌朝すぐに気付けるようにしています。 運用を開始してからまだ10日ほどですが、今のところ安定して稼働いるため、リリース作業項目からデザイン崩れの確認項目を削除しました。 これにより、リリース作業負担をかなり減らすことができました。
確認対象であるビューはめったに変更することはないのですが、サービスとして絶対にデグレさせてはいけないクリティカルなものであり、修正内容的にデグレが起きる可能性はほぼゼロだとわかっていても安全のために確認をするという虚しいことをしていたので、作業時間だけでなく心理的な負担も大きく減らすことができたのではないかと思います。
また、自分が実装したものが実際に役立っているところを見ると、非常に達成感がありますね。実装していく中でもさまざまな問題に直面し、かなりの時間がかかってしまいましたが、インターン生ながら大きなタスクを任せてもらえて、とてもいい経験となりました!