blog.yujinakayama.me

Transpec 開発記 – 前編

Aug 28th, 2014

Transpecという、RSpecの古い記法で書かれたspecを、最新の記法に自動で書き換えるツールを作った。

最初のバージョン0.0.1をリリースしたのが2013年8月9日なので、すでに一年前になる。 先日のRSpec 3の正式リリースからもしばらく経って一段落したところだし、 この辺で一旦振り返って、 開発中のその時々で何を考えていたのか、忘れてしまう前に長々と残しておくことにする。

きっかけ

そもそもTranspecを作り始めたきっかけは、 should記法を使っていた自分のプロジェクトのspecをexpect記法に書き換えようとしたところから。 これは2013年の7月下旬の話で、まだRSpec 2.99/3.0のベータ版も出ていない頃。

正直なところexpect記法が導入された当初は違和感があったし、 更にはThe Plan for RSpec 3も発表され、 「このままRSpecを使い続けるべきなのか?」と言う気持ちもあった。 しかし実際他のテストフレームワークを触ってみたり、変更に至った経緯を詳しく追っていくうちに、 なんだかんだでやっぱりRSpecかなという結論に。 例え乗り換えるにしても、既存のRSpecを使ったプロジェクトを今すぐ放り投げられる訳でもない。

また、別ブロジェクトでexpect記法を使っているうちにそっちに慣れてきたこともあって、 自分のプロジェクトも全部書き換えることにした。

流石にすべて手作業はしんどいので、 まずは安直に正規表現のワンライナーで find spec -name '*.rb' | xargs perl -i -pe 's/(\w+)\.should/.../g'1 とかやってちまちま変換しようと試みる。 が、実際やってみると誤変換や変換残りが結構あるし、 演算子マッチャがからんできたりもするので、 ワンライナーでスムーズにやれるレベルではないと認識。

そもそも正規表現なんて緩い仕組みで複雑なソースコードの書き換えをしようというのが間違っている。 単なる文字列処理ではなく、 ちゃんとRubyコードの意味を解釈して変換したいコードを狙い撃つ方がスマートだし、 正規表現と延々と格闘して消耗するより早い。 幸いRuboCopプロジェクトでの経験から、 ソースコードのパースによって得られるAST(Abstract Syntax Tree)の扱いと、 ソース書き換えのノウハウはあったので、 静的解析ベースの変換ツールを作ってみることにした。 この時点では自分用のツールで、汎用化するつもりはあまりなかった。

プロトタイプ

思い立ったが吉日、まずはプロトタイプを作った。

とりあえずはobj.shouldからexpect(obj).toへの置き換えと、それに伴う演算子マッチャの変換のみで、 コードはせいぜい200行程度。 ちなみにこの時のプロジェクト名はExpectizeで、 その名残が未だに一つのメソッドとして残っている。

実際にプロトタイプを実行してみると、自分が想像していた以上にうまく変換できていて予想外に感動した。 何より一気に自動変換された後のdiffを眺めていると、なんかこう、気持ち良いというか、 まるで人間が書き換えたかのような魔法っぽさがすごい面白かった。 「お、おお〜、おほ、おほほほほ」とか言いながらニヤニヤしていたと思う。 これは面白いぞ。単なる退屈な作業の省力化という、マイナスをゼロへ近づけるだけのものでなく、 本来ネガティブな体験をポジティブなものに変えられる可能性があるというのはデカい。

モチベーション

というプロトタイプの感触から、汎用化してリリースすることを決めた。 Transpecの開発に対するモチベーションは他にも色々あったけど、 常にコアとなっていたのはこの「いかにポジティブな体験にするか」という部分だったように思う。

どうせやるならshould/expect記法の変換だけでなく、RSpec 3における各種変更もサポートしちゃおう、 ということでRSpec 3へのアップグレード補助ツールという位置付けを狙うことにした。

また、決定打となったのはプロトタイプの感触ではあったものの、 他にもいくつかのモチベーションがあった。

