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

More than 3 years have passed since last update.


はじめに

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

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


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


ソースコードのURL


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

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

banner


備考


  • 本文やサンプルコードは極力オリジナルバージョンを踏襲します。

  • 記述が古くなっている箇所は新しい記述方法に書き直します。

  • 新しい記述方法や和田さんのオリジナル記事に書かれていない情報には 【注目!】 の印を付けて説明しています。


  • 第1イテレーション第2イテレーションですでに説明した 【注目!】 情報(shouldがexpect(...).toに変わった点等)は本記事では説明しません。


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

この「RSpec 入門とその一歩先へ」シリーズでは、メッセージフィルタを RSpec を使って開発することで、 RSpec の機能と TDD を同時に学ぶことを狙いとしています。

前回終了時点のコードと実行結果をまず記します。

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

(-fd オプション付きで)実行してみましょう。

$ 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.00466 seconds (files took 0.09372 seconds to load)
5 examples, 0 failures


新仕様追加

さて、第3イテレーションでは機能をまた新しく加えてゆきましょう。

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

$ git checkout master

Switched to branch 'master'
Your branch is ahead of 'origin/master' by 15 commits.
(use "git push" to publish your local commits)
$ git merge 2nd
Updating 6740238..05d2bf1
Fast-forward
message_filter.rb | 6 +++---
message_filter_spec.rb | 18 ++++++++++++++----
2 files changed, 17 insertions(+), 7 deletions(-)
$ git checkout -b 3rd
Switched to a new branch '3rd'


第3イテレーション開始

さて、第3イテレーションでは NG ワードに関する機能を追加します。具体的には、 MessageFilter に NG ワードがいくつ設定されているか、どんな NG ワードが設定されているかが分かるような機能を追加してみます。

最初に書くのは…そう、 spec からですね。第2イテレーションまでの spec の context で既に NG ワードが設定されているはずなので、 NG ワードが空でない、というテストを書いてみましょう。

message_filter_spec.rb

   context 'with argument "foo"' do

subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
+ it 'ng_words should not be empty' do
+ expect(subject.ng_words.empty?).to eq false
+ end
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }


subject を明示的なレシーバとして使う

ここで

expect(subject.ng_words.empty?).to eq false

という書き方をしています。この subject は何かというと、 subject ブロックの評価結果が返るメソッドです。

前回までのイテレーションでは、 it ブロックの中で should のレシーバが暗黙的に subject ブロックの評価結果になるという説明をしてきましたが、明示的に subject ブロックの評価結果をテストコード内で使いたい場合には、 subject メソッドを使うことが出来ます(ちょっと紛らわしいですね)。

では実行してみましょう

$ bundle exec rspec message_filter_spec.rb 

F.....

Failures:

1) MessageFilter with argument "foo" ng_words should not be empty
Failure/Error: expect(subject.ng_words.empty?).to eq false
NoMethodError:
undefined method `ng_words' for #<MessageFilter:0x007f9266950fb0 @words=["foo"]>
# ./message_filter_spec.rb:12:in `block (3 levels) in <top (required)>'

Finished in 0.00379 seconds (files took 0.08248 seconds to load)
6 examples, 1 failure

Failed examples:

rspec ./message_filter_spec.rb:11 # MessageFilter with argument "foo" ng_words should not be empty
$ git commit -am 'Add spec for ng_word'
[3rd 83085ac] Add spec for ng_word
1 file changed, 3 insertions(+)

Commit

ng_words というメソッドが無いというエラーが発生して、めでたく(?)、失敗しました。まだ実装していない機能に対するテストですから、落ちるのが当然ですね。 TDD では大事なステップです。では実装を行いましょう。


明白な実装

前のステップで追加した spec は、コードの利用者から見た視点で書きました。このテストを通すためにまた「仮実装」をしても良いのですが、ここは一気に実装まで行う「明白な実装(Obvious Implementation)」路線で行ってみます。 attr_reader で実装できそうですね。

message_filter.rb

 class MessageFilter

def initialize(*words)
- @words = words
+ @ng_words = words
end
+ attr_reader :ng_words
def detect?(text)
- @words.any?{|w| text.include?(w) }
+ @ng_words.any?{|w| text.include?(w) }
end
end

