Hatena::Groupcoders

ラシウラ出張所 このページをアンテナに追加 RSSフィード

2007/04/18

[] RSpecことはじめ  RSpecことはじめ - ラシウラ出張所 を含むブックマーク はてなブックマーク -  RSpecことはじめ - ラシウラ出張所  RSpecことはじめ - ラシウラ出張所 のブックマークコメント

(記述を1.0.0対応しました)

RSpecRubyでBehaviour Driven Development(BDD)を行うための環境

BDDとは、Test Driven Developmentの発展形というか、より新機能開発に特化したもの。

Testは(assert_equalsのように)ふつう条件を満たすという視点で記述を行なうが、BDD仕様を宣言し、それはこうこうあるべき(should)という視点で記述する、という違いになります。

インストール

gemから

gem install rspec

rspecを入れると、binにspecというコマンドが追加されます。

RSpecことはじめ

Ruby組み込みクラスArrayを使ってspecを書いてみます。

ArraySpec.rb

describe "An Array" do
  before do
    @array = []
  end
  it "shoud be empty" do
    @array.should be_empty
  end
  it "shoud append at last when send #<<" do
    @array << "0"
    a = "a"
    @array << a
    @array.last.should equal(a)
  end
end

describeではArrayといった仕様対象のオブジェクトを書き、itでは仕様記述します。(0.8.2のころはdescribe/itcontext/specifyでしたが、deprecatedになりました)。

beforeで、コンテキスト対象のオブジェクトフィールド初期化するブロックをおきます(以前はsetupでしたが、beforeが基本になりました)。itごとに行うbefore(:each)describeごとに行うbefore(:all)があります。

仕様は二つ書きました。

  • 何もしないとき空
  • <<で送ったものは最後にある

最初の仕様の中身

    @array.should be_empty

shouldRSpecのコアクラス拡張で受け付けるようになったメソッドです。be_emptyはこのdoの実行オブジェクトが受け付けるメソッドで、shouldと連携しているようです。@array.empty?trueであることをチェックするものになります(block中のこのメソッドの使い方はとても面白い)。


以前は、

    @array.should_be_empty

と書けたのですが、0.9以降ではこの記法は受け付けなくなりました。

(Spec::Expectationsの)メソッドはshouldshould_notだけになりました。


二つ目の仕様

    @array.last.should equal(a)

    @array.last.should_equal a

でも0.8.2時代はいけたのですが、これらも同様に受け付けなくなりました。

実行したら以下のように失敗なく終わるでしょう

$ spec ArraySpec.rb

..

Finished in 0.002134 seconds

2 specifications, 0 failures

should ~のありか

shouldで受け付けるものの解説ドキュメント

ですが、ここにはshouldshould_notだけになりました。

~の仕様Spec::Matchersにあるものになります:

複数のコンテキストは分割する

先ほどのspecでは、ひとつのdescribeの中にbeforeで空のインスタンス作成、emptyの仕様記述、appendの仕様記述が入っていました。

もし、中の入ったArrayインスタンスの場合どうなるでしょうか。emptyの仕様は別のもの。appendの仕様記述は同じものになります。

このような状況で仕様を書くには、インスタンス化して入力条件を作る部分と、アクションと事後条件チェックする部分を分割し、組み合わせるのが理知的です。RSpecでは以下のようにdescribeの分割、組み合わせが可能です。

require "array_ext"

# 振る舞い、事後条件の記述
describe "empty array", :shared => true do
  it do
    @array.should be_empty
  end
end

describe "array", :shared => true do
  it "shoud append at last when send #<<" do
    a = "a"
    @array << a
    @array.last.should equal(a)
  end
end

# コンテキスト、事前条件の記述と、事後条件の組み合わせ
describe Array, "empty" do
  before(:each) do
    @array = []
  end

  it_should_behave_like "empty array"
  it_should_behave_like "array"
end

describe Array, "has items" do
  before(:each) do
    @array = [2,3,4]
  end

  it_should_behave_like "array"
end

事後条件セット側は、describeで、shared => :trueをし、事前条件用意側で、it_should_behave_like 「事後条件セット名」を呼ぶ必要があります。

また、itに名前を渡さない場合は、処理内容から自動生成されます。前記の場合、"should be empty"になるでしょう。

実行すると:

$ RUBYLIB=$RUBYLIB:lib/ spec ArraySpec.rb
...

Finished in 0.009816 seconds

3 examples, 0 failures

specチェックもひとつ増えました。

BDDしてみる

前述のArrayのついでに右から走査していくfoldrArrayのメソッドとして作ってみましょう。

require "array_ext"

# 振る舞い、事後条件の記述
describe "empty array", :shared => true do
  it do
    @array.should be_empty
  end
