はじめに
本記事は和田卓人さん(@t_wada)が書かれた有名なRSpec入門記事、「RSpec の入門とその一歩先へ、第2イテレーション」をRSpec 3バージョンとして書き直したものです。
詳しくは第1イテレーションに書いた説明を参照してください。
各イテレーション(RSpec 3バージョン)へのリンク
ソースコードのURL
本記事のライセンスについて
本記事は クリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスで提供されています。
備考
- 本文やサンプルコードは極力オリジナルバージョンを踏襲します。
- 記述が古くなっている箇所は新しい記述方法に書き直します。
- 新しい記述方法や和田さんのオリジナル記事に書かれていない情報には 【注目!】 の印を付けて説明しています。
- 第1イテレーションで説明した 【注目!】 情報(shouldがexpect(...).toに変わった点等)は本記事では説明しません。
前回終了時点のコードと実行結果
前回終了時点でのコードを以下に記します。
class MessageFilter
def initialize(word)
@word = word
end
def detect?(text)
text.include?(@word)
end
end
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(+)
引数ひとつを期待したところに引数がふたつ来たよというエラーになりました。想定どおりですね。
さて、どういう実装を書きましょうか。仮実装路線で行くか、それとも明白な実装路線で行くか。今回は背伸びして実装を一気にしてみましょう。
可変引数を使って実装してみる
明白な、というか、ちょっとベタな実装をしてみます。(この実装はイテレーション後半でリファクタリングします)
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(-)
テストを追加する
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(+)
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(-)
テストとしての意味を保ったまま、コードは(比較的) 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%)
ネストしても 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(-)
テストが整ったので再度実装のリファクタリングを行う
テストコードの方はだいぶリファクタリングできました。実装コードの方に目を向けてみましょう。最初に行った実装はあまりにも安易でしたね。テストがあるので、より綺麗なコードを追い求めることができます。 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(-)
第2イテレーション終了
このイテレーションではテスト/実装コードは最終的に以下のようになりました。
class MessageFilter
def initialize(*words)
@words = words
end
def detect?(text)
@words.any?{|w| text.include?(w) }
end
end
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
このイテレーションで学んだこと
- 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テスト入門
本書の内容に関する詳しい情報はこちらのブログをどうぞ。
RSpec 3.1に完全対応!「Everyday Rails - RSpecによるRailsテスト入門」をアップデートしました