より「普通に」書くためのTest Doubleライブラリ「crispy」

by Yuji Yamamoto on December 5, 2014

Tagged as: Ruby, Test Double.

この記事は、ソフトウェアテストあどべんとかれんだー2014と、 Ruby Advent Calendar 2014の12月5日の記事を兼ねています。
前日の記事はそれぞれ以下のものでした。

RubyのAdvent CalendarとテストのAdvent Calendarということで、 今日は私が半年ぐらい前からちまちま作っている、crispyというTest Doubleのためのgemを紹介させてください。

crispyって?

Test Double」がそもそも何なのかはちょっと今回は割愛させていただきます。 Wikipediaなりよそで検索するなりしてください。
一言で言うとstubとかmockとかtest spyとかの総称です。 RSpecをお使いの方であれば知らず知らずのうちに

allow(hoge).to receive(:foo).and_return(:bar)

とか

expect(hoge).to receive(:foo)

とか、あるいは古いsyntaxなら

hoge.stub(:foo).and_return(:bar)

hoge.should_receive(:foo)

という機能を使ったことがある、なんて方もいらっしゃるかもしれません。 これはRSpecデフォルトのTest Doubleライブラリ、「rspec-mocks」です。

crispyはこのrspec-mocksを始め、rrflexmockなど、 Ruby界に並み居る競合Test Doubleライブラリの置き換えになるものを目指して作られています。
今のバージョンは0.2.0ですが、主要な機能は十分に使えるものとなっていますので、 よろしければREADMEを読んでお試しください。

何が違うの?

crispyはテストをより「普通の」順番で、あるいは「普通の」Rubyで書くことを可能にするTest Doubleライブラリです。
これまでのTest Doubleライブラリとは大きく異なるアプローチをとることで、これを実現しています。

その1 なんでもspy

Test Doubleライブラリの多くには、「test spy」という機能があります。
例えばcrispyだとこんな感じ。

# 対象のメソッドsubject_methodが、渡したfileをcloseするか?
file = File.open "hoge"
spy_into file
subject_method file

# subject_methodが実際にfileのcloseメソッドを使用していれば、trueが返る。
spy(file).received? :close

上記のようにspyされたオブジェクトは、自分自身が呼び出したメソッド(と、その引数)をすべて記憶することで、 テストしたいメソッドによって正しく使用されているかチェックすることができるようになります。

crispyは、名前が「spy」で終わっていることが示しているように、この「test spy」をfeatureしたTest Doubleライブラリです。 他のあらゆるTest Doubleとも異なるtest spy機能を持っています。

何が異なるのかと言うと、それは、Rubyのあらゆるオブジェクトに対してspyできる、という点です。
例えばrspec-mocksで先ほどの例を再現すると、 そのものずばりspyメソッドによって作られる、spy専用のオブジェクトを使わなければなりません1

# 対象のメソッドsubject_methodが、渡したfileをcloseするか?
file = spy 'fileっぽい何か'
subject_method file

# subject_methodが実際にfileのcloseメソッドを使用していれば、テストが通る。
expect(file).to have_received :close

そのため、rspec-mocksを使った上記の例では、実際にfilecloseメソッドを使ったり、他のメソッドを呼んだりすることができません。 それに対してcrispyは、普通のRubyのオブジェクトであるfileにspy機能を「付け足す」ことができるので、fileをそのまま扱うことが出来ます2

で、何がいいの?

さて、あらゆるオブジェクトに対してspyできると、一体何が嬉しいのでしょう?
それは、テストをより「普通な」順序で書くことができる、という点です。

例えば、先ほどの例で「filecloseメソッドを呼んだり、他のメソッドを呼んだり」しつつ、 「対象のメソッドsubject_methodが、渡したfilecloseするか?」調べるには、 mockを使って次のように書かないといけません3

file = File.open "hoge"

expect(file).to receive(:close)
subject_method file
# この辺の時点でfileのcloseメソッドが呼ばれていれば、テストが通る。