end

describe "array", :shared => true do
  it "shoud append at last when send #<<" do
    @array << "0"
    a = "a"
    @array << a
    @array.last.should equal(a)
  end
  it "should be iterate from last when send #foldr" do
    @array.foldr(@to_list_init, &@to_list_block).should == @to_list_result
  end
end

# コンテキスト、事前条件の記述と、事後条件の組み合わせ
describe Array, "empty" do
  before(:each) do
    @array = []
    @to_list_init = "nil"
    @to_list_block = proc do
      |a, b|
      "[" + a.to_s + "," + b + "]"
    end
    @to_list_result = "nil"
  end

  it_should_behave_like "empty array"
  it_should_behave_like "array"
end

describe Array, "has items" do
  before(:each) do
    @array = [2,3,4]
    @to_list_init = "nil"
    @to_list_block = proc do
      |a, b|
      "[" + a.to_s + "," + b + "]"
    end
    @to_list_result = "[2,[3,[4,nil]]]"
  end

  it_should_behave_like "array"
end

specでは[1,2,3,4].foldr("nil"){|l,r| "[" + l.to_s + "," + r + "]"}が期待する文字列"[2,[3,[4,nil]"になるべきだと書くことをします。

まず、@arrayだけでなく、foldr引数二つと結果もコンテキストで変わります。そのため、まず振る舞い側でそれらをすべてフィールドで渡すよう記述します。そして、コンテキスト側でbeforeでその値を用意します。

そしてとりあえず、lib/array_ext.rbを空にしてつくり、このままspec実行します

$ RUBYLIB=$RUBYLIB:lib/ spec ArraySpec.rb
..F.F

1)
NoMethodError in 'Array empty should be iterate from last when send #foldr'
undefined method `foldr' for []:Array
./ArraySpec.rb:17:

2)
NoMethodError in 'Array has items should be iterate from last when send #foldr'
undefined method `foldr' for [2, 3, 4]:Array
./ArraySpec.rb:17:

Finished in 0.011838 seconds

5 examples, 2 failures

foldrがないと言われています。で、array_ext.rbを以下のように書きます。

class Array
  def foldr(v, &block)
    v
  end
end

んで実行します。

$ RUBYLIB=$RUBYLIB:lib/ spec ArraySpec.rb
....F

1)
'Array has items should be iterate from last when send #foldr' FAILED
expected "[2,[3,[4,nil]]]", got "nil" (using ==)
./ArraySpec.rb:17:

Finished in 0.011083 seconds

5 examples, 1 failure

こんどは期待値と違うといわれます。

で、正しい動くコードをいれましょう。

class Array
  def foldr(v, &block)
    return v if empty?
    block.call(self[0], self[1..size].foldr(v, &block))
  end
end

で、実行すると

$ RUBYLIB=$RUBYLIB:lib/ spec ArraySpec.rb
.....

Finished in 0.011391 seconds

5 examples, 0 failures

と、成功しました。

次はfoldrメソッドをリファクタリングして、このままのspec実行して通ることもチェックすることを確認します。

class Array
  def rest
    if empty? then [] else self[1..size] end
  end
  def foldr(v, &block)
    return v if empty?
    block.call(first, rest.foldr(v, &block))
  end
  def foldl(v, &block)
    return v if empty?
    rest.foldl(block.call(v, first), &block)
  end
end

mockを使う

@to_list~というフィールドが3つできましたが、Mockオブジェクトを使えば以下のようにひとつにまとめられます。

  it "should be iterate from last when send #foldr" do
    @array.foldr(@to_list.init) do |a, b|
      @to_list.block(a, b)
    end.should == @to_list.result
  end

mockは以下の@to_listのように作ります。

describe Array, "empty" do
  before(:each) do
    @array = []
    @to_list = mock("to_list")
    @to_list.should_receive(:init).any_number_of_times.and_return("nil")
    @to_list.should_receive(:block).any_number_of_times do |a, b|
      "[" + a.to_s + "," + b + "]"
    end
    @to_list.should_receive(:result).any_number_of_times.and_return("nil")
  end

  it_should_behave_like "empty array"
  it_should_behave_like "array"
end

describe Array, "has items" do
  before(:each) do
    @array = [2,3,4]
    @to_list = mock("to_list")
    @to_list.should_receive(:init).any_number_of_times.and_return("nil")
    @to_list.should_receive(:block).any_number_of_times do |a, b|
      "[" + a.to_s + "," + b + "]"
    end
    @to_list.should_receive(:result).any_number_of_times.and_return("[2,[3,[4,nil]]]")
  end

  it_should_behave_like "array"
end

