WEARをリノベ!Objective-CからSwiftへのリプレイス戦略でも使えるスナップショットテスト

ogp

目次

はじめに

memoji

みなさん、こんにちは! 松井です。普段はWEAR iOSアプリ開発で、コードを書く筋肉をパンパンに鍛えています。WEARアプリは、長い歴史を持っており、まだまだObjective-Cで書かれたレガシーなコードも居座っているんです。そんな中、私たちは地道にリファクタリングを進めています。そうしたObjective-CからSwiftへのリプレイス戦略において、スナップショットテストを活用したお話をしたいと思います。

スナップショットテストと聞くと、一般的にはコードの修正前後でUIが変わってしまっていないかをチェックするためのテスト手法という印象が強いでしょう。しかし今回は単なる修正ではなく、大胆なリプレイスにおいて旧と新のViewController(以下、VC)を比較した際のUIリグレッション確認のためにスナップショットテストを活用しました。スナップショットテストの力を借りて、リプレイスは無事に完了! 0.5ptの微妙なズレや、他にもいくつかリグレッションを見つけることができ大助かりでした。

マイページ画面リプレイスに伴う課題

私たちが着手したのは、投稿したコーディネートや動画、持っているアイテムやお気に入りしたコンテンツを一元管理するマイページ画面です。Objective-C製のコードで動いており、テストが書かれておらず、そもそもテストを書きづらい設計になっていました。しかもこの画面、各項目をタブで仕分けしており、1つの画面内でタブを切り替えるごとにUIがガラリと変わります。ユーザのプロフィールも表示しているので、プロフィール情報によってもUIが変化するんです。確認パターンが多すぎて、まさに開発者の強敵! 確認に工数がかかり、人的なミスも誘発しやすいです。ならばここは、スナップショットテストで対抗しましょう! スナップショットテストの使い方としては、まずObjective-Cによって書かれた旧VCでリファレンス画像を撮ります。それを元にSwiftへ生まれ変わった新VCが旧VCの見た目を再現できているかをテストすることで、解決を図ります。

mypage

使用したライブラリ

ライブラリはPoint-Freeのswift-snapshot-testingを選択しました。このライブラリは、デバイスごとのプリセットが用意されているという点が優れています。後述しますが、これによりデバイスのサイズを指定しなくても良くなり、複数のデバイスのテストを一度に行うことができるんです。

Objective-Cでリファレンス、Swiftでテスト

さて、Objective-CとSwiftでの見た目の違いを検証する際の、具体的なコードをお見せします。

@MainActor
final class MypageViewControllerTest: XCTestCase {
    // リファレンス画像撮影モードを切り替える
    private let isRecord: Bool = true

    func testコーディネートタブ_投稿1件() async throws {
        let vc: UIViewController
        if isRecord {
            // VCを作成して返すメソッドは別途作成する必要があります。
            vc = makeOldMypageViewController(tabType: .coordinate, dataCount: 1)
        } else {
            vc = makeNewMypageViewController(tabType: .coordinate, dataCount: 1)
        }
        snapshot(vc: vc)
    }

    private func snapshot(vc: UIViewController) {
        // テスト対象のViewControllerを表示
        UIApplication.shared.firstKeyWindow?.rootViewController = vc

        // スナップショットテスト実行部分
        // .imageHEICに関しては後述します。
        assertSnapshots(matching: vc, as: [.imageHEIC], record: isRecord, testName: "testコーディネートタブ_投稿1件")
    }
}

isRecord変数を定義し、このフラグを使うことでObjective-CとSwift、どちらのVCをテスト対象とするかを切り替えています。実際にスナップショットテストを実行するコードは、snapshot(vc: UIViewController)メソッドの中に閉じ込めています。

上記のテストコードを実行すると、isRecordがtrueの時は旧VCのマイページ画面でリファレンス画像が生成されます。isRecordをfalseにして実行すると、リファレンス画像と新VCのマイページ画面を比較するテストが走ります。リファレンス画像どおりの見た目になっていたらテスト成功となります。失敗した場合は、失敗画像が生成されるので、リファレンス画像との差分を見比べて新VCのコードを修正します。

普通にテストを実行するだけならここまでの話で十分なのですが、swift-snapshot-testingには、強力な機能がほかにも備わっています。次のセクションからは、私たちが実際に使ってみて「これは便利だな」と感じたTipsをいくつか紹介します。

リファレンス画像のファイルサイズを小さく

ここでは前節で省略した、assertSnapshotsの引数に指定した.imageHEICについて説明します。

リファレンス画像が増えたり、サムネイル画像が含まれていたりすると、どうしてもファイルサイズが大きくなってしまいます。しかもGitで管理したい場合、なおさらファイルサイズが気掛かりですね。ライブラリのREADMEを見ると多数の拡張ライブラリが紹介されており、その中にSnapshotTestingHEICというHEIC形式でリファレンス画像を出力してくれる拡張ライブラリを発見しました。swift-snapshot-testingはPNG形式でリファレンス画像を出力しますが、PNGよりHEICのほうがファイルサイズを小さくできます。こちらを採用した結果、ファイルサイズを1/4〜1/3くらいまで落とすことができました。 filesize

上のコードに出てきた.imageHEICというキーワードは、このSnapshotTestingHEICが提供している機能だったというわけです。拡張ライブラリは、他にもいくつか紹介されています。プロジェクトのニーズに合う拡張機能がないか確認してみると良いかもしれません。

デバイスも言語も一気にテスト

