ZOZOTOWN iOS にスナップショットテストを導入して開発速度を劇的に向上させた話

f:id:vasilyjp:20200123114510j:plain

こんにちは! 開発部の@ahiru_starrrです。

本稿では、ZOZOTOWN iOSにSnapshotTestを導入したのでその経緯や導入方法、導入するメリット・デメリット、どんな場面で役に立つのかなどについて書いていきます。

SnapshotTestがどのようなものかよく分からない方や導入を検討している方々のお役に立てれば幸いです。

SnapshotTestとは

構成済みのUIViewまたはCALayerからスナップショットを生成し「正しい状態との比較・差分」を検出するためのテストです。

開発を進める中で、「意図せずにデザインやレイアウトが崩れてしまう」ようなケースはしばしば起こるかと思いますがそれを防ぐためのテストとして大変効果的です。

SnapshotTest導入の背景

きっかけとなったのは、iOSDC Japan 2019のスナップショットテスト実戦投入 / Practical Snapshot Testingのセッションです。
このセッションで実際に導入した話を聞いたことで、ZOZOTOWNのプロジェクトに導入するメリットが鮮明になりました。

現状のZOZOTOWN iOSの開発には2つの課題がありSnapshotTestを導入することでそれらの解決・緩和が見込めると考えたのです。

2つの課題

エンジニア ↔︎ デザイナー間のコミュニケーションコスト

レイアウトに関連する変更を加えた場合、ZOZOTOWNでは例え小さな変更であったとしても必ずデザイナーにデザイン確認を行なうフローがあります。
いわゆるデザインのリグレッションテストです。

ZOZOTOWNはデザインに強いこだわりのあるサービスのため、このフローは欠かすことができませんでした。

f:id:vasilyjp:20200122104937p:plain

しかしながら、より効率的でスピーディーな開発を目指していく上でこのフローが無駄なコストであることはいうまでもありません。少なくとも「開発の前後でデザインに差分がないことを確認する」だけであればもっと機械的にできるはずです。

またZOZOTOWNは主に千葉と東京、福岡の3拠点で開発が行われています。
そのためデザイナーとエンジニアのコミュニケーションはSlackやテレビ会議で行うことが多いのもコミュニケーションコストの増加に拍車をかけていました。

レガシーからモダンへの取り組み

ZOZOTOWNは長い歴史のあるサービスです。

iOSアプリは2010年にリリースされており、iOSアプリだけでみても約10年の歴史があります。 2010年というと、iPhone 4が発売された時期ですね。

長い年月とともに開発環境や開発言語は大きな進化を遂げましたが、一方でプロジェクトのソースコード量は膨大し、一部の取り残されたObjective-Cは大きな態度で居座り続けています。

この半年〜1年でチームメンバーは大きく変わりSwift化をはじめとするコードのモダン化への取り組みが活発に行われるようになりました。
リファクタリングも日常的に行われているため、その際のデザインのリグレッションテストをもっと機械的に効率よく行いたいと模索をしているところでした。

ソースコードは大きく変更したいものの、それに伴う画面のUIは変更したくない状況がZOZOTOWNでは頻繁にありました。

上記2つの課題を解決するためにSnapshotTestを導入しました。

導入方法

ZOZOTOWNでは「iOSSnapshotTestCase」×「OHHTTPStubs」の組み合わせで導入をしています。

iOSSnapshotTestCaseはUberがFacebookから引き継ぎ今も継続的にメンテナンスが行われているOSSです。
併せてスタブ用のレスポンスを返すためにOHHTTPStubsも導入しました。

ほとんどの画面はAPIからのレスポンスによって動的に構成されるため、SnapshotTestとの相性が非常に良いです。staticなjsonファイルをアプリに持たせておくことで常に同じ表示条件にてSnapshotTestができるのも大きなメリットだと思います。

上記2つのOSSはCocoaPodsにて導入しましたが、予めSnapshotTestを行うためのターゲットを作成しておきそのターゲットのみに導入をしました。

target 'SnapshotTests' do
    inherit! :search_paths
    pod 'iOSSnapshotTestCase'
    pod 'OHHTTPStubs/Swift'
end

環境変数を設定

OSSを導入したら環境変数の設定を行います。

これによりSnapshotTestを行った後の差分画像とリファレンス画像の保存先が設定されます。