コミュニティへの還元

自分が困っている問題は、他にも困っている人がいるはず。 特にRSpec 3が正式リリースされたら、 アップグレード作業の面倒くささにコミュニティから不満が噴出するだろうと思った。

最初のきっかけの時点から、RSpecのコードを読んだりリポジトリをウォッチしたりして、 RSpecそのものやRSpec 3での変更に関する知見がかなり得られてきていた。 せっかくだから「RSpec 3へのアップグレードコンプリートガイド」みたいな記事書こうかな、 と考えたりもした。

しかし、そのノウハウの大半はアップグレード時の一過性のものだし、 その知識も地道なアップグレード作業も、即座に価値を産み出すものではない。 しかも、様々なケースを網羅してどう対処すべきかを緻密に自然言語で書き、 読者がそれを読み解いて忠実に実行する、 それって人間の仕事じゃないでしょ。 ということで、それらのノウハウは可能な限り実行可能なコードに落とし込み、 誰もが簡単に実行できるようにすることにした。

Rubyにおける実用レベルのコード変換の実現

単なる proof of concept 的な「なんか面白いもの作ってみた」程度のものではなく、 現実世界に存在する無数のプロジェクトでの利用に耐えうるクオリティのものを実現できるか、 試してみたかった。

言語やフレームワークの変更に対して、ソースコードの自動書き換えによって対応した前例としては、 自分が経験した中ではXcodeによるObjective-CのMRCからARCへの変換や、 Modern Objective-Cへの変換がある。 この変換の精度はそこまで完璧ではなかったけれど、 まあ必要な作業の8割くらいはやってくれたかな?くらいな印象だった、気がする。

他には、使ったことはないけれど、Python 2のコードをPython 3対応に変換する2to3というツールもあるらしい。

Rubyの世界ではそういう大きな前例は聞いたことがなかったし、 動的型付け言語であるという時点で(静的解析で得られる情報が少ないため) 不利なことは否めなかったけど、 実現できるか?できたとしてコミュニティがどう受け止めるか? みたいな好奇心があった。

とは言っても、Rubyでは全然無理ですなんてことはまず有り得なくて、 少なくとも7〜8割くらいのケースはカバーできるだろうとは予想していた。 ただそれ以上の精度に関しては、言語の変更なのかフレームワークの変更なのか、 また、言語が静的型付けなのか動的型付けなのか、という辺りで大分変わると思うので未知数だった。

技術的課題

さて、モチベーションが湧いて夢が広がりんぐな構想を巡らせていると、 色々な課題も浮かび上がってくる。

人間の仕事ではないので機械にやらせようとは言ったものの、 ソースコードを正確に書き換えるという行為は、 機械がやるにはちょっと面倒くさくて、人間がやるには単調過ぎる、 というような微妙な領域にあると思う。

ソースの変換処理というのは、大きく以下の三つののフェーズに分けることができて、 それぞれに問題が見えてきた。

  • 変換対象の検出
  • どんな形に変換するかの判断
  • 書き換え処理

コードスタイルの尊重

これは書き換え処理における問題。

今回Transpecが書き換えるソースコードはspecファイルであり、 変換後も人間がメンテナンスしていくものである。 そんなソースに対して、 変換前のコードスタイルを完全に無視して違和感バリバリのコードを生成するような、 おざなりな仕事をするツールは自分がユーザーだったら使いたくない。

という訳で、なるべく既存のコードスタイルを活かした変換をしたい。 もちろん、不正なコードを出力しないという大前提と共に。 さてどうすんのという話だけど、これについては銀の弾丸はない。

Transpecが行うのは、RSpecの等価なAPI間の変換である。 その等価なAPI — 例えばobj.shouldexpect(obj).to — というのは、 RSpecという単なる1フレームワークがRubyレイヤーの上に作り上げたものに過ぎず、 Rubyのレベルで見ると変換前後でのプログラムの意味は等価ではなくなる。 プログラムの意味が変わるということは、つまりASTの構造も変わる。

