blog.yujinakayama.me

FBSnapshotTestCaseでUIViewの描画をテストする

Feb 14th, 2014

先日、iOS 7のMusic.app内の再生状態インジケータのクローンなビュー、NAKPlaybackIndicatorViewを作りました。

Music.app

このプロジェクトでは、ビューが期待通りに描画できているかのテストにFBSnapshotTestCaseというものを使っています。

FBSnapshotTestCaseとは

FBSnapshotTestCaseは、 Facebookによる、ビューの“snapshot test”ライブラリです。 “snapshot test”とは、テスト対象のUIViewまたはCALayerに表示されたコンテンツのスナップショットを撮り、 事前に用意しておいた“reference image”(PNG画像)と同一かどうかをピクセルレベルでチェックすることで、 将来の予期しない表示崩れを検出するものです。

FBSnapshotTestCaseXCTestCaseのサブクラスであり、snapshot test用のヘルパーメソッドやマクロを追加した程度のものなので、 特にsnapshot testでない通常のテストを実行することも可能です。

蛇足ですが、 「ビューの表面に関わる」テストというと KIFFrank などによるエンドツーエンドが一瞬連想されますが、 これらはUIを介してアプリ全体の機能をテストするものであり、 単体のビューの描画内容をテストするsnapshot testとは別物です。

使い方

インストール

CocoaPodsでインストールが可能です。

# Podfile

target 'MyProjectTests' do
  pod 'FBSnapshotTestCase', '~> 1.0'
end

Reference Imageファイルを置くディレクトリのパスを設定する

Reference imageを保存するディレクトリのパスを、プリプロセッサマクロFB_REFERENCE_IMAGE_DIRで定義します。

公式には以下の記述をxcconfigファイルに追加するが挙げられています。

GCC_PREPROCESSOR_DEFINITIONS = $(inherited) FB_REFERENCE_IMAGE_DIR="\"$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages\""

しかし今回はCocoaPodsによって既にxcconfigファイルが自動生成されてしまっており、 ここに手動で設定を追加するのはメンテナンス性に欠けるため、プロジェクトそのもののBuild Settingsに設定を追加することにします。 以下のように、テストターゲットの Build Settings 内、 Preprosessor MacrosFB_REFERENCE_IMAGE_DIR="\"$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages\""を追加します。テストターゲットのファイル群を入れているディレクトリ名をXcodeデフォルトの$(PROJECT_NAME)Testsから変更している場合は、パス中のその部分を合わせます。また、ソース内への展開時にCの文字列リテラルになる必要があることに注意して下さい。

FB_REFERENCE_IMAGE_DIR

ちなみにこの例ではXcodeのビルド設定SOURCE_ROOTPROJECT_NAMEの値を参照しているため、 ソース内で#defineしてもこれらは展開されません。

テストケースクラスを準備する

既存のXCTestCaseなテストのスーパークラスをFBSnapshotTestCaseに変えるか、 新しくFBSnapshotTestCaseを継承したクラスを作成します。

その後、-setUpメソッド内にself.recordMode = NO;(詳細は後述)を加えます。

雛形としては以下のような形になります。

#import <XCTest/XCTest.h>
#import <FBSnapshotTestCase/FBSnapshotTestCase.h>

@interface SnapshotTests : FBSnapshotTestCase
@end

@implementation SnapshotTests

- (void)setUp
{
    [super setUp];
    self.recordMode = NO;
}

@end

テストを書く

通常のXCTestCaseのテストと同様に、testで始まるメソッド内にテストを記述します。

テスト対象となるビューを用意し、スナップショットを撮りたい状態にしたら、FBSnapshotVerifyViewマクロにビューを渡します。 ここでは、UILabelによってテキストがちゃんと描画されるかテストすることにします。

- (void)testLabel
{
    UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
    label.text = @"Foo";
    [label sizeToFit];
    FBSnapshotVerifyView(label, nil);
}

さて、ここで ⌘U でテストを実行してみましょう。 現在のバージョン1.0.0では実機でのテストはサポートされていないので、シミュレータで実行します。 すると以下のようなエラーでテストが失敗します。

