Ruby
TDD
RSpec

RSpecの入門とその一歩先へ ~RSpec 3バージョン~

More than 3 years have passed since last update.


はじめに

有名な初心者向けのRSpec入門記事として、和田卓人さん(@t_wada)の「RSpec の入門とその一歩先へ」という記事があります。

僕もRSpecを全く知らなかった頃に参考にさせてもらいました。

今読んでもとても素晴らしい資料なのですが、RSpecのバージョンが古く、現状の書き方とマッチしなくなってきているのが少しもったいないところです。

そこで、この記事では和田さんの記事をRSpec 3バージョンに書き直してみようと思います。


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


ソースコードのURL


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

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

banner


備考


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

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

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


使用するツールのバージョン


  • RSpec 3.0.0

  • Ruby 2.1.2


読み進める前にインストールしておくもの


  • git

  • rbenv



  • Ruby 2.1.2


    • rbenvを使ってインストールしておいてください。



  • bundler



    • bundleコマンドが見つからない場合はgem install bundlerしてください。




1st iteration

favotter の みたいな NG ワードのフィルタリング機能を RSpec で作りましょう。まずは NG ワードの検出機能を作成します。

このイテレーションでは最初ベタな形のテストコードと実装を書き、だんだんとそのコードを洗練させてゆきます。


開発環境の準備(RSpecのインストール)

開発用のディレクトリを作成し、bundlerを使ってRSpecをインストールします。

なお、このエントリのコードは diff 風の記法で書かれています。 "+" が追加された行、 "-" が削除された行です。

また、これから書くコードを git で管理しましょう。各ステップ毎に commit を行いつつ進めましょう。

$ mkdir rspec3-for-beginners

$ cd rspec3-for-beginners
$ rbenv local 2.1.2
$ ruby -v
ruby 2.1.2p95 (2014-05-08 revision 45877) [x86_64-darwin13.0]
$ git init
Initialized empty Git repository in /Users/jit/dev/private/rspec3-for-beginners/.git/
$ git add .
$ git commit -am 'Initial commit'
[master (root-commit) 1c1a33b] Initial commit
1 file changed, 1 insertion(+)
create mode 100644 .ruby-version
$ touch Gemfile

Commit

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

【注目!】

オリジナルの記事とは異なり、本記事ではbundlerを使ってRSpecをインストールします。

Gemfileはbundler用の設定ファイル(インストールするgemの定義)です。

+source 'https://rubygems.org'

+
+gem 'rspec', '3.0.0'

bundle installを実行し、RSpecをインストールします。

完了したらgitにcommitします。

$ 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
Using bundler 1.6.2
Your bundle is complete!
Use `bundle show [gemname]` to see where a bundled gem is installed.
$ git add .
$ git commit -m 'Install RSpec'
[master 2f64804] Install RSpec
2 files changed, 25 insertions(+)
create mode 100644 Gemfile
create mode 100644 Gemfile.lock

Commit

これでRSpecを使って開発する準備が整いました。


message_filter_spec.rb を作成

次に、これから育てていく spec ファイルを作成します。

$ touch message_filter_spec.rb

ファイルを作成したら、いったんgitにcommitします。

また、1st iteration用の作業ブランチも作成します。

$ git add .

$ git commit -m 'Add spec'
[master cc2b2df] Add spec
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 message_filter_spec.rb
$ git checkout -b 1st
Switched to a new branch '1st'

Commit


message_filter.rbの作成

続いてmessage_filter_spec.rbを編集します。

+describe MessageFilter do

+end

それからMessageFilterクラスを作成します。

$ touch message_filter.rb

message_filter.rb

+class MessageFilter

+end

message_filter_spec.rb

+require_relative 'message_filter'

+
describe MessageFilter do
end

なお、require_relativeは現在のファイルからの相対パスでrequireするための組み込みメソッドです。

module function Kernel.#require_relative

ではRSpecを実行してみましょう。

次のような結果が表示されればOKです。

$ bundle exec rspec message_filter_spec.rb

No examples found.

Finished in 0.00015 seconds (files took 0.16597 seconds to load)
0 examples, 0 failures
$ git add .
$ git commit -m 'Add message_filter.rb'
[1st 04172c0] Add message_filter.rb
2 files changed, 6 insertions(+)
create mode 100644 message_filter.rb