そもそもASTって何なのという話だけど、Wikipediaによると、

抽象構文木(abstract syntax tree、AST)とは、通常の構文木(具象構文木、あるいはparse treeとも言う)から、言語の意味に関係ない情報を取り除き、意味に関係ある情報のみを取り出した(抽象した)木構造のデータ構造である。

らしい。 これは要するに、ASTはプログラムの意味を解析するのに便利なデータ構造であり、 その上でノイズにしかならない、ソースコード上での見栄えに関する情報を取り除いたもの、ということ。

例えば「if文の中でputsメソッドで文字列"foo"を出力している処理」を検出したいという要求があった場合、 こういうコードも、

if some_condition
  puts 'foo'
end

こんなコードも、

puts("foo") if some_condition

ソースコード上での形は違うが、プログラムとしての意味は全く同じなので、これらを等価に扱える必要がある。

もしソースコードを文字列としてそのまま解析するのであれば、 そんな処理を実装するのは気が遠くなる話だけど、 そんな気が遠くなることを代わりにやってくれるパーサーという有り難い存在がある。 パーサーは入力としてソースコードを受け取り、出力としてASTなどのデータを吐き出す。 上記の2つの例をパースして得られるASTは、なんと両方とも以下の構造になる。 これはParserによって生成されたASTをS式で表現したもので、 実際には木構造になっている。

(if
  (send nil :some_condition)
  (send nil :puts
    (str "foo")) nil)

つまり、ASTだけを見ればソースコード上の細かな違いを気にせず、意味の解析に集中できる。 超便利。

さて、話をソースの書き換えに戻すと、プログラムの意味を変えるソース変換の一つの方法として、 ソースを文字列として操作するのではなく、 元のソースから得られたASTの構造を組み替え(AST変換)、 その組み替えられたASTから意味的に等価になるソースを自動生成するという方法がある。

AST Transformation

この方法を使えば、ASTだけを見れば良いので変換のロジックはシンプルになるし、 少なくともRubyレベルで不正なソースコードを吐き出すことはなくなる。

しかし前述の通り、ASTはソースコード上の見栄えに関する情報が失われているため、 ASTから生成されたソースには、元のソースのスタイルが全く反映されない2

ということでここまで長々と書いたけど、Transpecではこの方法は使っていない。 代わりに、ParserのASTは元のソースへのロケーションマップ情報を持っているので、 それを参照しながら地道に文字列操作をすることにした。 つまりロケーションマップという補助的な情報はあるけれど、 結局は原始的な文字列操作なので、 変換対象の周囲のコードに配慮をしないと不正なコードを出力してしまう可能性はある。

世の中には、Rubyの構文の自由度の高さを活かした変態的なコードを書く人もいるけど、 そんな未知のコードであろうが、Rubyとして正しく、RSpecのAPIを正しく使っている限り、 なるべく正常に変換できるようにしたい。 これについては結局、テストケースを充実させながら地道に実装していくしかないという結論に。

ASTだけでは情報が足りない

これは、変換対象の検出と、どんな形に変換するかの判断に関わる問題。

インターフェースが似ている同名のメソッドを見分けられない

例えば、今回Transpecが変換対象とする、mockstubというRSpec 3で廃止されたメソッドがある。 これはdoubleの単純なaliasであり、 書き換え処理としてはmockというメソッド名をdoubleに置換するだけで良い。

が、それはそのmockが本当にRSpecによって定義されたmockであればの話。

例えばこんなmock呼び出しがあったとき、

  it 'is an object' do
    book = mock('book')
    book.should be_an(Object)
  end

これはRSpecのmockだろうか?doubleに変換して良いだろうか? それはASTを見てもわからない。

上記のソースコードから得られるASTの中で、 コード片mock('book')に対応するノードは以下の形になる。

(send nil :mock
  (str "book"))

