DMM.comの、一番深くておもしろいトコロ。

DMMブックスにビジュアルリグレッションテストを導入してみた(iOS版)

DMMブックスにビジュアルリグレッションテストを導入してみた(iOS版)

  • このエントリーをはてなブックマークに追加

はじめに

この記事は、DMMグループAdvent Calendar 2021の16日目の記事になります。

電子書籍事業部のiOSアプリ開発をしているあらさん(@arasan01_me)がお送りします。 最近分割タイプの自作キーボードに入門しました、かなりいいのでオススメです。

今回は歴史のあるiOSアプリに対してビジュアルリグレッションテストを導入することを目的とする内容になります。 いざ導入を考えるとなったときにこのアドカレが皆さんの叩きの資料になればいいなと思っています。

さて、ビジュアルリグレッションテストをわざわざ導入する理由はなんでしょうか。 ユニットテストは比較的導入しやすく想像が容易いものです、しかしUIやビジュアルが関わってくる部分のテストでは不安に思える部分が増えます。

  • 時に壊れやすく
  • 時に融通が効かず
  • 時に管理がめんどくさく
  • 時に非常に手間のかかるテストの記述になること

アプリを作る上で本質ではない部分で問題になり、非常に多くの時間を費やされる羽目になることが往々にしてあります。 そうは言っても見た目はiOSアプリとユーザを繋げる重要な要素であり、開発においても重要な部分です。検証なども十分に行う必要があります。

ざっくり一言で課題感を表すとこのような状況でした。

いやー、辛い、かなり辛い、手動で画面の検証をするのは非常に辛い、以前の画面を常に覚えて変更などが発生しているかを確認するのは色々なリソースを使うし機械的に行いたい、でも機械的に行う部分はどうしてもオオゴトになりやすい、どうにかならんもんか

ということで今回はDMMブックスアプリでビジュアルリグレッションテストをオオゴトにせず、頑張らずに運用するため試行錯誤して導入する過程と結果で得られた内容を紹介していきます。

お品書き

今回話していく内容は以下のものになります。

  • ビジュアルリグレッションテストをどう実現するのか
  • DMMブックスアプリに対して落とし込む
  • 導入設定方法・テストの書き方・結果の確認方法

ビジュアルリグレッションをどう実現するのか

そもそもiOSアプリに対してビジュアルリグレッションテストをどのように実現していくかを手順を分解して考えていきます。この分解の粒度をチーム内で検討するところから始めると、チームとしてどのようにテストにアプローチしていくのか認識を合わせることができるようになります。私達は最終的に以下の内容で要素を分解しました。

  1. 画面状態を操作して目的とする画面を作り出す
  2. 作り出した画面をスクリーンショットなどキャプチャが可能な方法で画像として取得する
  3. 取得した画像と比較して今回の画像の評価をする
  4. 評価結果を通知する

1. 画面状態を操作して目的とする画面を作り出す

画面を作り出すためにはどのようにすれば一番簡単にできるのかを考える部分になります。 状態がビューとある程度分離できていれば考えることは少なそうです。マッシブな作りになっていると少しつらそうです。

実際に新規アプリやアーキテクチャがはっきりとしており画面の責務が分離していて画面状態は特定の値のものを使えば画面再現ができるアプリもあれば、ViewControllerに多彩な責務をもたせた設計で画面の操作は色々頑張らなければいけないアプリもあります。

弊チームでは後者よりの設計であったためこの部分の抽象化を深堀りすることは避けました。とにかく画面に関わる状態を操作して目的とする画面にしていきます。

2. 作り出した画面をスクリーンショットなどキャプチャが可能な方法で画像として取得する

上手くツールやOSSライブラリを活用して画面のキャプチャを行っている先行事例があります。2.の内容と3.の内容を同時に処理するライブラリなどもあるため手順の分解を考える時に切り分ける粒度でかなり迷うところです。

弊チームでは最初の設計段階では2.と3.を同一視して処理する流れを検討していました。テストのワークフローの組み方によっては同一視しても問題ない設計にできます。 後々になって、分離した方がより頑張らない運用にできる目処が立ったので変更を加えました。

変更した理由として、3.に特化した便利なツールを用いると自分たちのワークフローで凝った作りを導入せずともPRを出して差分確認するまでの流れが素直に導入できることが分かったためです。