【注目!】

RSpec 2からRSpecの実行コマンドがspecからrspecに変更されました。

また、bundlerを使ってRSpecをインストールしたので、ここではbundle exec rspecでRSpecを実行します。

ここでもgitにcommitしておきます。

Commit

これからあともステップごとにcommitしていくので、その都度コマンドの実行内容を確認してください。


最初のテスト (code example) を書きましょう

非常にベタな書き方ですが、最初のテスト (code example) を書きます。このイテレーションの後半で、テストの書き方も洗練させていきます。

【注目!】

RSpec 2.11からshouldではなく、expect(...).toを使うようになりました。

それにあわせて、itの引数として与える説明文も'should detect message with NG word'から'detects message with NG word'に変更しています。

message_filter_spec.rb

 require_relative 'message_filter'

describe MessageFilter do
+ it 'detects message with NG word' do
+ filter = MessageFilter.new('foo')
+ expect(filter.detect?('hello from foo')).to eq true
+ end
end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb

F

Failures:

1) MessageFilter detects message with NG word
Failure/Error: filter = MessageFilter.new('foo')
ArgumentError:
wrong number of arguments (1 for 0)
# ./message_filter_spec.rb:5:in `initialize'
# ./message_filter_spec.rb:5:in `new'
# ./message_filter_spec.rb:5:in `block (2 levels) in <top (required)>'

Finished in 0.0004 seconds (files took 0.16648 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./message_filter_spec.rb:4 # MessageFilter detects message with NG word
$ git commit -am 'Add first spec'
[1st 196a261] Add first spec
1 file changed, 4 insertions(+)

Commit

コンストラクタの引数の数が不正であると言われました。それはそうですね、コンストラクタを作成します。


コンストラクタの作成

message_filter.rb

 class MessageFilter

+ def initialize(word)
+ @word = word
+ end
end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb   

F

Failures:

1) MessageFilter detects message with NG word
Failure/Error: expect(filter.detect?('hello from foo')).to eq true
NoMethodError:
undefined method `detect?' for #<MessageFilter:0x007ff7f92767a0 @word="foo">
# ./message_filter_spec.rb:6:in `block (2 levels) in <top (required)>'

Finished in 0.00039 seconds (files took 0.16748 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./message_filter_spec.rb:4 # MessageFilter detects message with NG word
$ git commit -am 'Define initialize'
[1st 4ab3494] Define initialize
1 file changed, 3 insertions(+)

Commit

テストはまだ落ちています。 detect? というメソッドが無いよと言われました。無いですね。作りましょう。


detect?メソッド作成

空で良いので、メソッドを作成します。

message_filter.rb

 class MessageFilter

def initialize(word)
@word = word
end
+ def detect?(text)
+ end
end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb      

F

Failures:

1) MessageFilter detects message with NG word
Failure/Error: expect(filter.detect?('hello from foo')).to eq true

expected: true
got: nil

(compared using ==)
# ./message_filter_spec.rb:6:in `block (2 levels) in <top (required)>'

