チームのtakeshiがお送りします。メルペイiOSチームのtakeshiです。メルカリエンジニアリング」でご紹介したとおり、メルペイiOSチームではスナップショットテストを導入し画面のレイアウト確認を行っていました。その後の運用でいくつかの課題も見つかりました。この記事ではメルペイiOSチームがスナップショットテストを運用していく中で見つけた課題とそれをどう解決したのか、また効率的にスナップショットテストを開発フローへ導入する方法についてお話します。スナップショットテストとはまずはじめにスナップショットテストという用語について整理します。スナップショットテストを一言で表せば「画面レイアウトのテスト」です。iOSに限らず、クライアントアプリを開発する上で重要になるものが、画面のレイアウトが正しく表示されているかどうかです。Testでは確認することが難しいものです。レイアウトを確認するためには、アプリを操作し、画面をキャプチャする必要がありました。画面をそのまま画像として出力し、それを確認・比較できれば、アプリを操作する手間を省き自動化することができます。この画面の見た目に焦点を当てたテストをスナップショットテストといいます。Snapshotという用語には「ある時点でのデータを抜き出す」という意味があるので、それをクライアントアプリの画面レイアウトに対して用いている格好になります。出力するデータは画像だけでなく、View階層を表すファイルであることもあります。○Unitテストはロジックを担保するために作成するもので、レイアウト確認には適していません。UIテストやマニュアルテストでは、アプリを操作し画面を確認します。アプリを操作するためのデータは実際のユーザーとほぼ同じ環境で作成しなければいけません。近況」で公開したように、テストに必要なお客さまデータを簡単に作成できる仕組みが整っています。テストの環境がクライアントに閉じず、通信状態やサーバーの状態に依存するといった不確実性を伴います。スナップショットテストはコードからViewをレンダリングして画像に出力することで、アプリを起動しなくても画面の確認・比較をするテストです。そのため、モックデータを差し込めるので、安定した同じデータを使ってViewをテストできます。メルカリエンジニアリング」で解説したとおり、iOSSnapshotTestCaseというOSSを利用していました。Blogを参照ください。swift-snapshot-testingiOSSnapshotTestCaseと同様にスナップショットテストのライブラリーとしてもう一つ有名なのが、swift-snapshot-testingです。このライブラリーは関数型言語とSwiftがテーマのWebマガジンPoint-Freeが運営しているOSSです。基本的な使い方はiOSSnapshotTestCaseと同じです。一度撮影のためのテストを実行すると画面を画像に出力します。その画像を比較用の画像としてコミットしておきます。2回目以降テスト時に、その比較用の画像とテスト時に作成された画像を比較し、差異がなければテスト成功、あればテスト失敗となる仕組みです。iOSSnapshotTestCaseとの違いは次のようなものがあります。見えてきた課題点2019年12月ごろに導入して半年ほど経った頃、スナップショットテストの運用でさまざまな課題が見えてきました。大まかに2つあります。出力画像に微妙に差がある問題これらの問題がswift-snapshot-testingを組み合わせることで解決できそうでした。問題の詳細とどのように解決したのかを解説します。実行時間の増加iOSSnapshotTestCaseはスナップショットを撮る画面のViewサイズは自分で用意しなければいけません。複数端末のスクリーンショット画像が必要だったため、次のような操作で各画面の画像を撮影していました。はじめにメルペイiOSチームではテストコードの親クラスにWindowを保持させ、そのサイズをUIScreen.main.boundsに設定します。Controllerを設定し、windowに対してスクリーンショットを撮っていました。CircleCIで必要なだけの端末種別それぞれに対してスナップショットテストを実行していました。iPadの10種類です。実行端末が増えるごとにテストの時間も伸びていきます。こちらは2020年9月のUnitテストとスナップショットテストのCircleCI上の結果です。Unitテストのテストケースの数は2013ケース、対してスナップショットテストケースの数は206ケースでした。テストケース数は10分の1ですが、スナップショットテストのほうが時間がかかっていることがわかります。テストの時間が長引くと待ち時間が発生し、開発効率が悪くなってしまいます。できれば短くしたいと思っていたところ、swift-snapshot-testingでは各端末サイズのプリセットがあることが判明したので、それを利用しました。swift-snapshot-testingで実行時間を短縮swift-snapshot-testingには各端末サイズのプリセットが用意されています。スナップショットテストのコードに端末の情報を渡せば、その端末のサイズごとに画面の画像を確認できるのです。この機能を利用すれば、テストの実行は一度で済むので大幅な実行時間の短縮が望めます。どのようにswift-snapshot-testingで各端末のスナップショットテストを実装できるか、具体的にコードをみてみましょう。areaのインセット、UITraitCollectionを表す型が提供され、ツール側で各画面のプリセットがあらかじめ定義されています。そこでDeviceNameという型を作って各端末サイズを取り出せるようにします。}}実際のテストコードは次のとおりです。}}SnapshotConfig.DeviceName.allCases.forEachで指定の端末をそれぞれ回し、assertSnapshot関数を呼んでスナップショットテストを実行します。すると次のように、各端末サイズのスナップショット画像が出力されます。これを利用して、一回のテスト実行で、複数端末のスナップショットが取得できます。実行端末を削減して実行時間を短縮swift-snapshot-testingを導入したことで、一回の実行で複数端末のスナップショット画像が撮れるようになりました。そこで、2020年9月、現状10端末で実行していたテストのJobを1端末に削減しました。すると、今まで17分ほどかかっていたテストが8分ほどと、約半分の時間で済むようになりました。出力画像に微妙に差がある問題テスト時に出力される画像に、一定の条件で微妙な差が生じてしまいテストが失敗してしまう問題がありました。次のような画面でテストが失敗しました。違いがわかりますでしょうか?銀行のアイコンの下がほんのりと赤く出力されていることがわかると思います。このように、期待される画像と異なる出力が行われ、テストが失敗してしまうことがしばしば起こり、開発の効率が悪くなってしまいました。この問題はiOSSnapshotTestCaseの公式でもIssueがあげられています。11よりシュミレーターのプロセスにGPUが使われるようになり、GPUベンダーによって処理が異なるので出力画像に差異が出てきてしまったようです。透過部分をもつ画像でよくこの問題が起こっていました。メルペイiOSチームではCIサービスにCircleCIを利用していますが、テスト実行時のmacOSが毎回変わってしまっているため、対処が難しくなってしまいました。画像比較のしきい値を設定してエラーを回避するiOSSnapshotTestCaseとswift-snapshot-testingの両方の画像出力実装を比較したところ、Objective-CとSwiftの違いはあれ、実装内容はほぼ同じでした。スナップショットテストにswift-snapshot-testingを利用しても問題は解決しません。Swizzlingを使ってUIImageに対して透過しないような処理を行いましたが、変わらず画像の差異は起こり続けました。対応を検討した末、最終的には画像比較のしきい値を設けることにしました。iOSSnapshotTestCaseとswift-snapshot-testingは名前は異なりますが、画像の差異を許容する引数をテスト関数に持っています。これを使って、多少画像に差異があってもテストがパスするようにしました。各ライブラリーのしきい値の表は次のとおりです。画像全体のピクセル単位の失敗を許容するパーセンテージ。画像全体のピクセル単位の一致していなければならないパーセンテージ。指定した数値以上にマッチしなければTestは失敗する例えば、10%の差異を許容するコードを考えます。iOSSnapshotTestCaseで次のようなコードを書きます。0.1)この場合、比較用の画像とテスト時に作成された画像の差異が10%を超えた場合にテストが失敗します。swift-snapshot-testingでのサンプルコードは次のとおりです。0.9))この場合、比較用の画像とテスト時に作成された画像が90%以上一致すればテストに合格します。iOSSnapshotTestCaseと同じ言い方をすれば、10%未満の画像差異があっても許容します。iOSSnapshotTestCaseとswift-snapshot-testingでしきい値を指定する引数の意味が正反対なことに注意する必要があります。実際にメルペイでは10%までの差を許容することにしました。今のところうまくいっています。画像比較にしきい値を設けたことで、意図しない画面の更新をプログラム的に判断することが難しくなりました。画像が変更されたことは目視で確認するしかないでしょうか?幸いswift-snapshot-testingには.recursiveDescriptionというView階層をテキストファイルに出力する機能を持っています。これを使えば、意図しないViewの変更があった場合にテストが失敗して検知できます。コードはこのように書きます。.recursiveDescription)これを実行すると次のような内容のテキストファイルが出力されます。<_UILabelLayer>>ただし、.recursiveDescriptionを使う場合の注意点があります。View階層は同じ画面でも端末の種類やOSのバージョンによって、異なる結果が出力されます。常に同じ結果を得るために、実行端末とOSバージョンを固定する必要があります。メルペイではテストの実行時に次のようなassertを使って、期待する端末とOSでテストが実行されているかをチェックしています。14.2")これで画像比較にしきい値を設けても、意図しないViewの変更を検知できるようになりました。便利に見えるswift-snapshot-testingも撮影するのが苦手な画面があります。それは画面全体のWindowをキャプチャすることです。#279として上がっています。基本的に公式ではWindow全体をキャプチャするAPIは提供しておらず、自分で拡張する他なさそうです。}swift-snapshot-testingの導入方針swift-snapshot-testingの特徴を調べたのでどのようにチームに導入するかを考えました。複数端末を一度で取得できる一方で、Windowを全体を撮るのは苦手です。メルペイではポップアップや、ハーフモーダルなどで表示するコンポーネントが多くあり、Window全体のスナップショットテストが必要でした。そこでiOSSnapshotTestCaseとswift-snapshot-testingを併用して利用することにしました。次のように各ライブラリーによってスナップショットテストの目的を分けました。View自体のスナップショットテストswift-snapshot-testing用のtargetを追加し、View自体のテストをそのtargetに追加していく運用にしました。iOSSnapshotTestCaseで実装しているテストコードで移行できるものは、swift-snapshot-testingで書き直していくことにしました。Typeをテストするswift-snapshot-testingを導入したことで、さまざまな恩恵がありました。Typeのスナップショットテストができるようになったことです。Typeができなかったのですが、swift-snapshot-testingのおかげてスナップショットテストを実行できるようになりました。具体的にどうやってテストを作るかをみてみましょう。メルペイでは、UIContentSizeCategoryの各サイズを配列で保持し、スナップショットテストを作成しています。]}allContentSizesをforEachで回して各コンテンツサイズのキャプチャを撮ります。Typeのキャプチャを撮影します。撮影された結果はこのようになります。Typeのスナップショットテストを行っています。開発フロー導入の手助けスナップショットテストもUnitテストと同様に開発フローに入れていかなければ、品質の良いコードを開発することはできません。プロダクションコードを実装しつつ、テストコードも作るのは大変なものです。そこで私はテンプレートを用意して、すぐにスナップショットテストコードを実装できるようにしました。Controllerを作成すればすぐにスナップショットテストが作れます。11から登場したPreviewsの機能を使って、ViewをUIKitからSwiftUIに変換しViewを確認する仕組みがある程度確立していました。swift-snapshot-testingではSwiftUIで作成した画面に対してのスナップショットテストもできます。Previewsとswift-snapshot-testingを組み合わせてプレビューから直接スナップショットテストを作成する方法を導入しました。テストコードを書きやすい環境にして、開発フローに入れる仕組みを整えることが重要です。まとめこの記事では去年導入したスナップショットテストを今年はどのように効率化したのかをお伝えしました。swift-snapshot-testingを導入したことで、一度の実行で複数端末サイズのスナップショットを取得できるようになりました。出力画像に微妙に差がある問題は、しきい値を下げつつ、swift-snapshot-testingの.recursiveDescriptionを利用することで意図しない変更を検知することで解決しました。テンプレートの作成やPreviewsからスナップショットテストを生成することでテストコードを書きやすい環境も整えました。引き続きスナップショットテストの運用を続けていき、品質の高いアプリ開発を目指します。引き続きお楽しみください。