3. 取得した画像と比較して今回の画像の評価をする

ビジュアルリグレッションテストの心臓部分であり、2.と分離させると自由度が格段に高まります。 ただし分離すると、使用するツールなどによってはXcodeだけに閉じるテストではなってしまいます。

弊チームではビジュアルリグレッションテストの取得部分だけをXCTestで生成されるようにしています。 XCTestに取得を任せると嬉しいことは、組み立ての段階ではUnitTestと同様の扱いをできる部分が大きいです。

CIでの動かし方を考える上で取り敢えず全部のUnitTestを走らせる、という意識になります。単体テストとビジュアルリグレッションテストのテストコードの意識の差を無くすことができることは頑張らない運用を考えた場合にはメリットに感じました。

4. 評価結果を通知する

ビジュアルリグレッションテストではどの部分が変更されたのか分かりやすくレビューの段階で知りたいです。 レビュー段階でUnitTestとは異なる要求があるためワークフローの組み方も検討の余地があります。

弊チームではツールによってプラグインにより、スクリプトの実行を起点にしたGithub、Slack連携ができるものを採用したためワークフローに特別な扱いをさせる部分をできる限り無くしました。

DMMブックスアプリに対して落とし込む方法

ここまでにどのように実現していくかを考えてきました。次は具体的な形を作っていくための方法について考えていきます。

スクリーンショットの取得方法

スクリーンショットを取得する例としていくつか考えられます。

  • UIViewのBitmapを取得して書き出し
  • XCUIApplicationのscreenshotを使う
  • fastlaneのsnapshotを使ってXCUIApplicationでsnapshotを取得する
  • pointfreeco/swift-snapshot-testingを使って画像保存を任せる

他にもやり方はいくつもあるでしょう。それぞれのツールによって内部的には同じことをやっているかもしれませんができる限り開発の手間は減らしたいです。ライブラリの導入を検討すると非常に楽ができるため弊チームではpointfreeco/swift-snapshot-testingを用いています。

github.com

これを採用する利点としては画像だけじゃないスナップショット取得における汎用性の高さがあります。UIViewControllerなどの内部で表現されている状態をテキスト形式で出力できたりスクリーンショットとして取得できます。一気通貫で比較までできるツールですが、過去との比較などを考えた時に過去のスナップショットをCIに展開するなどの設計が増えて辛いためスナップショット取得専用として使います。

スクリーンショットの比較方法

同じDMMブックスアプリのAndroid版で導入・運用実績があるreg-viz/reg-suitを導入することにしました。

github.com

このツールではAWS S3やGCP Cloud Storageをプラグインとして選択し使うことが出来ます。また、各種ツールとの連携もプラグインで提供されておりGithubやSlackとの連携に優れています。標準で提供されている画像の差分表示機能もインタラクティブに変更点を確認できて非常に使いやすい部分が魅力的です。

今回は比較部分で採用しなかったswift-snapshot-testingを利用することも非常に魅力的です。差分の比較でどれだけの変更を許容するか、どのような表現方法であっても同じインターフェイスで提供されるため、まとまりが付きやすいです。ワークフローの設計で良い採用方法が考えられるならば、このツールのみを使ったテスト設計をしても良いかなと思っています。

具体的な設計について

考え方の部分で分割した粒度と、実際にどのような技術を用いて実現するかが確定しました。

目的 要素
画面状態を操作して目的とする画面を作り出す XCTest
作り出した画面をスクリーンショットなどキャプチャが可能な方法で画像として取得する swift-snapshot-testing
以前に取得した画像と比較して今回の画像の評価をする reg-suit & AWS S3
評価結果を通知する GitHub & Slack

最終的に弊チームではこれらの構成によりビジュアルリグレッションテストを行っています。 画面の状態を設定する部分でXCUITestを用いて画面操作をすることも良いでしょう。ただ、画面の作成に関する部分だけでありビジュアルリグレッションテストではアニメーションなどは検証しないことや、UIViewの描画周りを手動で操作すれば現状では十分であるだろうと考えたため導入を避けています。今後導入するとしてもそれぞれの役割が分離しているため導入は容易です。

導入設定方法・テストの書き方・結果の確認方法