WEARでは、日本語、英語、中国語(簡体字・繁体字)と複数の言語をサポートしています。そのため、言語が変わった時の表示と、複数のデバイスでの表示も確認しておきたいですね。スナップショットテストを使えばこうした色んな条件でのテストも楽チンです。言語の自動化はTestPlan、デバイスはswift-snapshot-testingが提供している各端末サイズのプリセットを使えば可能です。

複数言語のテスト自動化

まず、XcodeでTestPlanを新規作成します。TestPlanのConfigurations > Application Languageでテスト対象の言語を設定するだけです。あとはXcodeが自動的に、選択した言語環境でテストを回してくれます。

testplan

複数デバイスを一気にテストする方法

次に複数のデバイスを1度にテストするための、管理と実施方法について説明します。今回swift-snapshot-testingを採用した決め手となった、プリセットの出番です!

まずは、テストしたいデバイスをまとめておくためのSnapshotConfigというenumを作ります。これにはViewImageConfigを使います。これこそがswift-snapshot-testingが提供する便利な機能で、それぞれのデバイスに対するプリセット情報を持っています。

import Foundation
import SnapshotTesting

enum SnapshotConfig: CaseIterable {
    case iPhone8
    case iPhone13
    case iPhone13ProMax
    case iPad9_7

    func device() -> ViewImageConfig {
        switch self {
        case .iPhone8:
            return ViewImageConfig.iPhone8
        case .iPhone13:
            return ViewImageConfig.iPhone13
        case .iPhone13ProMax:
            return ViewImageConfig.iPhone13ProMax
        case .iPad9_7:
            return ViewImageConfig.iPad9_7
        }
    }
}

これで準備完了です。あとはテストケースでSnapshotConfig.allCasesを回すだけ。一度のテスト実行で、複数のデバイスのテストが可能です。

@MainActor
final class MypageViewControllerTest: XCTestCase {
    private let isRecord: Bool = true

    func testコーディネートタブ_投稿1件() async throws {
        let stubUser = makeStubUser()
        WRAccountManager.sharedInstance()?.setValue(stubUser, forKey: "user")

        SnapshotConfig.allCases.forEach {
            let vc: UIViewController
            if isRecord {
                vc = makeOldMypageViewController(tabType: .coordinate, dataCount: 1)
            } else {
                vc = makeNewMypageViewController(tabType: .coordinate, dataCount: 1)
            }
            snapshot(vc: vc, config: $0)
        }
    }

    private func snapshot(vc: UIViewController, config: SnapshotConfig) {
        UIApplication.shared.firstKeyWindow?.rootViewController = vc

        let suffix = "\(config)-\(Locale.preferredLanguages.first ?? "")"

        assertSnapshots(matching: vc, as: [.imageHEIC(on: config.device)], record: isRecord, testName: "testコーディネートタブ_投稿1件" + suffix)
    }
}

ここで紹介した実装方法は、メルペイiOSチームのスナップショットテストを効率化した話を参考にしました。

いにしえVCのためのスタブデータの用意

さいごに、あなたのいにしえVCでも使えるかもしれないスタブデータの用意の仕方をご紹介します。

スタブデータの作成、今回これがなかなかの難敵でした。マイページ画面はユーザ情報によりUIが変化します。テストケースごとにこの情報を変えたいのですが、お相手は、Objective-Cで書かれたダシの効いたコード。ユーザ情報はAccountManagerクラスでuserプロパティとして保持されており、userプロパティはreadonlyになっていることから、スタブデータのセットが難しい状況でした。AccountManagerクラス自体の改修は、今回のリプレイスに関係のないコードにまで連鎖的に影響を及ぼす可能性があり、そう簡単には手出しできません。そんな時、大活躍したのがKey-Value Coding(KVC)を使う方法です。KVCはNSObjectが標準で提供している機能で、これを使えば、例えプロパティがreadonlyだったとしても、Objective-Cの変数の値をいじったり取り出したりできます。

具体的な使い方は以下のような感じです。

func testヘッダー_性別のみ表示() async throws {
    let stubUser: User = makeStubUser()
    stubUser.sexName = "WOMAN"
    stubUser.height = nil
    AccountManager.sharedInstance()?.setValue(stubuser, forKey: "user")

    // 以下、スナップショットテスト実行コード 
}

まずスタブのユーザ情報を持ったstubUserを作成します。上記の例では性別を女性、身長を未設定にしています。このstubUserをAccountManagerのuserプロパティにsetValueメソッドを使ってセットすることで、テストケースごとにユーザ情報をコントロールすることが可能になります。とても便利な機能ですが、あくまでNSObjectが標準で提供するものですので型チェックをスルーしてしまうところが落とし穴です。プロダクトコードでの使用は避けたほうが良いですが、今回はテストコードだったため使用しています。

おわりに

Swiftに生まれ変わったマイページ画面がリリースされ、Objective-C製のマイページにさよならするとき、リグレッションの役目を終えたスナップショットテストも削除しました。役目を全うしたスナップショットテスト、おつかれさまでした! 今後も大掛かりなリプレイスをする際に活躍してくれることでしょう。この記事が、みなさんのプロジェクトでのリプレイス戦略にも何かのヒントになれば嬉しい限りです。より良いソフトウェア開発をこれからも進めていきましょう!

ZOZOでは一緒に楽しく働くエンジニアを絶賛募集中です。ご興味のある方は下記リンクからぜひご応募ください。

corp.zozo.com

カテゴリー