@words というインスタンス変数を @ng_words と書き換え、次に attr_reader を定義して外からメソッドとして見えるようにしました。ここにも、テストから先に書く意味と効果が現れています。

ここまでコードを書いてきた実装者の視点では、 MessageFilter の内部変数として @words という名前を使うことは簡潔で良いかもしれません。しかし、テストを先に書くという視点、つまりコードの利用者から見た視点ではどうでしょうか。 message_filter.words というメソッドでは、「 words 」が NG ワードを指すのか、それとも検出された言葉を指すのかが分かりにくくなてしまっていないでしょうか。


最初の利用者は自分

TDD では、実装の前にテストを書きます。これは、半ば強制的に「利用者の視点」を得ることが出来る、という効果を持ちます。まだ実装されていない機能のテストなのですから「自分はこういうメソッドがわかりやすい」「こういう名前が良い」、逆に「こういう名前は曖昧で不安」「引数が文字列5つとかありえない」などのコードを利用する側としての視点や感情を得られます。

TDD では、その感情や利用者としての設計判断をテストコードという形で記し、その後で実装を行うことで、既存の実装に引きずられにくい「こうしたい」「こうあるべき」という実装を引き出しやすくなるという効果があります。

「自分が書くコードの最初の利用者は自分」というのが TDD のルールです。英語では「 Eat your own dog food (自分のドッグフードを食べろ)」というフレーズで知られている考え方でもあります。

では実行してみましょう

$ bundle exec rspec message_filter_spec.rb

......

Finished in 0.00675 seconds (files took 0.08174 seconds to load)
6 examples, 0 failures
$ git commit -am 'Add ng_word accessor'
[3rd f9cf563] Add ng_word accessor
1 file changed, 3 insertions(+), 2 deletions(-)

Commit


predicate マッチャに書き換える

以前も書きましたが、 真偽値を返すメソッドは predicate マッチャという形式に書き換えることで可読性を上げることが出来ます。

expect(subject.ng_words.empty?).to eq false

という書き方は

expect(subject.ng_words).not_to be_empty

という書き方に変更することができます。この方が読みやすく、違和感が無いのではないでしょうか。(expect の引数が subject から始まるところに違和感がありますが…)

message_filter_spec.rb

     subject { MessageFilter.new('foo') }

it_behaves_like 'MessageFilter with argument "foo"'
it 'ng_words should not be empty' do
- expect(subject.ng_words.empty?).to eq false
+ expect(subject.ng_words).not_to be_empty
end
end
context 'with argument "foo","bar"' do

(-fd オプション付きで)実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

MessageFilter
with argument "foo"
ng_words should not be empty
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.0029 seconds (files took 0.08092 seconds to load)
6 examples, 0 failures
$ git commit -am 'Use predicate matcher'
[3rd b4656f4] Use predicate matcher
1 file changed, 1 insertion(+), 1 deletion(-)

Commit

問題なさそうですね。


RSpec に仕様記述をさせる

さて、この「RSpec 入門とその一歩先へ」シリーズでは一貫して「なるべく it メソッドの文字列引数を使わずに、 RSpec 自身に仕様記述を組み立てさせる」という方針で進めてきました。

it 'ng_words should not be empty' do

という記述もいかにも RSpec 自身に組み立てさせることが出来そうです。やってみましょう。

message_filter_spec.rb

   context 'with argument "foo"' do

subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
- it 'ng_words should not be empty' do
- expect(subject.ng_words).not_to be_empty
- end
+ it { expect(subject.ng_words).not_to be_empty }
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }

(-fd オプション付きで)実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

MessageFilter
with argument "foo"
should not be empty
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.00306 seconds (files took 0.08187 seconds to load)
6 examples, 0 failures
$ git commit -am 'Remove example description'
[3rd 5b9f8ea] Remove example description
1 file changed, 1 insertion(+), 3 deletions(-)

Commit

む? なにかおかしいですね…

RSpec 自身に仕様記述を組み立てさせると

MessageFilter

with argument "foo"
should not be empty

「 MessageFilter with argument "foo" should not be empty 」…意味を正確に伝えなくなってしまいました。 RSpec にもっとヒントを与えなくてはならないようですね。

【注目!】it メソッドの文字列引数を使うことは悪ではありません