実際どのようにテスト環境を組み立てるか検討がついたため実際に動かしていきます。

はじめに私達のチームではXcodeでのターゲットに通常のUnitTestと同じようにSnapshotTestsの環境を作成しました。 E2Eテストのように操作することはなくViewControllerを操作して画面を取得することが成功すればXcodeのテストとしては役目を終えるため成功となるためUnitTest同様の扱いをすれば良いと考えています。

実際に動作させるコードは以下のようになります。

import XCTest
import SnapshotTesting
import SnapshotTestingStitch
@testable import App

open class XCSnapshotTestCase: XCTestCase {

    enum LifeCycle: CaseIterable {
        case initialize
        case loadView
        case layoutAssembly
        case beginAppearance
        case done

        var description: String {
            switch self {
            case .initialize:
                return "Initialized"
            case .loadView:
                return "DidLoadView"
            case .layoutAssembly:
                return "DidLayoutView"
            case .beginAppearance:
                return "BeginAppearView"
            case .done:
                return "DoneLifeCycle"
            }
        }
    }

    func saveAsImageStitch<T: UIViewController>(
        matching vc: T,
        description: String = #function,
        lifeCycle: Set<LifeCycle> = Set(LifeCycle.allCases)
    ) {
        let lambdaSnapshot: (LifeCycle) -> Void = { event  in
            guard lifeCycle.contains(event) else { return }
            assertSnapshot(
                matching: vc,
                named: "\(description)_\(lifeCycleEvent.description)",
                as: .stitch(
                    strategies: SnapshotConfig.DeviceName.allCases.map { deviceName  in
                        StitchTask(
                            name: "\(deviceName.rawValue)",
                            strategy: .image(on: deviceName.viewImageConfig))}))
        }

        lambdaSnapshot(.initialize)
        vc.loadViewIfNeeded()
        lambdaSnapshot(.loadView)
        vc.view.layoutIfNeeded()
        lambdaSnapshot(.layoutAssembly)
        vc.beginAppearanceTransition(true, animated: false)
        lambdaSnapshot(.beginAppearance)
        vc.endAppearanceTransition()
        lambdaSnapshot(.done)
    }
}

public enum SnapshotConfig {
    public enum DeviceName: String, CaseIterable {
        case iPhone8 = "iPhone-8"
        case iPhone8Plus = "iPhone-8-Plus"
        case iPhoneX = "iPhone-X"
        case iPhoneXsMax = "iPhone-Xs-Max"
        case iPadPro11Portrait = "iPad-Pro-11-portrait"
        case iPadPro11Landscape = "iPad-Pro-11-landscape"

        public var viewImageConfig: ViewImageConfig {
                switch self {
                case .iPhone8:
                    return .iPhone8
                case .iPhone8Plus:
                    return .iPhone8Plus
                case .iPhoneX:
                    return .iPhoneX
                case .iPhoneXsMax:
                    return .iPhoneXsMax
                case .iPadPro11Portrait:
                    return .iPadPro11(.portrait)
                case .iPadPro11Landscape:
                    return .iPadPro11(.landscape)
        }
    }
    }
}

記載にあるSnapshotConfigの内容はメルペイが公開している記事の内容を参考にさせていただきました。 DeviceNameを列挙することで確認したい画面サイズを任意に指定できます。また、画面のサイズ自体を指定できるため、1つのViewControllerの画面を広く画像として取得できます。通常では隠しておきたい画面や長いリストの常時をざっくりと取得できることは便利です。

また、複数のDeviceの画像を扱っていると同じ画面を示すテストの結果がデバイスの数だけエラーとして表示されることになるため、まとめるStitchというプラグインを利用しています。画面によって差分が表示されているか、されていないかを端末別に確認しやすくなるため導入しています。

表示したコードではXCTestCaseが継承されたクラスを使っているため、基本的に通常のXCTestと同じように使えて便利です。 好みによりますが、主導でライフサイクルを進めるコードを入れておくと何かと便利です。私達が開発しているアプリは歴史がありどのように画面が出ているのかよく読まないと認識できない部分がありますが、コードの修正により影響があった場合にはテスト結果として差分を出してくれるため何がどうなっているのか可視化されて分かりやすくなります。

ViewControllerを呼び出して保存させるテストを記述したものがこちらです。

