Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

メルペイ iOS にスナップショットテストを導入した話

Merpay Advent Calendar 2019 の16日目は、メルペイ iOS チームの @akifumi がお送りします。

メルペイ iOS の品質向上を目的に、スナップショットを用いたテストを行うことができる iOSSnapshotTestCase を導入した話について記載します。

f:id:akifumi-fukaya:20191209174346p:plain

目次

iOSSnapshotTestCaseとは

iOSSnapshotTestCaseUIViewCALayerのスナップショット画像を作成し、画像を比較することでテストを行うライブラリです。

iOSアプリを開発する中で、ユニットテストは導入しやすいですが、UIテストは難しいことが多くあります。

XCUITest の登場によりUIテストを作成できるようになりましたが、テストのメンテナンスが困難なことによってテストが失敗されたまま放置されてしまうことが散見されます。

スナップショットテストは UIViewControllerUIView の画像を作成するだけなので、メンテナンスを用意にすることが可能です。

またGithub上で画像を管理することで、PM・Designer・QAの方に共有することができるという利点もあります。

本記事では、メルペイにおいてどのように iOSSnapshotTestCase を導入したかについて記載したいと思います。

iOSSnapshotTestCaseの導入方法

導入準備

メルペイ iOS チームでは、メルカリアプリに対してメルペイの機能を提供するために merpay-ios-sdk というプロジェクトを開発しています。

merpay-ios-sdk は、メルペイの機能を提供する複数のフレームワークで構成されています。

全てのフレームワークに対してスナップショットテストを適応するために、 Snapshots というスナップショット専用のプロジェクトとアプリを追加しました。

f:id:akifumi-fukaya:20191204144607p:plain

インストール

iOSSnapshotTestCaseSnapshots のテストターゲットである SnapshotsTestsCocoaPods を使用してインストールしました。

Podfile

target 'SnapshotsTests' do
  workspace 'Merpay'
  project 'Snapshots/Snapshots.xcodeproj'
  pod 'iOSSnapshotTestCase'
end

SnapshotsTests で全てのフレームワークに対してスナップショットテストを実装しています。

導入のながれ

iOSSnapshotTestCase でテストを行う場合は次のような導入手順が必要です。

  1. 環境変数 SNAPSHOT_TEST_RECORD_MODEtrue にし、テストを実行する。するとテスト対象の画面のキャプチャを撮影される。
  2. 環境変数 SNAPSHOT_TEST_RECORD_MODEfalse にし、テストを実行する。テスト対象の画面が1で撮影された画像と比較される。もし画像が異なる場合、環境変数 IMAGE_DIFF_DIR で指定したディレクトリに比較画像が保存される。

環境変数の定義

SnapshotsTests スキーマで以下の環境変数を定義しました。

SNAPSHOT_TEST_RECORD_MODE は参考画像を作成する際に使用します。

Name Value Description
FB_REFERENCE_IMAGE_DIR $(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages テスト時に参照する画像のディレクトリ
IMAGE_DIFF_DIR $(SOURCE_ROOT)/$(PROJECT_NAME)Tests/FailureDiffs テスト時にレファレンス画像と異なる場合、差分画像が保存されるディレクトリ
SNAPSHOT_TEST_RECORD_MODE $(SNAPSHOT_TEST_RECORD_MODE) レコードモードがtrueなら画像が作成され、falseならテストが実行される
<EnvironmentVariables>
   <EnvironmentVariable
      key = "FB_REFERENCE_IMAGE_DIR"
      value = "$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages"
      isEnabled = "YES">
   </EnvironmentVariable>
   <EnvironmentVariable
      key = "IMAGE_DIFF_DIR"
      value = "$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/FailureDiffs"
      isEnabled = "YES">
   </EnvironmentVariable>
   <EnvironmentVariable
      key = "SNAPSHOT_TEST_RECORD_MODE"
      value = "$(SNAPSHOT_TEST_RECORD_MODE)"
      isEnabled = "YES">
   </EnvironmentVariable>
</EnvironmentVariables>

f:id:akifumi-fukaya:20191204151315p:plain

スナップショットの作成

SnapshotTestCase を作成し、このクラスを継承してスナップショットテストを作成するようにしています。

SnapshotTestCase.swift

import FBSnapshotTestCase

class SnapshotTestCase: FBSnapshotTestCase {

    var window: UIWindow!

    override func setUp() {
        super.setUp()
        recordMode = ProcessInfo().environment["SNAPSHOT_TEST_RECORD_MODE"] == "true"
        fileNameOptions = [.device, .OS, .screenSize, .screenScale]

        window = UIWindow(frame: UIScreen.main.bounds)
        window.makeKeyAndVisible()
    }
}

UIViewControllerのテスト例

下記は、メルペイ画面のスナップショットテストの例です。

import XCTest
@testable import MerpayMercariWalletKit
import FBSnapshotTestCase

class DashboardViewControllerTests: SnapshotTestCase {

    func testDashboardSnapshot() {
        let service = MockService()
        let dependencyRegistry = MockMerpayDependencyRegistry()
        dependencyRegistry.service = service
        let vc = DashboardViewController(input: .init(), dependencyRegistry: dependencyRegistry)
        FBSnapshotVerifyView(vc.view)
    }
}

ViewController を作成し、FBSnapshotVerifyViewUIView を渡すことでスナップショットの作成・テストを行うことができます。

CALayerのテスト例

FBSnapshotVerifyLayer を使用し、 UIWindowCALayer をでスナップショットすることで、全画面ではない画面の表示テストを行うこともできます。

import XCTest
@testable import MerpayMercariWalletKit
import FBSnapshotTestCase

class BankRechargeNavigationControllerTests: SnapshotTestCase {

    private var vc: ViewController!

    override func setUp() {
        super.setUp()

        vc = ViewController()
        window.rootViewController = vc
    }

    override func tearDown() {
        window.rootViewController = nil
        vc = nil

        super.tearDown()
    }

    func testBankRechargeNavigationController() {
        let exp = expectation(description: "open BankRechargeNavigationController")

        let dependencyRegistry = MockMerpayDependencyRegistry()
        let bankRechargeNavigationController = BankRechargeNavigationController(input: .init(chargeType: .selectable, completion: nop), dependencyRegistry: dependencyRegistry)

        vc.present(bankRechargeNavigationController, animated: true)
        // Waiting for animation completion
        DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
            self.FBSnapshotVerifyLayer(self.window.layer)
            exp.fulfill()
        })

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

    private final class ViewController: UIViewController {
        override var prefersStatusBarHidden: Bool {
            return true
        }
    }
}