オリジナルの記事では「なるべく it メソッドの文字列引数を使わずに、 RSpec 自身に仕様記述を組み立てさせる」という方針をとっていますが、必ずしもこれがRSpecのベストプラクティスだというわけではありません。あくまでアプローチの一つです。

たしかに、文字列引数を使わずにきれいなドキュメント形式のアウトプットが出せるのは気持ちがいいかもしれません。

しかし、それを実現するために長時間試行錯誤したり、文字列引数を使わないことが目的になってしまい、テストコードがトリッキーになったりしてしまうのは逆効果です。

「時間がかかってるな」「かえってテストコードがややこしくなるな」と思ったときは、躊躇せずに文字列引数を使いましょう。

英語が苦手なのであれば日本語で書くのもよいと思います。


itsメソッド

RSpec に情報を与えるために、ここで its というメソッドを使ってみます。

【注目!】RSpec 3では rspec-its gemのインストールが必要です

この its というメソッドはRSpec 3からは標準で提供されなくなりました。

its を使う場合は rspec-its というgemをインストールする必要があります。

まず、Gemfileを次のように編集します。

Gemfile

 source 'https://rubygems.org'

gem 'rspec', '3.0.0'
+gem 'rspec-its', '1.0.1'

続いて、bundle install でインストールを実行します。

$ bundle install

Fetching gem metadata from https://rubygems.org/.........
Fetching additional metadata from https://rubygems.org/..
Resolving dependencies...
Using diff-lcs 1.2.5
Using rspec-support 3.0.2
Using rspec-core 3.0.2
Using rspec-expectations 3.0.2
Using rspec-mocks 3.0.2
Using rspec 3.0.0
Installing rspec-its 1.0.1
Using bundler 1.6.2
Your bundle is complete!
Use `bundle show [gemname]` to see where a bundled gem is installed.


its メソッドの導入

its メソッドは引数に Symbol を受け取ります。そして subject に対して Symbol で指定されたメソッドを呼び出し、その戻り値を its のブロック内での should の暗黙のレシーバに設定します。

ちょっと複雑なので、実際に例を見た方が良いかもしれません。今回のテストコードは、 its 機能を使って次のように書き換えることが出来ます。

message_filter_spec.rb

 require_relative 'message_filter'

+require 'rspec/its'

describe MessageFilter do
shared_examples 'MessageFilter with argument "foo"' do

   context 'with argument "foo"' do

subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
- it { expect(subject.ng_words).not_to be_empty }
+ its(:ng_words) { is_expected.not_to be_empty }
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }

(-fd オプション付きで)実行してみます。さて、どうなるでしょう。

$ 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!"
ng_words
should not be empty
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.00403 seconds (files took 0.14538 seconds to load)
6 examples, 0 failures
$ git commit -am 'Use rspec-its'
[3rd 91637bd] Use rspec-its
3 files changed, 7 insertions(+), 1 deletion(-)

Commit

出力が変わりましたね。

MessageFilter

with argument "foo"
(中略)
ng_words
should not be empty

「 MessageFilter with argument "foo" ng_words should not be empty 」…ぎこちなくはありますが、補足情報が加わりました。

それよりも、大きく可読性が上がったのはテストコードの方です。

its(:ng_words) { is_expected.not_to be_empty }

括弧や記号を"見えないもの"とすると、「 its ng words is expected not to be empty 」となります。前よりも自然に読めるコードになっているのではないでしょうか。

【注目!】its メソッドを使うべきか否か?

RSpec 3から its メソッドが標準で提供されなくなった理由は、RSpecの作者であるMyron Marston氏のブログに詳しく書かれています。

Explanation for why `its` will be removed from rspec-3

このブログでは its メソッドの望ましくない使い方が載っています。

User = Struct.new(:name, :email)

describe User do
subject { User.new("bob") }
its(:name) { should == "bob" }
end

$ bin/rspec user_spec.rb --format doc

User
name
should == "bob"

Finished in 0.00085 seconds
1 example, 0 failures

Randomized with seed 25153

実行結果を読むと「User name should == "bob"」となり、User#nameが常に"bob"を返すように見えてしまいます。

its メソッドを使うと簡潔に書けるメリットはあるものの、誤解されかねない出力結果を生み出すデメリットの方が強いので、標準で提供するのをやめたようです。