Rubyは動的型付け言語なので、静的に得られたデータであるASTに型情報はない3。 このASTからわかるのは、 それがメソッド呼び出しであり、レシーバーは省略されていて、メソッド名はmockで、 文字列リテラル"book"を引数として渡している、ということだけ。

この呼び出し方であれば、 Mochaのmockであってもおかしくないし、 RRのmockの可能性もある4。 もしくはユーザーが自分で定義したヘルパーメソッドかもしれない。 当然それらを間違ってdoubleに変換してしまうと、specが壊れる。

結局のところ、ASTのみを頼りに変換対象のメソッドを探す場合、使える情報としては

  • メソッド名のユニークさ
  • レシーバーを取るかどうか
  • 引数の数

程度の、割とゆるい情報しかないという問題がある。

流石にshouldという名前のメソッドが自前で定義されている、というケースはあまりなさそうだけど、 上記のmockとか、stubなんかは結構他のフレームワークとかぶっていたりする。 またexampleというメソッドは、RSpec内だけでも2つ存在している上に、 普遍的な名前過ぎてユーザーのヘルパーメソッドやletによって不意にオーバーライドされてしまっていることがある。

どんな形に変換するべきか判断できない

もう一つの例として、RSpec 3で廃止されたhave(n).itemsマッチャというものがある。 これはテスト対象オブジェクトの性質によってその挙動を変える、 やり過ぎ感あふれるマッチャだったりする。

以下、その挙動のバリエーション。

class NumberSet
  def initialize(*numbers)
    @numbers = numbers
  end

  def count
    @numbers.size
  end

  def odd_numbers
    @numbers.select(&:odd?)
  end

  def even_numbers
    @numbers.select(&:even?)
  end

  def numbers_larger_than(threshold)
    @numbers.select { |number| number > threshold }
  end

  private

  def negative_numbers
    @numbers.select { |number| number < 0 }
  end
end

describe 'have(n).items' do
  let(:set) { NumberSet.new(-7, -1, 1, 2, 5) }

  it 'is complicated' do
    # テスト対象が、末尾のitems部と同名のメソッドに応答せず、
    # size, length, countいずれかのメソッドに応答する場合
    expect(set).to have(5).items
    expect(set).to have(5).numbers
    expect(set).to have(5).qawsedrftgyhujikolp
    expect(set.count).to eq(5) # 等価

    # テスト対象がodd_numbersメソッドに応答する場合
    expect(set).to have(4).odd_numbers
    expect(set.odd_numbers.size).to eq(4) # 等価

    # ActiveSupport::Inflectorがロードされていない場合
    expect {
      expect(set).to have(1).even_number # Fail
    }.to raise_error(RSpec::Expectations::ExpectationNotMetError)
    expect(set).to have(1).even_numbers  # Pass

    # ActiveSupport::Inflectorがロードされている場合
    require 'active_support/inflector'
    expect(set).to have(1).even_number   # Pass
    expect(set).to have(1).even_numbers  # Pass
    expect(set.even_numbers.size).to eq(1) # 等価

    # 引数が渡された場合
    expect(set).to have(2).numbers_larger_than(1)
    expect(set.numbers_larger_than(1).size).to eq(2) # 等価

    # テスト対象がsize, length, countいずれにも応答せず、
    # プライベートなnegative_numbersメソッドに応答する場合
    class << set
      undef_method(:count)
    end
    expect(set).to have(2).negative_numbers
    expect(set.send(:negative_numbers).size).to eq(2) # 等価
  end
end

これは実際廃止されてもしょうがないよね、と思ってしまうくらいにやり過ぎな挙動だけど、 皮肉なことにTranspecはそういうものを一掃するためのツールなので、 これに対処する必要がある。

have(n).itemsマッチャの変換後の形を決めるには、ASTからは得られない以下の情報が必要になる。

  • テスト対象がsize, length, countのいずれかに応答するか
  • テスト対象がhave(n).itemsitems部のメソッドに応答するか
    • 応答するのであれば、それはプライベートメソッドか
  • ActiveSupport::Inflectorがロードされているか

