Ruby
TDD
RSpec

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

More than 3 years have passed since last update.

はじめに

本記事は和田卓人さん(@t_wada)が書かれた有名なRSpec入門記事、「RSpec の入門とその一歩先へ、第2イテレーション」をRSpec 3バージョンとして書き直したものです。

詳しくは第1イテレーションに書いた説明を参照してください。

各イテレーション(RSpec 3バージョン)へのリンク

ソースコードのURL

本記事のライセンスについて

本記事は クリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスで提供されています。

banner

備考

  • 本文やサンプルコードは極力オリジナルバージョンを踏襲します。
  • 記述が古くなっている箇所は新しい記述方法に書き直します。
  • 新しい記述方法や和田さんのオリジナル記事に書かれていない情報には 【注目!】 の印を付けて説明しています。
  • 第1イテレーションで説明した 【注目!】 情報(shouldがexpect(...).toに変わった点等)は本記事では説明しません。

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

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

message_filter.rb

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

message_filter_spec.rb

require_relative 'message_filter'

describe MessageFilter, 'with argument "foo"' do
  subject { MessageFilter.new('foo') }
  it { is_expected.to be_detect('hello from foo') }
  it { is_expected.not_to be_detect('hello, world') }
end

実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

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

Finished in 0.00199 seconds (files took 0.08473 seconds to load)
2 examples, 0 failures

第2イテレーション

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

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

$ git checkout master
Switched to branch 'master'
$ git merge 1st
Updating cc2b2df..6740238
Fast-forward
 message_filter.rb      | 8 ++++++++
 message_filter_spec.rb | 7 +++++++
 2 files changed, 15 insertions(+)
 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

   it { is_expected.to be_detect('hello from foo') }
   it { is_expected.not_to be_detect('hello, world') }
 end
+
+describe MessageFilter, 'with argument "foo","bar"' do
+  subject { MessageFilter.new('foo', 'bar') }
+  it { is_expected.to be_detect('hello from bar') }
+end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb 
..F

Failures:

  1) MessageFilter with argument "foo","bar" 
     Failure/Error: subject { MessageFilter.new('foo', 'bar') }
     ArgumentError:
       wrong number of arguments (2 for 1)
     # ./message_filter.rb:2:in `initialize'
     # ./message_filter_spec.rb:10:in `new'
     # ./message_filter_spec.rb:10:in `block (2 levels) in <top (required)>'
     # ./message_filter_spec.rb:11:in `block (2 levels) in <top (required)>'

Finished in 0.00259 seconds (files took 0.08536 seconds to load)
3 examples, 1 failure

Failed examples:

rspec ./message_filter_spec.rb:11 # MessageFilter with argument "foo","bar" 
$ git commit -am 'Add spec for variable length argument'
[2nd a616565] Add spec for variable length argument
 1 file changed, 5 insertions(+)

Commit

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

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

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

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

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

実行してみましょう。

$ bundle exec rspec -fd 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.00238 seconds (files took 0.08568 seconds to load)
3 examples, 0 failures
$ git commit -am 'Implement variable length argument'
[2nd 602e5d1] Implement variable length argument
 1 file changed, 6 insertions(+), 3 deletions(-)

Commit

テストを追加する

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

message_filter_spec.rb

 describe MessageFilter, 'with argument "foo","bar"' do
   subject { MessageFilter.new('foo', 'bar') }
   it { is_expected.to be_detect('hello from bar') }
+  it { is_expected.to be_detect('hello from foo') }
+  it { is_expected.not_to be_detect('hello, world!') }
 end

実行してみましょう。

$ bundle exec rspec -fd 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.0025 seconds (files took 0.07999 seconds to load)
5 examples, 0 failures
$ git commit -am 'Add specs for variable length argument'
[2nd fb0a27e] Add specs for variable length argument
 1 file changed, 2 insertions(+)

Commit

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

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

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

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

【注目!】
RSpec 3では share_examples_for ではなく、 shared_examples もしくは shared_examples_for を使うようになりました。本記事ではRelishのサンプルコードに倣ってshared_examplesを使います。
it_should_behave_like は引き続き使えますが、RSpec 3では"should"というキーワードを使わなくなっているので、本記事ではit_behaves_likeを採用しています。

message_filter_spec.rb

 require_relative 'message_filter'

+shared_examples 'MessageFilter with argument "foo"' do
+  it { is_expected.to be_detect('hello from foo') }
+  it { is_expected.not_to be_detect('hello, world!') }
+end
+
 describe MessageFilter, 'with argument "foo"' do
   subject { MessageFilter.new('foo') }
-  it { is_expected.to be_detect('hello from foo') }
-  it { is_expected.not_to be_detect('hello, world') }
+  it_behaves_like 'MessageFilter with argument "foo"'
 end

 describe MessageFilter, 'with argument "foo","bar"' do
   subject { MessageFilter.new('foo', 'bar') }
   it { is_expected.to be_detect('hello from bar') }
-  it { is_expected.to be_detect('hello from foo') }
-  it { is_expected.not_to be_detect('hello, world!') }
+  it_behaves_like 'MessageFilter with argument "foo"'
 end

実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

MessageFilter with argument "foo"
  behaves like 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"
  behaves like MessageFilter with argument "foo"
    should be detect "hello from foo"
    should not be detect "hello, world!"