mockメソッドで名前指定でオブジェクトを作成します。mockはshould_receiveメッセージ名を指定し、呼び出され回数any_number_of_times(任意回)を指定し、戻り値を指定しています。呼び出され任意回にしたのは、emptyなどでもmockの実行回数チェックが起きてしまうからです。

同様のもので呼び出され回数をチェックしないものとしてstubもあるのですが、こちらはキーと値のペアだけになります(引数を受け付けるメッセージは作れない)。

mockの配置

入力は共通化されることから、mockは、振る舞い側で定義し、結果だけインスタンス側で定義するのがいいでしょう。

  it "should be iterate from last when send #foldr" do
    to_list = mock("to_list")
    to_list.should_receive(:block).exactly(@to_list_call).times do |a, b|
      "[" + a.to_s + "," + b + "]"
    end
    @array.foldr("nil") do |a, b|
      to_list.block(a, b)
    end.should == @to_list_result
  end

こうすることで、exactlyによって、チェックで使う実行回数を設定します。

実行回数、結果は、コンテキスト側に書きます。

describe Array, "empty" do
  before(:each) do
    @array = []
    @to_list_call = 0
    @to_list_result = "nil"
  end

  it_should_behave_like "empty array"
  it_should_behave_like "array"
end

describe Array, "has items" do
  before(:each) do
    @array = [2,3,4]
    @to_list_call = 3
    @to_list_result = "[2,[3,[4,nil]]]"
  end

  it_should_behave_like "array"
end

mockチェック要素を生成的に導く

@to_list_call@array.sizeそのものであり、@to_list_result@arrayの要素のループから生成できる。そこで、これらをitの側にうつしてやる。

require "array_ext"

describe "empty array", :shared => true do
  it do
    @array.should be_empty
  end
end

describe "array", :shared => true do
  it "shoud append at last when send #<<" do
    @array << "0"
    a = "a"
    @array << a
    @array.last.should equal(a)
  end
  it "should be iterate from last when send #foldr" do
    item_proc = proc do |a, b|
      "[" + a.to_s + "," + b + "]"
    end
    to_list = mock("to_list")
    to_list.should_receive(:block).exactly(@array.size).times(&item_proc)
    init = "nil"
    result = init
    @array.reverse.each do |item|
      result = item_proc.call(item, result)
    end

    #action
    @array.foldr(init) do |a, b|
      to_list.block(a, b)
    end.should == result
  end
end

describe Array, "empty" do
  before(:each) do
    @array = []
  end

  it_should_behave_like "empty array"
  it_should_behave_like "array"
end

describe Array, "has items" do
  before(:each) do
    @array = [2,3,4]
  end

  it_should_behave_like "array"
end

これはテストとその対象が近くにおける反面、チェックする結果の要素が直接的でなくなる。ただ、BDDは実装前に必ずspecチェックするので、結果的に失敗メッセージの中に結果値が埋め込まれるので、それでチェックできなくもない。

ただし、処理と結果導出があまりにもそっくりな場合、コード変更が同時影響すると、チェックは効かなくなるという面には注意したい。例ではfoldr自体もreverseを使って実装されていると、reverseが狂っている場合は、このspecチェックは多分通ってしまう。つまり、使用するメソッドは何らかの形で直接的なspecが入ってないといけないということになる。

rakeでspec実行するためのRakefile

rspecrake用taskを生成するライブラリも含んでいます。

たとえば、lib/以下においたライブラリ開発用のspecがspec/以下にある場合、そのspecを実行するtask runspecは以下のようにRakefileに書けばよいです。

begin
  gem "rspec"
  require 'spec/rake/spectask'
  desc "Run all specs"
  Spec::Rake::SpecTask.new('runspecs') do |t|
    t.libs << "lib"
    t.spec_files = FileList['spec/**/*.rb']
  end
ensure
end

rspecが入ってる環境なら

rake runspecs

で実行されます。

感想

BDD自体は、普通テストでも書き方を注意すればできることですが、このようにDSL風にすることでいろいろ機能を埋め込めるし、みやすいかもしれないです。

個人的に面白かったのは should be_emptyのようなDSL風メソッドの使い方でした。ブロックを使うことで、仕様に文字列を使えるのもいいです(そのかわりreturnが使えなくなるが)。

rspec自体についていえば、specコマンドruby同様の-Iオプションが使えればいいのにと思いました。bin/specを直接起動ではなく、rubyコマンドに食わせればいいのですが、それもださいし。

名前汚染の注意が必要

rspec-0.8.2では、specの中でグローバル変数classを定義すると、spec実行を同時に行うすべてのspecでそれらの名前が共有されるようです。

将来改善させるとは思いますが、現状spec中でそれらを行う場合は注意が必要です。