で、どうする

この「ASTだけでは情報が足りない」問題は、 どう考えても単純な静的解析では手に負えない問題で、 とりあえず初期リリースの段階では考えないことにした。 衝動ドリブンで開発している時にあまり遠くの問題を見過ぎると、 失速して完成せずに終わってしまう恐れがある。

一応このとき、 「最悪、他にスマートな方法がなければ強引にアレをこうすればいけるかも」という案は考えていて、 最終的にはその方法を使うことになる。

リリースに向けて

といったようないくつかの問題を考えたところで、 平均的な使い方として1プロジェクトに対して1回使ったら終わりのツールに、 そこまでの労力をかける意義はあるのか?という疑問もあったのだけど、 RSpecのシェアを考えれば自分一人がそのくらいの労力をかけるのはアリだと思った。 もっとマイナーなフレームワークだったらやらなかったかもしれない。

つづく


  1. rubyのコマンドラインオプションをいつまで経っても覚えられない 

  2. 逆に、変換後のソースの見栄えを気にする必要のない、例えば一時的なデバッグコードの挿入などの場合は、AST変換のメリットをフルに享受できる。 

  3. 厳密には、この場合に欲しい情報は「誰によってそのmockメソッドが定義されたか」であり、レシーバーや返り値のクラス名ではないのだけど。 

  4. どちらもrspec-mocksの代替として、RSpecに組み込んで使うことができる。 

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なビューライブラリなどでは、使う価値は十分あると思います。

CocoaPodsのプラグインを作る

Dec 14th, 2013

この記事はiOS Second Stage Advent Calendar 2013の14日目の記事です。

概要

Cocoaのライブラリ管理ツールであるCocoaPodsですが、2013年11月14日にリリースされたバージョン0.28で、プラグイン機能が導入されました。

本記事ではこのプラグインの作り方を紹介します。

CocoaPodsプラグインとは

CocoaPodsのプラグイン機能を利用すると、podコマンドに任意のサブコマンドを追加することができます。標準サブコマンドのpod installpod updateと同列な、新たなサブコマンドの作成が可能です。

CocoaPodsはRubyで作られたツールなので、プラグインもRubyで書く必要があります。また、プラグインの配布にはRubyGemsを利用します。

このプラグインの仕組みそのものは、CocoaPodsプロジェクトが依存gemとして利用しているCLAideによって可能になりました

プラグインを作る

ここではcheck-latestというサブコマンドを定義する、cocoapods-check_latestというgemを作ることにします。これは指定したライブラリのpodの最新バージョンが、そのライブラリのGitHubリポジトリの最新のバージョンタグと同一かどうかをチェックするコマンドです。

試しにCocoaPodsで導入してみたライブラリがうまく動作しないと思ったら、実は最新のpodspecで指定されているライブラリのバージョンが、そのライブラリの本当の最新バージョンではなかった(この表現わかりにくいですね)という場面がたまにあったりします。なので事前にpodspecが最新かどうかチェックしたいのですが、そのライブラリのGitHubのリポジトリをブラウザで開いてタグ一覧を確認、というのも割と面倒なルーチンワークなので、この作業を自動化することにします。

まずは新しくgemのプロジェクトを作成します。Bundlerを利用するのが良いでしょう。なお、gem名はcocoapods-プラグイン名にすることが公式に推奨されています。そうしないとプラグインとして動作しない訳ではありませんが、RubyGemsでの検索のしやすさも考慮した慣例になっています。RubyGemsをcocoapods-検索すれば、現在公開されているプラグインの一覧が見られる訳ですね。

$ bundle gem cocoapods-check_latest
...
$ cd cocoapods-check_latest

コマンドクラスを作成する

Pod::Commandクラスを継承したコマンドクラスを作成します。

# lib/pod/command/check_latest.rb

