RSpec の入門とその一歩先へ、第2イテレーション


クリエイティブ・コモンズ・ライセンス
和田 卓人(@t_wada) 作『RSpec の入門とその一歩先へ、第2イテレーション』はクリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスで提供されています。


#coffee.rb の写経会に招かれた(というよりは押しかけた?)ので、先日の RSpec チュートリアルの続きを記します。このエントリは写経会に参加しながらのライブ更新でした。


(更新) 第3イテレーションも書きました。続きに興味ある方はご覧下さい

前回終了時点のコードと実行結果


前回終了時点でのコードを以下に記します。


message_filter.rb

 class MessageFilter
   def initialize(word)
     @word = word
   end
   def detect?(text)
     text.include?(@word)
   end
 end

message_filter_spec.rb

 require 'rubygems'
 require 'spec'
 require 'message_filter'
 
 describe MessageFilter, 'with argument "foo"' do
   subject { MessageFilter.new('foo') }
   it { should be_detect('hello from foo') }
   it { should_not be_detect('hello, world!') }
 end

実行してみましょう

$ spec -fs message_filter_spec.rb 

MessageFilter with argument "foo"
- should be detect "hello from foo"
- should not be detect "hello, world!"

Finished in 0.003051 seconds

2 examples, 0 failures
$ 

第2イテレーション

これまでの NG ワードフィルタ*1は、NG ワードが一つしか使えませんでした。このままでは使い勝手がやや悪いですね。NG ワードを複数登録できるようにしてみます。



と、その前に前回 git を使っていた人は、前回のブランチをマージして新しいブランチを作成しておきましょう。

$ git checkout master
Switched to branch "master"
$ git merge 1st
Updating 2bc2345..3b05118
Fast forward
 message_filter.rb      |    8 ++++++++
 message_filter_spec.rb |    6 +++++-
 2 files changed, 13 insertions(+), 1 deletions(-)
 create mode 100644 message_filter.rb
$ 
$ git checkout -b 2nd
Switched to a new branch "2nd"
$

このイテレーションでも、大体見出し毎にコミットしています。



spec ファイルの構造は比較的自由

では、第2イテレーション最初のテストを書きます。 RSpec では spec ファイルの構造は比較的自由です。なので、 describe ブロックも同じレベルに並べることができます。引数を増やしたテストの記述をまずは単純に下に増やしてみましょう。


message_filter_spec.rb

 require 'rubygems'
 require 'spec'
 require 'message_filter'
 
 describe MessageFilter, 'with argument "foo"' do
   subject { MessageFilter.new('foo') }
   it { should be_detect('hello from foo') }
   it { should_not be_detect('hello, world!') }
 end
+
+describe MessageFilter, 'with argument "foo","bar"' do
+  subject { MessageFilter.new('foo', 'bar') }
+  it { should be_detect('hello from bar') }
+end


実行してみましょう

$ spec message_filter_spec.rb 
..F

1)
ArgumentError in 'MessageFilter with argument "foo","bar" should not be detect "hello, world!"'
wrong number of arguments (2 for 1)
./message_filter_spec.rb:12:in `initialize'
./message_filter_spec.rb:12:in `new'
./message_filter_spec.rb:12:
./message_filter_spec.rb:13:

Finished in 0.004677 seconds

3 examples, 1 failure
$ 

引数ひとつを期待したところに引数がふたつ来たよというエラーになりました。想定どおりですね。


さて、どういう実装を書きましょうか。仮実装路線で行くか、それとも明白な実装路線で行くか。今回は背伸びして実装を一気にしてみましょう。



可変引数を使って実装してみる

明白な、というか、ちょっとベタな実装をしてみます。(この実装はイテレーション後半でリファクタリングします)


message_filter.rb

 class MessageFilter
-  def initialize(word)
-    @word = word
+  def initialize(*words)
+    @words = words
   end
   def detect?(text)
-    text.include?(@word)
+    @words.each do |w|
+      return true if text.include?(w)
+    end
+    false
   end
 end


実行してみましょう

$ spec -fs message_filter_spec.rb 

MessageFilter with argument "foo"
- should be detect "hello from foo"
- should not be detect "hello, world!"

MessageFilter with argument "foo","bar"
- should be detect "hello from bar"

Finished in 0.003604 seconds

3 examples, 0 failures
$ 

テストを追加する

foo,bar というふたつの引数を取るフィルタは、 foo ひとつを引数に取る場合のテストも当然満たさなければならないですよね。ということで、まずは foo 一引数のテストをコピペして持ってきます。