XcodeのEdit SchemeからRunの項目を選択して、Environment Variablesに2つの環境変数を設定します。

IMAGE_DIFF_DIR $(SOURCE_ROOT)/$(PROJECT_NAME)SnapshotTests/FailureDiffs
FB_REFERENCE_IMAGE_DIR $(SOURCE_ROOT)/$(PROJECT_NAME)SnapshotTests/ReferenceImages


f:id:vasilyjp:20200114121624p:plain

FailureDiffsディレクトリにはSnapshotTestを行った後の差分の画像が保存され、ReferenceImages_64ディレクトリにはリファレンス画像が保存されるようになります。

実装方法

下記はUIViewControllerのSnapshotTestの実装例です。

import FBSnapshotTestCase
import OHHTTPStubs

@testable import ZOZOTOWN

class CartStockViewControllerTest: FBSnapshotTestCase {

    override func setUp() {
        super.setUp()

        folderName = "CartStockViewController"
        fileNameOptions = [.device, .OS, .screenSize, .screenScale]

        recordMode = false
    }

    func testCartSnapshotTest() {
        stub(condition: isPath("/stocklist.json")) { _ in
            let stubPath = OHPathForFile("stocklist_test.json", type(of: self))
            return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"])
        }

        let cartStockViewController = CartStockViewController()
        cartStockViewController.view.layoutIfNeeded()

        let exp = expectation(description: "Screen Loaded")
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.FBSnapshotVerifyView(cartModalStockViewController.view)
            exp.fulfill()
        }

        wait(for: [exp], timeout: 10.0)
    }

}

順番に説明していきます。

テストクラスを作成

はじめにテスト用のファイルを作成し、上記で導入した2つのOSSをimportしてFBSnapshotTestCaseを継承したクラスを作成します。

import FBSnapshotTestCase
import OHHTTPStubs

@testable import ZOZOTOWN

class CartStockViewControllerTest: FBSnapshotTestCase {
}

SnapshotTestではすでに実装済みの画面に対してテストを行う場合がほとんどのため
@testable importを記述します。これにより、プロダクトコードが記述されているターゲット内のinternalで宣言されたソースコードにアクセス可能となります。

recordModeを設定

SnapshotTestを行う際はrecordModeの設定が必要となります。

recordMode 説明
true リファレンス画像を生成
false SnapshotTestを行う

リファレンス画像すなわち正しいUIの状態のスナップショットを生成するときはrecordModeをtrueにセットしてSnapshotTestを実行します。
コードのリファクタリングなどを行った後でデザインのリグレッションテストを行う際にはrecordModeにfalseを設定してSnapshotTestを実行します。

テストコードを実装

フォルダ名を設定

folderName = "フォルダ名"

folderNameに適当なフォルダ名をセットします。

これを設定するとReferenceImages_64/フォルダ名/以下にリファレンス画像が出力されるようになります。

ファイル名を設定

fileNameOptions = [.device, .OS, .screenSize, .screenScale]

fileNameOptionsにてファイル名を設定します。

上記のような設定をした場合は「ファンクション_識別子_デバイス名_OSバージョン_スクリーンサイズ_倍率」のような画像名になります。

stubの設定

APIモックを用意する主な目的は最初の方でも述べましたが、APIのレスポンスによってテスト結果が変わるのを防ぐためです。

APIのレスポンスから動的に構成される画面のSnapshotTestを行う場合、リファレンス画像生成時のAPIのレスポンスとSnapshotTest実行時のAPIのレスポンスは同一である必要があります。
これを実現するために、OHHTTPStubsを導入しています。

stub(condition: isPath("/stocklist.json")) { _ in
    let stubPath = OHPathForFile("stocklist_test.json", type(of: self))
    return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"])
}

isPathの引数には、画面の表示に必要な情報を取得しているAPIのエンドポイントを指定します。

OHPathForFileの第一引数には予め用意していおいたテスト用のjsonファイル名を指定します。

FBSnapshotVerifyView

let cartStockViewController = CartStockViewController()
FBSnapshotVerifyView(cartStockViewController.view)

SnapshotTest対象のViewController(View)を生成し、FBSnapshotVerifyViewに渡してあげます。

CartStockViewControllerの内部ではViewのライフサイクルイベントをトリガーにAPIの通信処理がはしり、そのレスポンスを受け取って画面が組み立てられる実装になっています。