先ほどの例とは打って変わって、 「対象のメソッドsubject_methodが、渡したfilecloseするか?」 と問う処理(RSpec用語で言うところの「expectation」)が、対象のメソッドsubject_methodにあります。
RSpecに限らず、自動テストをある程度書いたことがある方なら、これがかなり異例であることはよくご存知でしょう。
crispyはまさにこれをなくすために、あらゆるオブジェクトに対してspyできるようにしたのです。

というのも、例えばここまで何度も出した例が、 とある大きなspecの一部である場合を想像してみてください。

describe 'subject_method' do
  before do
    @file = File.open "hoge"

    @result = subject_method file
  end

  it 'なにか望ましい値を返す' do
    expect(@result).to eq 'なにか望ましい値'
  end

  it '渡したファイルになにか書き込む' do
    @file.rewind # 書き込んだ値を調べるため、fileの読み込み位置を最初の位置に戻す
    expect(@file.read).to eq 'なにか書き込まれた値'
  end

  it '何か他にも状態を変える' do
    expect(nanika).to be_nantoka
  end

  # ... その他もろもろ。

  # ここまで before ブロックで呼んだ subject_method が起こす影響を
  # subject_method の **後に** 書くことでテストしていたのに、
  it '渡したファイルのcloseメソッドを呼ぶ' do
    # ここだけsubject_methodが起こす影響を **先に** 書かないといけない!
    expect(@file).to receive(:close)
    subject_method @file
  end

end

なんだかもにょりませんか?
通例、私達はテストを通して対象のメソッドの振る舞いを記述する際、
「hogehogeのメソッドを呼ぶとfugafugaな値を返す」と言った具合に、対象のメソッドの戻り値についての期待を書いたり、
「hogehogeのメソッドを呼ぶとbarbarをblahblahな状態に変える」と言った具合に、対象のメソッドが何かに及ぼす影響(副作用)についての期待を書いたりするはずです。

これらは原理的に対象のメソッドを呼んだにしか調べることができないため、 対象のメソッドを、それに対する期待より前に書くことが、私達にとってより自然で「普通な」はずです。
そしてこれを常に実現するには、設定した任意のオブジェクトに対してspyする仕組み — つまりcrispyが、なくてはならないのです。

その2 普通のRubyでmessage expectation

crispyの目覚ましい特徴は、もう一つあります。
それは、spyしたオブジェクトが呼び出したメソッドと引数の記録を、received_messagesというメソッドで自由にアクセスできるようにしたことです。
これはすなわち、Rubyの「普通の」Arrayを扱うような感覚で、より複雑な条件のmessage expectation、言い換えればオブジェクトが呼び出したメソッドの記録のテスト、ができるということです。

例を示すために、もう一度fileをcloseする件に帰りましょう。
今度はcloseしたfileを二度と使ってほしくない、という要件から、 fileが呼んだ最後のメソッドがcloseであるかどうか調べたいとします。
そのような場合、crispyでは次のように書くことができます。

# 対象のメソッドsubject_methodが、渡したfileを**最後に**closeするか?
file = File.open "hoge"
spy_into file

# received_messagesというArrayに、fileが呼び出したすべてのメソッドが記録されているので、
# その最後の記録を見れば、fileが呼んだ最後のメソッドがcloseであるとわかる。
spy(file).received_messages.last.method_name == :close
# これも同じ意味。
spy(file).received_messages[-1].method_name == :close

どうでしょう?直感的だと思いませんか?

更に複雑なこともできます。
今度はcloseよりも前にちゃんとreadメソッドを使って、fileを読んでいるどうかも調べてみましょう。

spy(file)
  .received_messages          # file が呼んだすべてのメソッドから、
  .map(&:method_name)         # メソッド名だけを取り出し、
  .take_while do|method_name| # close より前のものだけを抜き出し、
    method_name != :close
  end
  .include?(:read)            # read があるか調べる。

