FBSnapshotTestCaseでUIViewの描画をテストする
Feb 14th, 2014
先日、iOS 7のMusic.app内の再生状態インジケータのクローンなビュー、NAKPlaybackIndicatorView
を作りました。
このプロジェクトでは、ビューが期待通りに描画できているかのテストにFBSnapshotTestCase
というものを使っています。
FBSnapshotTestCaseとは
FBSnapshotTestCase
は、
Facebookによる、ビューの“snapshot test”ライブラリです。
“snapshot test”とは、テスト対象のUIView
またはCALayer
に表示されたコンテンツのスナップショットを撮り、
事前に用意しておいた“reference image”(PNG画像)と同一かどうかをピクセルレベルでチェックすることで、
将来の予期しない表示崩れを検出するものです。
FBSnapshotTestCase
はXCTestCase
のサブクラスであり、snapshot test用のヘルパーメソッドやマクロを追加した程度のものなので、
特にsnapshot testでない通常のテストを実行することも可能です。
蛇足ですが、 「ビューの表面に関わる」テストというと KIF や Frank などによるエンドツーエンドが一瞬連想されますが、 これらは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 Macros にFB_REFERENCE_IMAGE_DIR="\"$(SOURCE_ROOT)/$(PROJECT_NAME)Tests/ReferenceImages\""
を追加します。テストターゲットのファイル群を入れているディレクトリ名をXcodeデフォルトの$(PROJECT_NAME)Tests
から変更している場合は、パス中のその部分を合わせます。また、ソース内への展開時にCの文字列リテラルになる必要があることに注意して下さい。
ちなみにこの例ではXcodeのビルド設定SOURCE_ROOT
やPROJECT_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.recordMode
をYES
にしてテストを実行すると、
FBSnapshotVerifyView
マクロは常に成功し、
現在のビューのスナップショットをreference imageとして保存するようになります。
逆に言えばテストをしなくなるので、self.recordMode = YES;
の状態で
Gitなどのバージョン管理システムにコミットしてはいけません。
さて、実際にself.recordMode = YES;
に変更してテストを実行してみるとパスするはずです。
その後git status
をしてみると、
MyProjectTests/ReferenceImages/SnapshotTests/testLabel@2x.png
のようなファイルが生成されており、
Preview.app
なりで開いてみると以下のような内容になっているのが確認できます。
適切に表示されているのが確認できたらself.recordMode = NO;
に戻し、もう一度テストを実行してパスすることを確認します。
その後、テストファイルと生成された画像ファイルをコミットします。
これによって今後、ビューがこの画像と同一の内容を描画するかどうかがチェックされることになります。 一度目視確認してしまえば、後はずっと自動化してくれる訳ですね。
ここまでが基本的なワークフローになります。
- テストを書く
self.recordMode = YES;
でテストを実行する- 生成された画像を目視で確認する
- 画像の内容が適切であれば
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
では、
CALayer
のanchorPoint
とposition
プロパティの兼ね合いで描画位置がずれていたのに気付くことができました。
FBSnapshotTestCase
はGitHubでスターが現在350ほども付いているにも関わらず、
今のところ英語圏も含めて紹介している記事が見当たりませんが、
ビューのテストを考えているのであれば一度試してみてはいかがでしょうか。
特に汎用化したOSSなビューライブラリなどでは、使う価値は十分あると思います。