本記事では its メソッドを使っても上記のデメリットが出ないような使われ方をしているので、引き続き its メソッドを使っていきます。

しかし、RSpec 3では明確な理由をもって標準実装から外れた以上、みなさん自身のプロジェクトでは新しく its メソッドを使ったテストコードを追加するのは避けた方が良いと思います。


shared_examples に移動する

ところで、「 its ng words is expected not to be empty 」という仕様は "foo" の場合だけでなく "foo, bar" でも当てはまりますね。なので、 shared_examples のブロックに移しましょう。

message_filter_spec.rb

   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!') }
+ its(:ng_words) { is_expected.not_to be_empty }
end
context 'with argument "foo"' do
subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
- its(:ng_words) { is_expected.not_to be_empty }
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }

(-fd オプション付きで)実行してみましょう。

$ 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!"
ng_words
should not be empty
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!"
ng_words
should not be empty

Finished in 0.00386 seconds (files took 0.14411 seconds to load)
7 examples, 0 failures
$ git commit -am 'Move its-example to shared_examples'
[3rd 111454e] Move its-example to shared_examples
1 file changed, 1 insertion(+), 1 deletion(-)

Commit

大丈夫そうですね。


do...end から {...} への変更とその意図について

このシリーズでは、これまでもテストコードの中でブロックの書き方を do...end から {...} への変更を何度か行ってきました。

二つの書き方のどちらを選択するかはプログラマの自由ですが、慣例的に以下のようなルールに従うことが多いと思います。


ただし、メソッドチェインを行う場合は{ ... }を使用する

Rubyコーディング規約


 


まず基本的な使いわけとして、複数回呼ぶ可能性があるとき (例えば Enumerable#each) は do....end を使う。呼ぶのが一回だけのとき (例えば File.open) は {....} を使う。ここで、後者の「一回だけのとき」とは RubyIteratorPattern で言う「範囲型」あるいは「コンテキスト型」のことである。

加えて、一行に収めるときは {....} を使う。

さらに、返り値を使うときも {....} を使う。

LoveRubyNet Wiki: RubyCodingStyle


RSpec の場合は、it のブロックの戻り値を使うことは(多分)ありません。つまりメソッドチェインを使いたいという動機はありません。

なので、 RSpec を使ったコードの中で do...end を使うか {...} を使うかは、純粋に可読性のための選択ということになります。例えば今回の場合は、「一行に収まるので {...} を選択する」というルールに従ったとも言えます。

しかし私は、 RSpec のコードの中で it {...} というスタイルを選択することに、もっと積極的な意味を持たせています。それは「 RSpec 自身に状況を理解させ、テストコードの可読性と仕様記述を両立させる」ということです。

先ほど説明の中で「括弧や記号を"見えないもの"とすると」というフレーズを使いました。 RSpec を使ったコードの中では、視覚的に強くない it{...} 形式は黒子のような役割を果たし、テストコード自身の可読性に寄与すると考えています。つまり、文字列によってではなく、テストコード自身に仕様を語らせるときに it {...} を使うようにしています。

テストコードをリファクタリングするときは、 RSpec とプログラマの間で情報を共有しつつ、記述量も少なく簡潔で、かつ大きい情報の欠落が無い、そんなコードを目指しています。


もう一つ機能追加: NG ワードの個数を調べる

さて、もう一つ機能追加してみましょう。「NG ワードの個数を調べる」という機能のテストと実装を行います。最初に書くのはもちろんテストです。

message_filter_spec.rb

   context 'with argument "foo"' do

subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
+ it 'ng_words size is 1' do
+ expect(subject.ng_words.size).to eq 1
+ end
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }

(-fd オプション付きで)実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb        

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

Finished in 0.00592 seconds (files took 0.1377 seconds to load)
8 examples, 0 failures
$ git commit -am 'Add example for ng_words size'
[3rd fd0cd72] Add example for ng_words size
1 file changed, 3 insertions(+)

Commit

おや、実装していないのに一発で通りましたね。つまり attr_reader を導入していたので実装は必要なかった、ということです。


have マッチャ

expect(subject.ng_words.size).to eq 1

という書き方は明示的ではありますが、ややぎこちないですね。このような場合、 RSpec では「 have マッチャ」という機能で書き方を改善することができます。