...
Snapshot comparison failed:
Error Domain=FBTestSnapshotControllerErrorDomain
Code=1 "Unable to load reference image."
UserInfo=0xa849a70 {NSLocalizedFailureReason=Reference image not found. You need to run the test in record mode,
...

Reference imageが無いと言われています。当然です。まだ用意していません。

しかし、実はreference imageを自前で用意する必要は基本的にありません。 前述のself.recordModeYESにしてテストを実行すると、 FBSnapshotVerifyViewマクロは常に成功し、 現在のビューのスナップショットをreference imageとして保存するようになります。 逆に言えばテストをしなくなるので、self.recordMode = YES;の状態で Gitなどのバージョン管理システムにコミットしてはいけません。

さて、実際にself.recordMode = YES;に変更してテストを実行してみるとパスするはずです。 その後git statusをしてみると、 MyProjectTests/ReferenceImages/SnapshotTests/testLabel@2x.pngのようなファイルが生成されており、 Preview.appなりで開いてみると以下のような内容になっているのが確認できます。

Foo

適切に表示されているのが確認できたらself.recordMode = NO;に戻し、もう一度テストを実行してパスすることを確認します。 その後、テストファイルと生成された画像ファイルをコミットします。

これによって今後、ビューがこの画像と同一の内容を描画するかどうかがチェックされることになります。 一度目視確認してしまえば、後はずっと自動化してくれる訳ですね。

ここまでが基本的なワークフローになります。

  1. テストを書く
  2. self.recordMode = YES;でテストを実行する
  3. 生成された画像を目視で確認する
    • 画像の内容が適切であればself.recordMode = NO;に戻し、画像と一緒にコミットする
    • 画像の内容が意図したものでない場合は、意図した表示になるまでビューの実装側のコードを修正、テスト実行(画像生成)、目視確認を繰り返す

テストの失敗

Reference imageがちゃんと用意されているテストが失敗すると、 Xcodeのコンソールに以下のようなログが表示されます。

If you have Kaleidoscope installed you can run this command to see an image diff:
ksdiff "/Users/me/Library/Application Support/iPhone Simulator/7.0.3/Applications/66392008-F6EB-4C2C-BAE8-90977D37893A/tmp/SnapshotTests/reference_testLabel@2x.png" "/Users/me/Library/Application Support/iPhone Simulator/7.0.3/Applications/66392008-F6EB-4C2C-BAE8-90977D37893A/tmp/SnapshotTests/failed_testLabel@2x.png"

このksdiffの行をターミナルで実行すると、 GUI diffツールのKaleidoscope(有償)で画像の差分が確認できます。 Kaleidoscopeを持っていない場合は、failed_xxx.pngの方のファイルを開けば失敗した画像を確認できます。

Tips

Reference Imageのパス

Reference imageは、FB_REFERENCE_IMAGE_DIR以下にテストケースクラス名/テストメソッド名.pngで保存されます。 また、FBSnapshotVerifyViewの第2引数に適当な識別子となるNSStringを渡すと、それがファイル名に追加されます。 一つのテストメソッド内で複数回FBSnapshotVerifyViewを使う場合に有効です。

- (void)testLabel
{
    UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];

    label.text = @"Foo";
    [label sizeToFit];
    FBSnapshotVerifyView(label, @"foo");
    // MyProjectTests/ReferenceImages/SnapshotTests/testLabel_foo@2x.png

    label.text = @"Bar";
    [label sizeToFit];
    FBSnapshotVerifyView(label, @"bar");
    // MyProjectTests/ReferenceImages/SnapshotTests/testLabel_bar@2x.png
}

が、基本的には1メソッド1アサーションとし、第2引数はnilで省略した方が良いと思います。

Retina・非Retina

既にお気付きかと思いますが、画像はRetina用と非Retina用で別々に書き出されるので、 非Retina環境でビューの位置が0.5ポイントずれて表示がボケるようなケースにも対応できます。 Rakeやシェルスクリプトを使って、一挙にRetinaと非Retina両方でテストを実行するようなタスクを準備しておくと便利です。

ビューの内部実装に非依存

FBSnapshotTestCase-[CALayer renderInContext:]を使ってスナップショットを撮っており、 これでキャプチャできるものであれば、 内部的に複数のサブビューを組み合わせていようが、CALayerを使っていようが、 Core Graphicsを使っていようが、UIImageViewで画像を貼付けているだけであろうが、何でもかまいません。 パフォーマンス上の都合から内部実装を大きく変えつつ描画内容は変えない、といったリファクタリングにも有効そうですね。

何から何までスナップショットを撮らなくても良い

特に既存のライブラリにテストを追加する場合、いきなりあらゆるケースを網羅しようとするとしんどいので、 エッジケースで表示崩れのバグが見つかった場合にテストを追加する程度で良い気がしています。 そもそもrecordModeの仕組みとワークフローからしてリグレッションテスト向きで、 実際FBSnapshotTestCaseはそのような動機で開発されたようです。

At Facebook we write a lot of UI code. As you might imagine, each type of feed story is rendered using a subclass of UIView. There are a lot of edge cases that we want to handle correctly:

自分でReference Imageを作っても良い

self.recordMode = YES;でreference imageを自動生成できますが、 自前で画像を用意してもかまいません。 あまりなさそうなケースですが、完成イメージが固まっていて描画内容が比較的単純な場合、 適当な画像編集ツールでreference imageを先に作っておくことで、テストファーストなビュー開発をすることもできそうです。 今回のNAKPlaybackIndicatorViewなんかはまさに打ってつけですね(実際は途中から導入したのでテストファーストではありませんでしたが)。

感想

正直なところ、最初はビューの描画テストなんてものすごい面倒くさくて割に合わないものかと思っていましたが、 下準備が済んでテストを追加するワークフローに入ってしまえば想像より断然楽でした。 CGRectのアサーションをちまちま書いたりするより早いし、 何より画像を目視確認できるので複雑で難解なアサーションを読み解く必要もありません。 実際NAKPlaybackIndicatorViewでは、 CALayeranchorPointpositionプロパティの兼ね合いで描画位置がずれていたのに気付くことができました。

FBSnapshotTestCaseはGitHubでスターが現在350ほども付いているにも関わらず、 今のところ英語圏も含めて紹介している記事が見当たりませんが、 ビューのテストを考えているのであれば一度試してみてはいかがでしょうか。 特に汎用化したOSSなビューライブラリなどでは、使う価値は十分あると思います。