実際には大抵の場合、ここまで複雑なことをしなくてもテストしたいことをテストし切るのは簡単でしょう。
しかしながらこの節の例は、spyreceived_messagesmethod_nameを除けば、 すべてRubyのEnumerableやArrayの標準的なAPIを使って書かれています。
crispyはこのように、Rubyを普通に勉強した知識をそのまま活かして、複雑なmessage expectationを書けるようにしてくれます。
そしてそのように勉強したRubyの知識は、テストだけでなく、実装する上でも必ず活かせるはずです。
ピンとこない方はちょうど昨日のRuby Advent Calendar 2014の記事、 tbpgrさんの「条件分岐とループベースのロジックからコレクションパイプラインを利用したロジックへ #ruby」 やRuby公式サイトのドキュメントを読んだりいろいろググったりして、 今から使えるよう勉強してみるのを強くオススメします。

これに対して他のTest Doubleライブラリはいかがでしょうか?
私は少なくとも前述の例をrspec-mocksで実現する方法を知りません4
他のライブラリはこうした複雑なケースをクリアするためにいろいろ直感的なAPIを考えて実装していますが、 いずれもそれ専用の学習が必要ですし、時々曖昧さを産んでしまうことさえあります(例は申し訳なくも割愛いたします)。
私は日々rspec-mocksを使い、そうしたことに悩みながら、crispyを考え、開発することにしました。
こうした思いに共感される方も、あるいは「ホントかよ〜」なんて疑いの目を持たれる方も、ぜひ一度お試しになって、もろもろフィードバックをください!

https://github.com/igrep/crispy

仕組み

時間がないので割愛しますが、簡単に言うと、指定したオブジェクトの、特異クラスのすべてのメソッドをラップするメソッドをもったmoduleを作り、それをprependしています。
prependが使えないといけないので、あいにくRuby 2.0以降でないと動きません。あしからず。

これから

残念ながら「もうちょっとあれが出来てから〜」なんて渋っているうちに、crispyを現場で使うことがないまま半年近くが過ぎてしまいました。
現在は現場、つまり私の仕事でcrispyをより使いやすくできるよう、 RSpecと組み合わせることができる拡張を開発しています。
もちろんこれがなくてもRSpecと一緒に使うことは出来ますが、より自然言語らしい書き方を好むRSpec-erの気持ちを考えれば、あったほうがいいに決まっています。
職場でこれができる前に採用するかは、現在検討中です。というのも、RSpec(というよりrspec-mocks)と一緒に使えるようにする、というのがなかなか難しそうなので…。

あ、もちろんcrispy自体の拡張はこれからももっともっと行ないます。
英語ですが、Issuesに追加したい機能を簡単にリストアップしています。
日本語でもコメントOKとしますので、「簡潔すぎてわからん!」とか「これも実装しろよ!」なんてコメントも歓迎です。
ただし、日本語でコメントされた場合、私が同意を求めたうえ、英語に訳したものを同じページに載せるかもしれませんが、予めご了承ください。

それではこれからのcrispyの進化に乞うご期待!

次回!


  1. 詳しい人にバカにされないために補足しますと、実際にはrspec-mocksのspydoubleとほぼ同等のものを返します。 rspec-mocksではdoubleがspyの機能を備えているためです。

  2. もちろん、場合によってはrspec-mocksのspyのような振る舞いのほうがいいこともあるでしょう。 その場合はrspec-mocksと同じように、crispyのdoubleメソッドを使いましょう。 rspec-mocksのdoubleと同じように、spyの機能も使えます。
    … いや、私が実装し忘れてなければ使えるはずです。 (;^_^)

  3. 私の経験上、実際そのようなケースの方が多いように思います。 呼んでいるかどうか調べたいメソッド以外のメソッドも使えた方が、 変更に強いテストが出来るでしょうし、そのメソッドはstubするのではなくそのまま使うほうが、 戻り値の型などをテストする側で意識する必要がなく、楽なので。

  4. よーく考えたら少なくとも2つ目の例はreceive().orderedマッチャーを使えば普通に出来そうですね… 失礼しました。


I'm a Haskeller キャッシングの返済の仕方