message_filter_spec.rb

 require 'rubygems'
 require 'spec'
 require 'message_filter'
 
 describe MessageFilter, 'with argument "foo"' do
   subject { MessageFilter.new('foo') }
   it { should be_detect('hello from foo') }
   it { should_not be_detect('hello, world!') }
 end
 
 describe MessageFilter, 'with argument "foo","bar"' do
   subject { MessageFilter.new('foo', 'bar') }
   it { should be_detect('hello from bar') }
+  it { should be_detect('hello from foo') }
+  it { should_not be_detect('hello, world!') }
 end


実行してみましょう

$ spec -fs message_filter_spec.rb 

MessageFilter with argument "foo"
- should be detect "hello from foo"
- should not be detect "hello, world!"

MessageFilter with argument "foo","bar"
- should be detect "hello from bar"
- should be detect "hello from foo"
- should not be detect "hello, world!"

Finished in 0.004234 seconds

5 examples, 0 failures
$ 

share_examples_for を使ってテストの重複を排除する

一つ前のステップでコピペを行ったので、テストコードの重複が増えましたね。重複している code example は、 share_examples_for という機能でまとめてみましょう。


share_examples_for の内容は引数に渡した文字列をキーとして登録され、 it_should_behave_like メソッドにその名前を使うことであたかもその場所に code example を書いたように動作します。


share_examples_for と it_should_behave_like は共に RSpec が提供するメソッドです。


message_filter_spec.rb

 require 'rubygems'
 require 'spec'
 require 'message_filter'
 
+share_examples_for 'MessageFilter with argument "foo"' do
+  it { should be_detect('hello from foo') }
+  it { should_not be_detect('hello, world!') }
+end
 
 describe MessageFilter, 'with argument "foo"' do
   subject { MessageFilter.new('foo') }
-  it { should be_detect('hello from foo') }
-  it { should_not be_detect('hello, world!') }
+  it_should_behave_like 'MessageFilter with argument "foo"'
 end

 describe MessageFilter, 'with argument "foo","bar"' do
   subject { MessageFilter.new('foo', 'bar') }
   it { should be_detect('hello from bar') }
-  it { should be_detect('hello from foo') }
-  it { should_not be_detect('hello, world!') }
+  it_should_behave_like 'MessageFilter with argument "foo"'
 end


実行してみましょう

$ spec -fs message_filter_spec.rb 

MessageFilter with argument "foo"
- should be detect "hello from foo"
- should not be detect "hello, world!"

MessageFilter with argument "foo","bar"
- should be detect "hello from bar"
- should be detect "hello from foo"
- should not be detect "hello, world!"

Finished in 0.004513 seconds

5 examples, 0 failures
$ 

テストとしての意味を保ったまま、コードは(比較的) DRY になりました。


今回は一つのファイルの中に share_examples_for ブロックと it_should_behave_like メソッドをまとめましたが、 share_examples_for メソッドはその名のとおり複数のテストファイルで code example を共有するためにも使えます。というよりは、ファイルを越えて code example を積極的に共有するための機能です。


また、 share_examples_for の兄弟メソッドとして share_as(Symbol) もあります。この二つの使い分けについては今後のイテレーションで登場するかもしれません。


なぜ説明的な長めの文字列をキーに使用したかの説明も必要ですね。


先ほど書いたように、 it_should_behave_like が書かれた場所と share_examples_for が書かれている場所は別ファイルである可能性が高くなります。このため、 it_should_behave_like が使われている場所から share_examples_for の中身は「遠く」なりがちです。毎回 share_examples_for の中身を見に行っているようでは共通化の意味がありません。つまり、 it_should_behave_like の引数は十分に説明的で、先にある share_examples_for の中身を見にいかなくても済むことが望ましいと考えています。以上が、私が share_examples_for でかなり長い名前をつけた理由です。



describe をネストする

今書いているテストは、 MessageFilter をつかう状況を二種類用意してテストしていると言えます。同じ対象に対する、別の状況のテストですね。 MessageFilter に対するテストであるという意図を示すために、ネストされた構造にテストコードを変更しましょう。今まで describe ブロックに二つ引数を渡していたところを、外側の describe ブロックの引数には MessageFilter 、内側の describe ブロックの引数には状況説明用の文字列を書くようにします。


(インデント変更につき diff が大きいので、今回はリファクタリング後のコードのみを記します。)


message_filter_spec.rb

 require 'rubygems'
 require 'spec'
 require 'message_filter'
 
 describe MessageFilter do
   share_examples_for 'MessageFilter with argument "foo"' do
     it { should be_detect('hello from foo') }
     it { should_not be_detect('hello, world!') }
   end
   describe 'with argument "foo"' do
     subject { MessageFilter.new('foo') }
     it_should_behave_like 'MessageFilter with argument "foo"'
   end
   describe 'with argument "foo","bar"' do
     subject { MessageFilter.new('foo', 'bar') }
     it { should be_detect('hello from bar') }
     it_should_behave_like 'MessageFilter with argument "foo"'
   end
 end