f:id:akifumi-fukaya:20191206144734p:plain

capture_snapshots lane

fastlane を使用してスナップショット画像を作成するために capture_snapshots というlaneを作成しました。

private_lane :snapshot_devices do
  [
    "iPad Pro (11-inch) (13.1)",
    "iPhone 8 (13.1)",
    "iPhone SE (12.2)",
    "iPhone Xs (12.2)",
    "iPhone Xs Max (12.2)",
    "iPhone Xʀ (12.2)"
  ]
end

lane :capture_snapshots do
  xcversion(version: "11.1")
  sh("rm", "-fr", "../Snapshots/SnapshotsTests/ReferenceImages_64/")
  ENV['SNAPSHOT_TEST_RECORD_MODE'] = 'true'
  scheme = ENV['SNAPSHOT_TEST_SCHEME']
  snapshot_devices.each do |device|
    scan(
      scheme: scheme,
      device: device,
      fail_build: false
    )
  end
end

スナップショットテスト用の画像は Snapshots/SnapshotsTests/ReferenceImages_64/ ディレクトリに出力されます。

スナップショットテスト

snapshot_test lane

fastlane を使用してCI上でスナップショットテストを実行するために snapshot_test というlaneを作成しました。

lane :snapshot_test do
  scheme = ENV['SNAPSHOT_TEST_SCHEME']
  test_scheme(
    scheme: scheme,
    devices: snapshot_devices
  )
end

テストが失敗すると、 Snapshots/SnapshotsTests/FailureDiffs/ というディレクトリに失敗した画像が出力されます。

以下の失敗時のサンプル画像です。

f:id:akifumi-fukaya:20191209184041p:plain

CircleCIのartifactsに失敗した画像を保存

CircleCIのjobの設定で、スナップショットテストに失敗した画像をartifactsに保存するようにしています。

そうすること、Web上からスナップショットテストが失敗した際の画像の確認ができ、修正作業を行いやすいようにしています。

      - store_artifacts:
          path: Snapshots/SnapshotsTests/FailureDiffs

CircleCIのWorkflow設定

merpay-ios-sdk では、CircleCI上で unit_testsnapshot_test を並列に実行することで、テスト時間を削減する工夫もしています。

f:id:akifumi-fukaya:20191204154730p:plain

メルペイではミッション・バリューに共感できるiOSエンジニアを募集しています。一緒に働ける仲間をお待ちしております。 apply.workable.com

明日のMerpay Advent Calendar 執筆担当は、 Experts Team の @tenntenn さんです。引き続きお楽しみください。

p.s.

アドベントカレンダーには参加していませんが、先週、メルペイ iOSチームの @kenmaz さんがブログを公開しました。 Xcode Previewsを用いたUIKitベースのプロジェクトの開発方法の詳細と、それによって得られたメリットや知見について紹介しています。宜しければこちらもご覧ください。

tech.mercari.com