具体的には、次のように書けます。

expect(subject.ng_words).to have(1).item

【注目!】RSpec 3では rspec-collection_matchers gemのインストールが必要です

この have マッチャはRSpec 3からは標準で提供されなくなりました。

have マッチャを使う場合は rspec-collection_matchers というgemをインストールする必要があります。

まず、Gemfileを次のように編集します。

Gemfile

source 'https://rubygems.org'

gem 'rspec', '3.0.0'
gem 'rspec-its', '1.0.1'
+gem 'rspec-collection_matchers', '1.0.0'

続いて、bundle install でインストールを実行します。

$ bundle install

Fetching gem metadata from https://rubygems.org/.........
Fetching additional metadata from https://rubygems.org/..
Resolving dependencies...
Using diff-lcs 1.2.5
Using rspec-support 3.0.2
Using rspec-core 3.0.2
Using rspec-expectations 3.0.2
Using rspec-mocks 3.0.2
Using rspec 3.0.0
Installing rspec-collection_matchers 1.0.0
Using rspec-its 1.0.1
Using bundler 1.6.2
Your bundle is complete!
Use `bundle show [gemname]` to see where a bundled gem is installed.


have マッチャの導入

では have マッチャを導入してみましょう。

message_filter_spec.rb

 require_relative 'message_filter'

require 'rspec/its'
+require 'rspec/collection_matchers'

describe MessageFilter do
shared_examples 'MessageFilter with argument "foo"' do

     subject { MessageFilter.new('foo') }

it_behaves_like 'MessageFilter with argument "foo"'
it 'ng_words size is 1' do
- expect(subject.ng_words.size).to eq 1
+ expect(subject.ng_words).to have(1).item
end
end
context 'with argument "foo","bar"' do

(-fd オプション付きで)実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

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

Finished in 0.00409 seconds (files took 0.14577 seconds to load)
8 examples, 0 failures
$ git commit -am 'Use rspec-collection_matchers'
[3rd 7c40c06] Use rspec-collection_matchers
3 files changed, 6 insertions(+), 1 deletion(-)

Commit

【注目!】複数形(item)と単数形(items)の使い分けについて

オリジナルの記事では

expect(subject.ng_words).to have(1).items

のように書かれています。(実際にはexpectではなくshouldを使っていますが)

わかりにくいかもしれませんが、最後の itemitems になっている点が異なります。

have マッチャを使った場合、 have(n).itemhave(n).items は互いに置換可能です。

ただし、英語としてみた場合は have(1).itemhave(2).itemsというように使い分けた方が正しい英文法に近くなります。

個人的には正しい英文法に近い方が違和感がないので、本記事では単数形と複数形を使い分けることにします。

【注目!】have マッチャを使うべきか否か?

RSpec 3から have マッチャが標準で提供されなくなった理由は、RSpecの作者であるMyron Marston氏のブログに書かれています。

Myron Marston » Notable Changes in RSpec 3

ここでは have マッチャのことを「one of the more “magical” and confusing parts of RSpec」と呼んでいます。

つまり、「黒魔術的でややこしいから」というのが、 have マッチャを標準で提供しなくなった理由のようです。

オリジナルの記事ではこのあとも have マッチャを活用したspecのリファクタリングが続くので、本記事でも引き続き have マッチャを使っていきます。

しかし、its メソッドと同様、RSpec 3では標準実装から外れた以上、みなさん自身のプロジェクトでは新しく have マッチャを使ったテストコードを追加するのは避けた方が良いと思います。


再度 RSpec に仕様記述を組み立てさせる

再度、テストコード記述の合理化を進めましょう。it の文字列引数を廃し、 RSpec に仕様記述を組み立てさせます。

message_filter_spec.rb

   context 'with argument "foo"' do

subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
- it 'ng_words size is 1' do
- expect(subject.ng_words).to have(1).item
- end
+ it { expect(subject.ng_words).to have(1).item }
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }

(-fd オプション付きで)実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb  

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

Finished in 0.00381 seconds (files took 0.09013 seconds to load)
8 examples, 0 failures
$ git commit -am 'Remove example description'
[3rd eeb560a] Remove example description
1 file changed, 1 insertion(+), 3 deletions(-)

Commit

うむむ、またも情報不足ですね。