実行してみましょう

$ spec -fs message_filter_spec.rb 

MessageFilter with argument "foo"
- should be detect "hello from foo"
- should not be detect "hello, world!"

MessageFilter with argument "foo","bar"
- should be detect "hello from bar"
- should be detect "hello from foo"
- should not be detect "hello, world!"

Finished in 0.004295 seconds

5 examples, 0 failures
$ 

ネストしても RSpec が出力する仕様記述が変わっていないことが確認できると思います。



状況を記すには describe より context を好む

さきほど describe がネストできることを学びました。ところで、 RSpec は describe のエイリアスとして context というメソッドも用意しています*2。私は、対象を説明する時は describe, 状況を説明する時は context というように使い分けています。


message_filter_spec.rb

 require 'rubygems'
 require 'spec'
 require 'message_filter'
 
 describe MessageFilter do
   share_examples_for 'MessageFilter with argument "foo"' do
     it { should be_detect('hello from foo') }
     it { should_not be_detect('hello, world!') }
   end
-  describe 'with argument "foo"' do
+  context 'with argument "foo"' do
     subject { MessageFilter.new('foo') }
     it_should_behave_like 'MessageFilter with argument "foo"'
   end
-  describe 'with argument "foo","bar"' do
+  context 'with argument "foo","bar"' do
     subject { MessageFilter.new('foo', 'bar') }
     it { should be_detect('hello from bar') }
     it_should_behave_like 'MessageFilter with argument "foo"'
   end
 end


実行してみましょう

$ spec -fs message_filter_spec.rb 

MessageFilter with argument "foo"
- should be detect "hello from foo"
- should not be detect "hello, world!"

MessageFilter with argument "foo","bar"
- should be detect "hello from bar"
- should be detect "hello from foo"
- should not be detect "hello, world!"

Finished in 0.004295 seconds

5 examples, 0 failures
$ 

テストが整ったので再度実装のリファクタリングを行う

テストコードの方はだいぶリファクタリングできました。実装コードの方に目を向けてみましょう。最初に行った実装はあまりにも安易でしたね。テストがあるので、より綺麗なコードを追い求めることができます。 Enumerable#any? メソッドを使ってシンプルにしましょう。

Enumerable には素敵なメソッドが沢山あるので、覚えて損はありません。


message_filter.rb

 class MessageFilter
   def initialize(*words)
     @words = words
   end
   def detect?(text)
-    @words.each do |w|
-      return true if text.include?(w)
-    end
-    false
+    @words.any?{|w| text.include?(w) }
   end
 end


実行してみましょう

$ spec -fs message_filter_spec.rb 

MessageFilter with argument "foo"
- should be detect "hello from foo"
- should not be detect "hello, world!"

MessageFilter with argument "foo","bar"
- should be detect "hello from bar"
- should be detect "hello from foo"
- should not be detect "hello, world!"

Finished in 0.004314 seconds

5 examples, 0 failures
$ 

第二イテレーション終了

このイテレーションではテスト/実装コードは最終的に以下のようになりました。


message_filter_spec.rb

 require 'rubygems'
 require 'spec'
 require 'message_filter'
 
 describe MessageFilter do
   share_examples_for 'MessageFilter with argument "foo"' do
     it { should be_detect('hello from foo') }
     it { should_not be_detect('hello, world!') }
   end
   context 'with argument "foo"' do
     subject { MessageFilter.new('foo') }
     it_should_behave_like 'MessageFilter with argument "foo"'
   end
   context 'with argument "foo","bar"' do
     subject { MessageFilter.new('foo', 'bar') }
     it { should be_detect('hello from bar') }
     it_should_behave_like 'MessageFilter with argument "foo"'
   end
 end


message_filter.rb

 class MessageFilter
   def initialize(*words)
     @words = words
   end
   def detect?(text)
     @words.any?{|w| text.include?(w) }
   end
 end

さて、ここまでで第二イテレーションは終了です。タグを打って終わりにしましょう。

$ git tag -a -m 'end of 2nd iteration' end_of_iter2

このイテレーションで学んだこと

  • spec ファイルの構造は比較的自由
  • share_examples_for で code example を共有できる
  • describe はネストできる
  • ものには describe, 状況には context


(第3イテレーションへ続く)

*1:というか、ここまででまだフィルタになっていないですね…

*2:歴史的には context の方が先に登場しているはず。要出典。