Finished in 0.00299 seconds (files took 0.16916 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./message_filter_spec.rb:4 # MessageFilter detects message with NG word
$ git commit -am 'Define detect?'
[1st c631d99] Define detect?
1 file changed, 2 insertions(+)

Commit

true が返ってきてほしいところに nil が返ってきました。メソッドの中身が空だからですね。ではこのテストを通すもっとも簡単な実装はどうなるでしょうか。ここに TDD のトリックがあります。それが、「 仮実装 (fake it) 」です。


仮実装 (fake it)

先ほどのテストを通すためのもっとも単純な実装はどうなるでしょうか? 書いてみましょう。

message_filter.rb

 class MessageFilter

def initialize(word)
@word = word
end
def detect?(text)
+ true
end
end

こ れ は ひ ど い !! しかし、これが TDD の「仮実装」です。

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb   

.

Finished in 0.00318 seconds (files took 0.17075 seconds to load)
1 example, 0 failures
$ git commit -am 'Implement detect? as fake'
[1st 3eaa110] Implement detect? as fake
1 file changed, 1 insertion(+)

Commit

メソッドから true を返すベタな実装を行ったのでテストが通るのは当たり前ですね。こんな行為に何か意味があるのでしょうか?

仮実装とは、テストのテスト、と考えることが出来ます。 例えば今回の例で、 true を返すという絶対テストが通るだろうという実装コードを書いても、テストが失敗したらどうでしょうか? それは、テストコードの方にこそバグが潜んでいることを示唆しています。仮実装で成功しないテストは、本実装でも成功しないでしょう。本実装でもテストが通らなかったときに、なぜテストが通らないのか本実装を長い時間デバッグした結果、テストコードが間違っていたのでは目も当てられません。


三角測量

しかし、このままでは実装はいつまでも安易過ぎるものになってしまうので、別のデータを使ったテストを足しましょう。これを TDD では 「三角測量(triangulation)」 といいます。

message_filter_spec.rb

 require_relative 'message_filter'

describe MessageFilter do
it 'detects message with NG word' do
filter = MessageFilter.new('foo')
expect(filter.detect?('hello from foo')).to eq true
end
+ it 'does not detect message without NG word' do
+ filter = MessageFilter.new('foo')
+ expect(filter.detect?('hello, world!')).to eq false
+ end
end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb

.F

Failures:

1) MessageFilter does not detect message without NG word
Failure/Error: expect(filter.detect?('hello, world!')).to eq false

expected: false
got: true

(compared using ==)
# ./message_filter_spec.rb:10:in `block (2 levels) in <top (required)>'