MessageFilter

with argument "foo"
should have 1 item

と、情報量が減ってしまっています。さてどうしましょう。


覚えたてホヤホヤの its を使ってみる

先程せっかく覚えたのですから、覚えたてホヤホヤの its を使ってみましょう。

message_filter_spec.rb

     subject { MessageFilter.new('foo') }

it_behaves_like 'MessageFilter with argument "foo"'
it { expect(subject.ng_words).to have(1).item }
+ its(:ng_words) { is_expected.to have(1).item }
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }

(-fd オプション付きで)実行してみましょう。

$ 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!"
ng_words
should not be empty
ng_words
should have 1 item
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!"
ng_words
should not be empty

Finished in 0.00378 seconds (files took 0.08807 seconds to load)
8 examples, 0 failures
$ git commit -am 'Use rspec-its'
[3rd 028ace7] Use rspec-its
1 file changed, 1 insertion(+), 1 deletion(-)

Commit

情報が付加されましたね。これで満足…でしょうか?


have(n).named_collection 記法を使う

実はもっと良い書き方があります。これまで have マッチャに対して 'items' というメソッドを呼んでいましたが、have マッチャはレシーバ(subject)がコレクションを返すメソッドを持つ場合に、そのメソッド名を記述することができます。

expect(subject).to have(n).named_collection 

という書き方です。ここで named_collection に subject が持つメソッド名を使うことが出来るのです。 RSpec は指定されたメソッドを subject に対して呼び出し、結果の数を元に検証を行います。

Rails の ActiveRecord のモデルを想像するとわかりやすいかもしれません。例えば

class Blog < ActiveRecord::Base

has_many :comments
end

というモデルがある場合、

expect(some_blog).to have(4).comments 

と書くことができるということです。

では今回の例ではどうなるでしょうか。書いてみましょう。

message_filter_spec.rb

   context 'with argument "foo"' do

subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
- its(:ng_words) { is_expected.to have(1).item }
+ it { is_expected.to have(1).ng_words }
end
context 'with argument "foo","bar"' do
subject { MessageFilter.new('foo', 'bar') }

(-fd オプション付きで)実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

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

Finished in 0.00408 seconds (files took 0.10256 seconds to load)
8 examples, 0 failures
$ git commit -am 'Use have(n).named_collection syntax'
[3rd b7ed263] Use have(n).named_collection syntax
1 file changed, 1 insertion(+), 1 deletion(-)

Commit

どうでしょう。

MessageFilter

with argument "foo"
should have 1 ng_words

出力は前よりも良い具合になりましたね。

テストコードの方も

its(:ng_words) { is_expected.to have(1).item }

から

it { is_expected.to have(1).ng_words }

へと書き換わりました。どうでしょう。読みやすくなっているのではないでしょうか。

【注目!】collectionが1個だったら単数形にするんじゃなかったの?

have マッチャを紹介するときに、「本記事では単数形と複数形を使い分ける」と書きましたが、ここでは it { is_expected.to have(1).ng_words } のように複数形の ng_words を使っています。

できれば it { is_expected.to have(1).ng_word } と書きたいところですが、単数形にすると NoMethodError: undefined method 'ng_word' というエラーが出るので、単数形では書けないようです。

よって、複数形の ng_words を使っています。


仕上げ

もう一つの context の方も have(n).named_collection 記法でテストを書いて、このイテレーションの締めとしましょう。

message_filter_spec.rb

     subject { MessageFilter.new('foo', 'bar') }

it { is_expected.to be_detect('hello from bar') }
it_behaves_like 'MessageFilter with argument "foo"'
+ it { is_expected.to have(2).ng_words }
end
end

(-fd オプション付きで)実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb        

MessageFilter
with argument "foo"
should have 1 ng_words
behaves like MessageFilter with argument "foo"
should be detect "hello from foo"
should not be detect "hello, world!"
ng_words
should not be empty
with argument "foo","bar"
should be detect "hello from bar"
should have 2 ng_words
behaves like MessageFilter with argument "foo"
should be detect "hello from foo"
should not be detect "hello, world!"
ng_words
should not be empty

Finished in 0.00428 seconds (files took 0.15165 seconds to load)
9 examples, 0 failures
$ git commit -am 'Add example for 2 ng_words'
[3rd 5210a85] Add example for 2 ng_words
1 file changed, 1 insertion(+)