class AViewControllerSnapshotTests: XCSnapshotTestCase {
    func testAViewController() {
        let aVC = AViewController.instantiate(dependency: .init())
        let vc = UINavigationController(rootViewController: aVC)
        saveAsImageStitch(matching: vc, named: "A_Initialize")

        aVC.showSideMenuView()
        saveAsImageStitch(matching: vc, named: "A_Show_SideMenu")

        aVC.hideSideMenuView()
        saveAsImageStitch(matching: vc, named: "A_Hide_SideMenu")
    }
}

基本的には画面を作ってそのまま投げるだけになります。画像取得部分はすべてSnapshotTestingがやってくれるためかなり助かりますね。 これを動作させると次のような画像が得られます。

f:id:dmmadcale2021:20211202182454p:plain
取得できるテスト結果画像

画像を取得するだけの部分はライブラリのチカラのおかげで非常に楽をさせてもらえました。次はこちらで取得した画像をさらって比較をしてもらう必要があります。生成された画像はそれぞれのテストコードを書いてあるディレクトリなどに配置されます。これをreg-suitが認識しやすくなるように集めたいです。私達のチームではCI環境としてBitriseを使っているためBitriseのymlファイルにスクリプトとして収集・起動までを書いてあります。

workflows:
  _XcodeAdvancedTest:
    steps:
      - xcode-test@4:
          inputs:
            - scheme: $BITRISE_SNAPSHOT_SCHEME
      - script@1:
          inputs:
            - content: |-
                #!/usr/bin/env bash
                set -x
                git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
                git fetch origin
                git checkout $BITRISE_GIT_BRANCH || git checkout -b $BITRISE_GIT_BRANCH
                mkdir ./ActualSnapshots
                find . -name "__Snapshots__" -exec cp -R {} ./ActualSnapshots \;
                npm install
                npx reg-suit run

reg-suitがすべての仕事をしてくれるようにするためには設定する必要があります。実際に使っている設定ファイル(regconfig.json)では以下のような設定になっています。

{
  "core": {
    "workingDir": ".reg",
    "actualDir": "ActualSnapshots",
    "thresholdRate": 0,
    "addIgnore": true,
    "ximgdiff": {
      "invocationType": "client"
    }
  },
  "plugins": {
    "reg-keygen-git-hash-plugin": true,
    "reg-notify-github-plugin": {
      "prComment": true,
      "prCommentBehavior": "default",
      "clientId": "<clientId>"
    },
    "reg-notify-slack-plugin": {
      "webhookUrl": "<Webhook URL>"
    },
    "reg-publish-s3-plugin": {
      "bucketName": "<S3 Bucket Name>"
    }
  }
}

reg-suitではAWS、もしくはGCPを保存先として使えるようにできるプラグインが提供されています。今回は事業部でAWSを使っている関係でAWSを採用しました。Bitriseのワークフローのシークレットの項目にAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEYを記入しておくと使えました。

Slackで結果の画面を通知するともっと便利そうです。ということでSlackのチャンネルを作成してテスト結果が流れるようにしました。 最終的にはGithub上で結果を教えてもらえると自然にPRとテストを紐付けられます。 差分の表示もWeb上でインタラクティブに見ることが出来てかなり使いやすいです。

f:id:dmmadcale2021:20211202182921p:plain
Bitriseの環境変数設定画面

f:id:dmmadcale2021:20211202183244p:plain
Slack通知画像

f:id:dmmadcale2021:20211202183500p:plain
Githubで表示されるステータス

f:id:dmmadcale2021:20211202183851p:plain
差分表示

おわりに

どのようにしてプロダクトに一歩進んだ品質向上の取り組みができるのか試行錯誤した結果、ある程度歴史を重ねたアプリでも頑張らずに運用していけるテストの方法がある程度見えてきました。すべてのロジックがViewControllerに記述されているアプリではそもそも先にユニットテストができるようにすべきかもしれません。

しかし、どこを変えると見た目にも影響が出てしまうのか分からないアプリではユニットテストより先にビジュアルリグレッションテストを導入すると差分として分かりやすくPRの段階で機械的に教えてくれます。画面と状態が密接に結合したコードを紐解くような作業をこれからする、今している方は参考にしてみてください。