module Pod
  class Command
    # クラス名からサブコマンド名が自動生成される。
    # CamelCaseの単語間には"-"が挿入されるため、この場合"check-latest"になる。
    class CheckLatest < Command
      # 必須。`pod help`時に一行で表示される概要を記述する。
      self.summary = 'Check if the latest version of a pod is up to date'

      # 任意。`pod help check-latest`時に表示される、より詳細な説明を記述する。
      # 未指定の場合、summaryの内容が表示される。
      self.description = 'Some long description...'

      # コマンドラインから引数を受け取るコマンドの場合は必須。
      # `pod help check-latest`時に、
      #
      # Usage:
      #     $ pod check-latest [NAME]
      #                        ^^^^^^ ここに表示される。
      self.arguments = '[NAME]'

      # 引数を受け取るコマンドの場合は必須。
      # argvから、後の処理で必要な引数をインスタンス変数に取り出しておく。
      # この処理はsuper呼び出し前に行うこと。
      # argvは、CLAide::ARGVのインスタンス。
      # https://github.com/CocoaPods/CLAide/blob/v0.4.0/lib/claide/argv.rb
      def initialize(argv)
        @name = argv.shift_argument
        super
      end

      # 引数を受け取るコマンドの場合は必須。
      # 引数が不正な場合はhelp!メソッドにメッセージを渡して中断する。
      def validate!
        super
        help!('A pod name is required.') unless @name
      end

      # 実際の処理を記述する。
      def run
        # 長いので省略。実際の内容は以下を参照して下さい。
        # https://github.com/yujinakayama/cocoapods-check_latest/blob/v0.0.1/lib/pod/command/check_latest.rb
      end
    end
  end
end

ちなみに、CocoaPodsのサブコマンドを定義するということは、CocoaPods本体のAPIを利用できる訳ですが、現時点ではどのクラスやメソッドがpublic APIであるかという宣言がされていません。将来的にはこの辺りも整備されてくるとは思いますが、現状では利用対象のクラスやメソッドが存在しているかどうかのチェックや、例外処理などをこまめに行うしかない模様です。どちらにしてもCocoaPodsは未だにバージョン1.0未満の初期開発段階なので、Semantic Versioning的にもAPIの互換性はあまり重視されないフェーズではあるのですが。

作成したコマンドが、podコマンド実行時に読み込まれるようにする

コマンドを作成しただけでは、CocoaPods本体がその存在を認識できません。

Rubyの$LOAD_PATHに登録されたパス直下(通常はプロジェクトのlibディレクトリ直下)に、cocoapods_plugin.rbというファイルを作成し、その中で上記のコマンドクラスが記述されたファイルをrequireします。これによってpodコマンド実行時にカスタムコマンドクラスがロードされるようになります。

# lib/cocoapods_plugin.rb

require 'pod/command/check_latest'

動作確認をする

実際にpodコマンド経由で動くか確認してみましょう。

$ echo "gem 'cocoapods', '~> 0.28'" >> Gemfile
$ bundle install
...
$ bundle exec pod help | grep check-latest
    * check-latest   Check if the latest version of a pod is up to date
$ bundle exec pod check-latest viewcontroller


-> AMBubbleTableViewController
   - Homepage: https://github.com/andreamazz/AMBubbleTableView
   - Latest pod version:0.5.1
   - Latest version in original repo:0.5.1


-> APPinViewController
   - Homepage: https://github.com/Alterplay/APPinViewController
   - Latest pod version:1.0.2
   - Latest version in original repo:1.0.2


-> ARGenericTableViewController
   - Homepage: https://github.com/arconsis/ARGenericTableViewController
   - Latest pod version:1.0.0
   - Latest version in original repo:1.0.1
   Outdated!

...

プラグインを配布する

あとはRubyGemsのルールに則ってgemを公開しましょう。

cocoapods-check_latest.gemspecdescription, summary, homepageあたりの項目を記述し、BundlerのRakeタスクで公開します。

$ rake release

おわりに

実際に作成したcocoapods-check_latestプラグインを公開しました。gem install cocoapods-check_latestでインストールができます。

Next page