<-- mermaid -->

iOSアプリ開発でVisual Regression Testingを導入しUIのデグレ検知を自動化した話

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)を使用して自動化していきます。

全体の流れはこのようになっています。

  1. BitriseのSchedule Buildという機能を用いてワークフローを定期実行

  2. Bitrise上でXCUITestを実行してスナップショットを撮る

  3. それらを事前に用意した正解画像と差分比較

  4. その結果のレポートを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さんの記事を参考にしました🙇

inside.dmm.com

- 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日ほどですが、今のところ安定して稼働いるため、リリース作業項目からデザイン崩れの確認項目を削除しました。 これにより、リリース作業負担をかなり減らすことができました。

確認対象であるビューはめったに変更することはないのですが、サービスとして絶対にデグレさせてはいけないクリティカルなものであり、修正内容的にデグレが起きる可能性はほぼゼロだとわかっていても安全のために確認をするという虚しいことをしていたので、作業時間だけでなく心理的な負担も大きく減らすことができたのではないかと思います。

また、自分が実装したものが実際に役立っているところを見ると、非常に達成感がありますね。実装していく中でもさまざまな問題に直面し、かなりの時間がかかってしまいましたが、インターン生ながら大きなタスクを任せてもらえて、とてもいい経験となりました!

Page top