Commit

テストが全て通りました。まずは一安心です。

【注目!】デフォルトの実行オプションを設定する

ここまで、RSpec実行時に毎回 -fd というオプションを付けていましたが、 .rspec というファイルを作ってその中にオプションを書くと、そのオプションがデフォルトで有効になります。

以下のように .rspec ファイルを作成し、中身を編集してください。

$ touch .rspec

+-fd --color

ここではドキュメント形式の出力を指定する -fd オプションに加え、出力結果を色づけする --color オプションも付けてみました。

それでは何もオプションを付けずにRSpecを実行してみましょう。

$ bundle exec rspec message_filter_spec.rb 

MessageFilter
with argument "foo"
should have 1 ng_words
behaves like MessageFilter with argument "foo"
should be detect "hello from foo"
should not be detect "hello, world!"
ng_words
should not be empty
with argument "foo","bar"
should be detect "hello from bar"
should have 2 ng_words
behaves like MessageFilter with argument "foo"
should be detect "hello from foo"
should not be detect "hello, world!"
ng_words
should not be empty

Finished in 0.01379 seconds (files took 0.15474 seconds to load)
9 examples, 0 failures
$ git add .
$ git commit -m 'Add .rspec file'
[3rd d005ccc] Add .rspec file
1 file changed, 1 insertion(+)
create mode 100644 .rspec

Commit

ご覧のように、 -fd オプションを付けなくてもドキュメント形式で出力されました。

--color オプションの効果がわかりづらいので、ターミナルのキャプチャ画像を貼っておきます。

パスしたテストはグリーンで表示されます。

Screen Shot 2014-07-20 at 5.35.09.png

テストがエラーになると、グリーンではなくレッドで表示されます。

Screen Shot 2014-07-20 at 5.35.59.png


第3イテレーション終了

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

message_filter_spec.rb

require_relative 'message_filter'

require 'rspec/its'
require 'rspec/collection_matchers'

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!') }
its(:ng_words) { is_expected.not_to be_empty }
end
context 'with argument "foo"' do
subject { MessageFilter.new('foo') }
it_behaves_like 'MessageFilter with argument "foo"'
it { is_expected.to have(1).ng_words }
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"'
it { is_expected.to have(2).ng_words }
end
end

message_filter.rb

class MessageFilter

def initialize(*words)
@ng_words = words
end
attr_reader :ng_words
def detect?(text)
@ng_words.any?{|w| text.include?(w) }
end
end

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

$ git tag -a -m 'end of 3rd iteration' end_of_iter3


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

このイテレーションで学んだことをまとめてみます。


  • subject を明示的なレシーバとして使うこともできる

  • 自分が最初のユーザ

  • its メソッド

  • have マッチャ

  • have(n).named_collection 記法

  • RSpec にヒントを与え、 RSpec 自身に仕様を記述させることで、テストコード自体も自然と短い記述に近づくという、 RSpec のコツ


おわりに: 「RSpec の入門とその一歩先へ ~RSpec 3バージョン~」のさらに先へ

以上で「RSpec の入門とその一歩先へ ~RSpec 3バージョン~」はおしまいです。

ここまで読んでくださったみなさん、どうもありがとうございました。

RSpecには他にも多くの機能がありますが、ここまでの内容を理解できれば、RSpecの基礎知識はほぼ身についたはずです。

RSpecについてもっと深く学習したい、という方は僕が翻訳に携わった電子書籍、「Everyday Rails - RSpecによるRailsテスト入門」を読んでもらうといいかもしれません。

テスト対象のアプリケーションは「ごく普通のRubyコード(Plain old Ruby)」ではなく、Railsアプリケーションになりますが、「RSpec の入門とその一歩先へ」では触れられなかった機能(letやmock等)についても説明が載っています。

電子書籍なので、RSpecの最新バージョンにあわせて随時内容もアップデートしていきます。

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

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

また、今後もときどきQiitaにRSpec関連の記事を投稿していこうと思っているので、良かったらフォローをお願いします。(RSpecのみならず、Rubyネタや英語ネタなど、いろいろ雑多に投稿していきます)

http://qiita.com/jnchito


あわせて読みたい

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

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

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