Finished in 0.001 seconds (files took 0.16826 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./message_filter_spec.rb:8 # MessageFilter does not detect message without NG word
$ git commit -am 'Add triangulation spec'
[1st c63c8dc] Add triangulation spec
1 file changed, 4 insertions(+)

Commit

「仮実装」で書いたコードはあっという間に破綻しました。そろそろきちんと実装しないといけないですね。


明白な実装

message_filter.rb

 class MessageFilter

def initialize(word)
@word = word
end
def detect?(text)
- true
+ text.include?(@word)
end
end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb           

..

Finished in 0.00093 seconds (files took 0.1758 seconds to load)
2 examples, 0 failures
$ git commit -am 'Finish obvious implementation'
[1st 17801c4] Finish obvious implementation
1 file changed, 1 insertion(+), 1 deletion(-)

Commit

実装が見えているときは、三角測量を介さずにそのまま仮実装を変更して実装してもかまいません。これを 「明白な実装 (obvious implementation)」 といいます。

大事なのは自分の不安をコントロールすることです。 TDD では、テストと実装両方に自信がある時は「明白な実装」、一歩一歩進めたい、つまりテストをテストして、そのあとで実装をテストしたい時は「仮実装」と「三角測量」の組み合わせを使います。


タグを打つ

きりのいいところまできたので、一度タグを打ちます。

$ git tag -a -m '1st iteration spec refactoring point' iter1_spec_refactoring

$ git tag
iter1_spec_refactoring


テストのリファクタリング

さて、テストが全部通っているので、実装クラスのリファクタリングを行いましょう。リファクタリングとは、コードに重複があったり、無駄がある場合に、テストが通ったままで実装を綺麗にしていくことです。実装にコードの重複や無駄はあるでしょうか? 現時点ではリファクタリングの余地がないほどシンプルですね。ではテストコードはどうでしょうか? …かなり重複が見られますね。テストを書いたすぐ後のタイミングで、テストコードの重複も積極的に排除していこう、というのが最近の考え方です。テストの「リファクタリング」というと厳密にはもっと難しく、タイミングが遅れるほど困難なものですが、テスト実装直後では自分の頭にもテスト設計が残っているでしょうし、このタイミングでは大胆に行動できます。では重複を排除していきましょう。


beforeメソッドの抽出

before メソッドを作成し、filter のインスタンス作成部分をそこに移動します。 before とは、 xUnit でいうと setUp に相当します。before メソッドは各テストの実行前に毎回実行されますので、重複部を before に書くことでテストコードの重複を排除することができます。

message_filter_spec.rb

 require_relative 'message_filter'

describe MessageFilter do
+ before(:each) do
+ @filter = MessageFilter.new('foo')
+ end
it 'detects message with NG word' do
- filter = MessageFilter.new('foo')
- expect(filter.detect?('hello from foo')).to eq true
+ expect(@filter.detect?('hello from foo')).to eq true
end
it 'does not detect message without NG word' do
- filter = MessageFilter.new('foo')
- expect(filter.detect?('hello, world!')).to eq false
+ expect(@filter.detect?('hello, world!')).to eq false
end
end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb

..

Finished in 0.00096 seconds (files took 0.17886 seconds to load)
2 examples, 0 failures
$ git commit -am 'Use before method'
[1st 8d3cc0e] Use before method
1 file changed, 5 insertions(+), 4 deletions(-)

Commit


eachは不要

ちなみに、 :each はデフォルト扱いなので、明示的に書く必要はありません。

message_filter_spec.rb

 require_relative 'message_filter'

describe MessageFilter do
- before(:each) do
+ before do
@filter = MessageFilter.new('foo')
end
it 'detects message with NG word' do

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb      

..

Finished in 0.00093 seconds (files took 0.17217 seconds to load)
2 examples, 0 failures
$ git commit -am 'Omit :each param'
[1st bda35e5] Omit :each param
1 file changed, 1 insertion(+), 1 deletion(-)

Commit


be_[predicate] マッチャー

expect(XXX?).to eq trueexpect(XXX).to be_truthy という記述は、 be_[predicate] マッチャーという書き方に変換することができます。こういう書き方をすることでドキュメントとしてのテストコードの意味を高め、かつ記述自体も簡潔にすることが出来ます。

expect(hoge.fuga?).to eq trueexpect(hoge).to be_fuga と書き直すことができます。 be_fuga というメソッドは当然存在しませんが、 RSpec が method_missing を使い、テスト用のメソッドであると解釈してくれます。これもメタプログラミングの例と言うことも出来ます。

【注目!】

上の説明に出てきたbe_truthyはRSpec 3から登場した記法です。RSpec 2まではbe_trueと書いていました。

同様にbe_falseもRSpec 3ではbe_falseyに変更されています。

message_filter_spec.rb

     @filter = MessageFilter.new('foo')

end
it 'detects message with NG word' do
- expect(@filter.detect?('hello from foo')).to eq true
+ expect(@filter).to be_detect('hello from foo')
end
it 'does not detect message without NG word' do
- expect(@filter.detect?('hello, world!')).to eq false
+ expect(@filter).not_to be_detect('hello, world')
end
end

実行してみましょう。

$ bundle exec rspec message_filter_spec.rb     

..

Finished in 0.00369 seconds (files took 0.17262 seconds to load)
2 examples, 0 failures
$ git commit -am 'Use be_predicate matcher'
[1st 1d8abc0] Use be_predicate matcher
1 file changed, 2 insertions(+), 2 deletions(-)

Commit

【注目!】

ここではexpect(...).not_to ...の形式で書いていますが、expect(...).to_not ...と書いても同じように動きます。

どちらが良いのか、という点についてはこちらの記事にまとめてみました。(結論としては「どっちでも良い」のですが)


-fdオプション

さて、ここ以降は RSpec のドキュメント出力機能にも着目します。

rspec コマンドの実行時に -fd というオプションをつけると、仕様記述を出力することが出来ます。現状ではどうなっているでしょうか。

【注目!】

RSpec 3から-fsオプションは-fdオプションに変わりました。

また、-fdの代わりに--format documentationと指定することもできます。

実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

MessageFilter
detects message with NG word
does not detect message without NG word

Finished in 0.00182 seconds (files took 0.17071 seconds to load)
2 examples, 0 failures


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

まだ重複しているのは、 it の引数とブロックの中身です。文字列で書かれた内容と、ブロック内のコード自身がかなり重複していますね。 RSpec は it の説明用の文字列引数を省略した時に自分で判断できる範囲で仕様記述を組み立てます。 it の文字列引数を削除してしまいましょう。

message_filter_spec.rb

   before do

@filter = MessageFilter.new('foo')
end
- it 'detects message with NG word' do
+ it {
expect(@filter).to be_detect('hello from foo')
- end
- it 'does not detect message without NG word' do
+ }
+ it {
expect(@filter).not_to be_detect('hello, world')
- end
+ }
end

実行してみましょう。

$ bundle exec rspec -fd message_filter_spec.rb

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

Finished in 0.00201 seconds (files took 0.16959 seconds to load)
2 examples, 0 failures
$ git commit -am 'Omit spec descriptions'
[1st 55c7ee4] Omit spec descriptions
1 file changed, 4 insertions(+), 4 deletions(-)

Commit

"should be detect" では英語的に微妙ですが、ここでは目をつぶります。どうでしょう。案外読めるのではないでしょうか?

違和感がある場合、それは設計がまだ不完全であることを示唆していると考えることができますし、カスタムマッチャーへの道を進むこともできます。

ここで論じているようなポイントも RSpec とのうまい付き合いかた、といえます。


describe にコンテクスト情報を追加する

先ほどの出力ですが、ちょっと情報が足りないですね

MessageFilter

should be detect "hello from foo"

「MessageFilter should be detect "hello from foo"」と読めますが、全ての MessageFilter がこう振る舞うわけではないですよね、状況の説明が足りていません。状況説明をテストコードに加えましょう。

message_filter_spec.rb

 require_relative 'message_filter'

-describe MessageFilter do
+describe MessageFilter, 'with argument "foo"' do
before do
@filter = MessageFilter.new('foo')
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.00196 seconds (files took 0.17112 seconds to load)
2 examples, 0 failures
$ git commit -am 'Add description to describe'
[1st 8e72fa5] Add description to describe
1 file changed, 1 insertion(+), 1 deletion(-)

Commit

ドキュメントを文字列で書かずとも、情報量がかなり保たれるようになってきましたね。これも RSpec Way です。


まだまだ重複がある!

@filter も重複していませんか? これも取り去ることができます。RSpec の subject という機能を使います。subject を使うと、 subject ブロックの評価結果が it 内の is_expected のレシーバになります。

【注目!】

RSpec 2まではit { should ... }と書いていましたが、RSpec 3からはit { is_expected.to ... }と書くようになりました。

message_filter_spec.rb

   before do

@filter = MessageFilter.new('foo')
end
+ subject { @filter }
it {
- expect(@filter).to be_detect('hello from foo')
+ is_expected.to be_detect('hello from foo')
}
it {
- expect(@filter).not_to be_detect('hello, world')
+ 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.00234 seconds (files took 0.26225 seconds to load)
2 examples, 0 failures
$ git commit -am 'Use subject'
[1st 98b1825] Use subject
1 file changed, 3 insertions(+), 2 deletions(-)

Commit


まだまだまだ重複がある!!

今回の例では before ブロックはほとんど仕事していないですね、 subject ブロックの中にインライン化してしまいましょう。

message_filter_spec.rb

 require_relative 'message_filter'

describe MessageFilter, 'with argument "foo"' do
- before do
- @filter = MessageFilter.new('foo')
- end
- subject { @filter }
+ subject { MessageFilter.new('foo') }
it {
is_expected.to be_detect('hello from foo')
}

実行してみましょう。

$ 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.00222 seconds (files took 0.21799 seconds to load)
2 examples, 0 failures
$ git commit -am 'Remove instance variable'
[1st c75a0bd] Remove instance variable
1 file changed, 1 insertion(+), 4 deletions(-)

Commit


簡潔さは力

かなりテストコードが簡潔になってました。コードをすっきりさせたいので it の改行を廃し、最終的にテストコードはこういう形になりました。重複が少なく、かつ情報量自体はかなり保たれています。

message_filter_spec.rb


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 { 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.00232 seconds (files took 0.22088 seconds to load)
2 examples, 0 failures
$ git commit -am 'Join lines'
[1st 6740238] Join lines
1 file changed, 2 insertions(+), 6 deletions(-)

Commit


第1イテレーション終了

さて、このイテレーションでは最終的にコードは以下のようになりました。

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

タグを打ってイテレーションの終了としましょう。

$ git tag -a -m 'end of 1st iteration' end_of_iter1

Tag


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

和田さんの記事と同様に、第2イテレーション第3イテレーションの記事についてもRSpec 3バージョンを作成しました。

続けて学習していきましょう!


あわせて読みたい

既存の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テスト入門」をアップデートしました