APIのレスポンス取得からレイアウト作成までは少し時間がかかるためexpectationを使って非同期処理を行なっています。

ZOZOTOWNでは極力既存の実装を変更しないことを優先してテストコードを書きましたが、この部分の実装に関しては

  • 予めモックとなるモデルを用意しておきViewControllerの初期化時に渡す方法
  • ViewControllerの初期化後にテストFunc内からAPIをコールし画面のレイアウトを行う方法

など様々な実装が考えられると思います。
各プロダクトに応じて最善の実装をしていただければと思います。

SnapshotTestのユースケース

ZOZOTOWNでのユースケースを例にSnapshotTestをしてみます。

テスト対象の画面はZOZOTOWNのカート画面です。

f:id:vasilyjp:20200115144125p:plain

コミットログを見る限り、最後に変更が加えられたのはもう何年も前でObjective-Cで記述されている画面です。

今回はSwiftで書き直し、さらにViewの構成も変更しました。

リファレンス画像を生成

まずはリファレンス画像を生成します。いわば、カート画面の模範(正しい状態)となる画像です。

recordModeをtrueに設定してテストを実行することで、ReferenceImages_64/以下にリファレンス画像が生成されます。

f:id:vasilyjp:20200115144208p:plain

SnapshotTest!!!

リファレンス画像を生成後、カート画面のリファクタリングを行なったブランチにてSnapshotTestを実行します。

recordModeをfalseに設定してテストを実行し、リファクタリングの前後でUIに差分がなければテストは成功し、差分がある場合にはテスト失敗のアラートが表示されます。

実際にテストを実行したところ下記の画像がFailureDiffs/ディレクトリに出力されました。

f:id:vasilyjp:20200115144013p:plain

リファクタの前後の画像を並べて比較すると差分がないように見えるのですが、実際には一部フォントサイズが違っていたりレイアウトが多少ずれていたりしました。

リファクタ前 リファクタ後
f:id:vasilyjp:20200115144208p:plain f:id:vasilyjp:20200115144325p:plain

SnapshotTestにより、これら差分の検出がとても簡単に行えます。

諸々修正して再度SnapshotTestを行うと成功しました。

f:id:vasilyjp:20200114124511p:plain

今までの開発では、リファクタリングが終わったタイミングでデザイナーにデザインの確認を依頼してエンジニアが再度修正、修正したら再度デザイナーに依頼....というフローがありましたが、SnapshotTestを導入したことによりこのフローが不要となりました。

デザイナー抜きでは実現できなかったタスクがエンジニアのみで完結できるようになったのです。

もちろんテストコードを書く必要はあるためその分のコストはかかってはしまいますが、それを差し引いても圧倒的に開発速度は向上します。

SnapshotTest導入のメリット・デメリット

ここまでSnapshotTestのメリットを挙げてきましたがデメリットもあると思います。

当然ながら、デザインに意図的な変更を加えた場合SnapshotTestは失敗となってしまいます。 その時は再度SnapshotTestでリファレンス画像を生成し直したり、あるいはstubを修正するなどの必要が出てくる場合もあり運用していく上でのコストがかかってきます。

デザインのほんの一部を修正したいだけなのにSnapshotTestのテストの方も修正する必要が出てきて全体としての開発速度が落ちてしまっては本末転倒です。

注意点としては、プロダクトに導入することで本当に恩恵を受けることができるのかよく考える必要はあるかなと思いました。

  • メリット

    • 導入コストが低い
    • デザインのリグレッションテストをエンジニアのみで行える
    • 開発速度の向上・開発の効率化が期待できる
    • 意図しないデザイン・レイアウト崩れを自動で簡単に検出できる
  • デメリット

    • 運用コスト

まとめ

SnapshotTestは、その性質を理解し上手に使うことで高い費用対効果を得ることができます。

実際にSnapshotTestを導入してみて、「効率的なチーム開発」「開発速度の向上」を実現するための選択肢の1つとして大きな力を発揮すると感じました。

今回は局所的にSnapshotTestを利用するユースケースをご紹介しましたが、CI/CD環境にSnapshotTestのWorkflowを組むことでデザインのリグレッションテストの自動化も可能です。

この記事がSnapshotTest実装の一助になれば幸いです。

ZOZOテクノロジーズでは、iOSエンジニアを募集しています。
興味のある方はこちらからご応募ください!

www.wantedly.com

カテゴリー