Finished in 0.00277 seconds (files took 0.09055 seconds to load)
5 examples, 0 failures
$ git commit -am 'Use shared_examples'
[2nd 31c6476] Use shared_examples
 1 file changed, 7 insertions(+), 4 deletions(-)

Commit

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

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

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

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

【注目!】
オリジナルの記事ではshare_asについても言及されていますが、share_asはRSpec 3で削除されました。

describe をネストする

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

message_filter_spec.rb

 require_relative 'message_filter'

-shared_examples 'MessageFilter with argument "foo"' do
-  it { is_expected.to be_detect('hello from foo') }
-  it { is_expected.not_to be_detect('hello, world!') }
+describe MessageFilter do
+  shared_examples 'MessageFilter with argument "foo"' do
+    it { is_expected.to be_detect('hello from foo') }
+    it { is_expected.not_to be_detect('hello, world!') }
+  end
+  describe 'with argument "foo"' do
+    subject { MessageFilter.new('foo') }
+    it_behaves_like 'MessageFilter with argument "foo"'
+  end
+  describe 'with argument "foo","bar"' do
+    subject { MessageFilter.new('foo', 'bar') }
+    it { is_expected.to be_detect('hello from bar') }
+    it_behaves_like 'MessageFilter with argument "foo"'
+  end
 end
-
-describe MessageFilter, 'with argument "foo"' do
-  subject { MessageFilter.new('foo') }
-  it_behaves_like 'MessageFilter with argument "foo"'
-end
-
-describe MessageFilter, 'with argument "foo","bar"' do
-  subject { MessageFilter.new('foo', 'bar') }
-  it { is_expected.to be_detect('hello from bar') }
-  it_behaves_like 'MessageFilter with argument "foo"'
-end

実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

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

Finished in 0.00279 seconds (files took 0.09226 seconds to load)
5 examples, 0 failures
$ git commit -am 'Nest describe blocks'
[2nd 486431b] Nest describe blocks
 1 file changed, 17 insertions(+), 17 deletions(-)
 rewrite message_filter_spec.rb (93%)

Commit

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

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

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

message_filter_spec.rb

     it { is_expected.to be_detect('hello from foo') }
     it { is_expected.not_to be_detect('hello, world!') }
   end
-  describe 'with argument "foo"' do
+  context 'with argument "foo"' do
     subject { MessageFilter.new('foo') }
     it_behaves_like 'MessageFilter with argument "foo"'
   end
-  describe 'with argument "foo","bar"' do
+  context 'with argument "foo","bar"' do
     subject { MessageFilter.new('foo', 'bar') }
     it { is_expected.to be_detect('hello from bar') }
     it_behaves_like 'MessageFilter with argument "foo"'

実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

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

Finished in 0.00291 seconds (files took 0.07985 seconds to load)
5 examples, 0 failures
$ git commit -am 'Use context instead of describe'
[2nd 777b266] Use context instead of describe
 1 file changed, 2 insertions(+), 2 deletions(-)

Commit

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

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

message_filter.rb

     @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

実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

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

Finished in 0.00274 seconds (files took 0.08211 seconds to load)
5 examples, 0 failures
$ git commit -am 'Refactoring'
[2nd 05d2bf1] Refactoring
 1 file changed, 1 insertion(+), 4 deletions(-)

Commit

第2イテレーション終了

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

message_filter.rb

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

message_filter_spec.rb

require_relative 'message_filter'

describe MessageFilter do
  shared_examples 'MessageFilter with argument "foo"' do
    it { is_expected.to be_detect('hello from foo') }
    it { is_expected.not_to be_detect('hello, world!') }
  end
  context 'with argument "foo"' do
    subject { MessageFilter.new('foo') }
    it_behaves_like 'MessageFilter with argument "foo"'
  end
  context 'with argument "foo","bar"' do
    subject { MessageFilter.new('foo', 'bar') }
    it { is_expected.to be_detect('hello from bar') }
    it_behaves_like 'MessageFilter with argument "foo"'
  end
end

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

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

Tag

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

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

第3イテレーションに続きます

和田さんの記事と同様に、第3イテレーションの記事についてもRSpec 3バージョンを作成しました。
第1イテレーション、第2イテレーションと学習してきたなら、最後まで完走しましょう!

あわせて読みたい

既存のRSpec 2プロジェクトをRSpec 3にアップグレードする場合は以下の記事が参考になります。

RSpec 3の新機能について深く掘り下げたい方はこちらの記事をどうぞ。

こちらの記事ではRSpecの基本事項を実践的な視点で独自にまとめてみました。

PR: RSpec 3.1に対応した「Everyday Rails - RSpecによるRailsテスト入門」が発売中です

僕が翻訳者として携わった 「Everyday Rails - RSpecによるRailsテスト入門」 という電子書籍が発売中です。

Railsアプリケーションを開発している方で、実践的なテストの書き方を学んでみたいという人には最適な一冊です。
現行バージョンはRSpec 3.1に対応しています。
一度購入すれば将来無料でアップデート版をダウンロードできるのも本書の特徴の一つです。
よかったらぜひ読んでみてください!

Everyday Rails - RSpecによるRailsテスト入門
Everyday Rails

本書の内容に関する詳しい情報はこちらのブログをどうぞ。

RSpec 3.1に完全対応!「Everyday Rails - RSpecによるRailsテスト入門」をアップデートしました