Ruby
RSpec

実用的な新機能が盛りだくさん!RSpec 3.3 完全ガイド

More than 3 years have passed since last update.


はじめに

2015年6月12日にRSpec 3.3がリリースされました。

APIが大きく変更されたり、派手な新機能が追加されたりはしていませんが、うまく活用するとテストを効率よく書いていけそうな実践的な新機能がたくさん導入されています。

この記事ではそんなRSpec 3.3の新機能を紹介していきます。


新機能一覧

RSpec 3.3で追加された主な新機能は以下の11個です。

これから各新機能の内容を紹介していきます。


  1. 特定のエクスペクテーション群をまとめて検証できる(aggregate_failures メソッド)

  2. グループやexampleをID指定して実行できる

  3. 失敗したテストだけを再実行できる(--only-failures オプション)

  4. 失敗したテストを1件ずつ修正できる(--next-failure オプション)

  5. テストが増減しても seed を指定したランダム実行が同じ順序で実行される

  6. 実行順序に依存するテストの最小セットを抽出できる(--bisect オプション)

  7. デフォルトでスレッドセーフになった let と subject

  8. 改善されたテスト失敗時の表示内容

  9. new をスタブ化するとき、引数を適切に扱っているかどうかを検証できる

  10. [rspec-rails] scaffold作成時のルーティングスペックでPATCHのテストが追加される

  11. [rspec-rails] スペックタイプとしてジョブスペック(job spec)が追加される


参考文献

この記事はRSpecの公式ブログの内容をベースにしています。

RSpec 3.3 has been released!

ただし、ブログの内容を丸写しするのではなく、自分で実際に動かしてみた結果とあわせて内容を再構成しています。


1. 特定のエクスペクテーション群をまとめて検証できる(aggregate_failures メソッド)

RSpec 3.3では特定のエクスペクテーション群をまとめて検証できます。

たとえば以下のようなテストがあったとします。


slow_spec.rb

require 'spec_helper'

describe 'Slow spec' do
let(:fruits) { %w(apple orange melon) }
before do
puts "Preparing..."
sleep 2
puts "OK."
end
it 'has 3 items' do
expect(fruits.size).to eq 2
end
it 'does not have duplicated items' do
expect(fruits.uniq).to contain_exactly(*fruits)
end
it 'contains apple' do
expect(fruits).to include 'tomato'
end
end


3つのexampleがありますが、そのうち2件はわざと失敗させています。

また、beforeブロックの中で sleep を実行し、毎回テストのセットアップに時間がかかることをシミュレートしています。

これを実行すると以下のようになります。

$ rspec spec/slow_spec.rb

Slow spec
Preparing...
OK.
has 3 items (FAILED - 1)
Preparing...
OK.
does not have duplicated items
Preparing...
OK.
contains apple (FAILED - 2)

Failures:

1) Slow spec has 3 items
Failure/Error: expect(fruits.size).to eq 2

expected: 2
got: 3

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

2) Slow spec contains apple
Failure/Error: expect(fruits).to include 'tomato'
expected ["apple", "orange", "melon"] to include "tomato"
# ./spec/slow_spec.rb:17:in `block (2 levels) in <top (required)>'

Finished in 6.03 seconds (files took 0.06566 seconds to load)
3 examples, 2 failures

Failed examples:

rspec ./spec/slow_spec.rb:10 # Slow spec has 3 items
rspec ./spec/slow_spec.rb:16 # Slow spec contains apple

beforeブロックが3回呼ばれるので、テストが完了するのに6秒以上かかってしまいました。

テストの実行速度を上げる方法の一つはexample(itやspecify)の件数を減らすことです。

たとえば以下のようにすればbeforeブロックは1回しか実行されません。


slow_without_aggregate_failures_spec.rb

require 'spec_helper'

describe 'Slow spec' do
let(:fruits) { %w(apple orange melon) }
before do
puts "Preparing..."
sleep 2
puts "OK."
end
it 'has valid items' do
expect(fruits.size).to eq 2
expect(fruits.uniq).to contain_exactly(*fruits)
expect(fruits).to include 'tomato'
end
end


$ rspec spec/slow_without_aggregate_failures_spec.rb 

Slow spec
Preparing...
OK.
has valid items (FAILED - 1)

Failures:

1) Slow spec has valid items
Failure/Error: expect(fruits.size).to eq 2

expected: 2
got: 3

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

Finished in 2.02 seconds (files took 0.0648 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/slow_without_aggregate_failures_spec.rb:10 # Slow spec has valid items

実行結果を見ればわかるとおり、beforeブロックは1回しか実行されず、テストの実行時間も2秒程度に収まりました。

ただし、メリットだけではなくデメリットも存在します。

この方式のデメリットは失敗する2件のエクスペクテーションのうち、1件しか失敗が報告されていない点です。

これだとどこまでテストを修正すれば全部パスするのかがわかりません。

(1つエラーを修正しては再実行、新たなエラーを修正しては再実行、を繰り返すことになってしまいます)

そこで登場するのが aggregate_failures メソッドです。

これを使うと失敗の有無にかかわらずブロックで囲まれたエクスペクテーションをすべて実行してくれます。


slow_with_aggregate_failures.rb

require 'spec_helper'

describe 'Slow spec' do
let(:fruits) { %w(apple orange melon) }
before do
puts "Preparing..."
sleep 2
puts "OK."
end
it 'has valid items' do
aggregate_failures 'testing items' do
expect(fruits.size).to eq 2
expect(fruits.uniq).to contain_exactly(*fruits)
expect(fruits).to include 'tomato'
end
end
end


$ rspec spec/slow_with_aggregate_failures_spec.rb 

Slow spec
Preparing...
OK.
has valid items (FAILED - 1)

Failures:

1) Slow spec has valid items
Got 2 failures from failure aggregation block "testing items".
# ./spec/slow_with_aggregate_failures_spec.rb:11:in `block (2 levels) in <top (required)>'

1.1) Failure/Error: expect(fruits.size).to eq 2

expected: 2
got: 3

(compared using ==)
# ./spec/slow_with_aggregate_failures_spec.rb:12:in `block (3 levels) in <top (required)>'

1.2) Failure/Error: expect(fruits).to include 'tomato'
expected ["apple", "orange", "melon"] to include "tomato"
# ./spec/slow_with_aggregate_failures_spec.rb:14:in `block (3 levels) in <top (required)>'

Finished in 2.02 seconds (files took 0.05756 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/slow_with_aggregate_failures_spec.rb:10 # Slow spec has valid items

ご覧のとおり、テストが失敗してもそこで終了せず、失敗するエクスペクテーションがすべてリストアップされています。

このように aggregate_failures メソッドを使うと共通セットアップの時間を短縮しつつ、エクスペクテーション全体の成功/失敗を確認することができます。


応用:aggregate_failures すべてのテストに適用する

spec_helper.rb に次のような設定を追加すると、すべてのテストで aggregate_failures が有効になります。


spec_helper.rb

RSpec.configure do |config|

config.define_derived_metadata do |meta|
meta[:aggregate_failures] = true unless meta.key?(:aggregate_failures)
end
end

aggregate_failures を無効にしたいテストに対しては aggregate_failures: false のタグを付けます。

it 'has valid items', aggregate_failures: false do

expect(fruits.size).to eq 2
expect(fruits.uniq).to contain_exactly(*fruits)
expect(fruits).to include 'tomato'
end


2. グループやexampleをID指定して実行できる

rspecの実行時にグループやexampleをID指定して実行できるようになりました。

たとえばこんなSpecがあったとします。


sample_rspec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
describe 'ここは[1:2]' do
it 'ここは[1:2:1]' do
expect(true).to be true
end
it 'ここは[1:2:2]' do
expect(true).to be true
end
end
end


[1:2][1:2:2] のように階層ごとに番号を指定して実行できます。

複数指定したい場合は [1:1,1:2:1] のようにカンマ区切りにします。

$ rspec 'spec/sample_spec.rb[1:2]'  

Run options: include {:ids=>{"./spec/sample_spec.rb"=>["1:2"]}}

ここは[1]
ここは[1:2]
ここは[1:2:1]
ここは[1:2:2]

Finished in 0.00095 seconds (files took 0.05959 seconds to load)
2 examples, 0 failures

$ rspec 'spec/sample_spec.rb[1:2:2]'
Run options: include {:ids=>{"./spec/sample_spec.rb"=>["1:2:2"]}}

ここは[1]
ここは[1:2]
ここは[1:2:2]

Finished in 0.00087 seconds (files took 0.06188 seconds to load)
1 example, 0 failures

$ rspec 'spec/sample_spec.rb[1:1,1:2:1]'
Run options: include {:ids=>{"./spec/sample_spec.rb"=>["1:1", "1:2:1"]}}

ここは[1]
ここは[1:1]
ここは[1:2]
ここは[1:2:1]

Finished in 0.0009 seconds (files took 0.05728 seconds to load)
2 examples, 0 failures

実際はこの機能を直接使う機会はあまりなく、これから紹介する --only-failures オプションや --next-failure オプションで内部的に活用される機能と考えた方がいいでしょう。


3. 失敗したテストだけを再実行できる(--only-failures オプション)

RSpec 3.3では失敗したテストだけを再実行できます。

まず、spec_helper.rb に次のような設定を入れます。


spec_helper.rb

RSpec.configure do |config|

config.example_status_persistence_file_path = "./spec/examples.txt"
end

gitを使っている場合は .gitignore に以下の設定も追記します。

spec/examples.txt

わざとテストを失敗させてみましょう。


sample_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
describe 'ここは[1:2]' do
it 'ここは[1:2:1]' do
expect(true).to be true
end
it 'ここは[1:2:2]' do
fail 'わざと失敗させる'
expect(true).to be true
end
end
end


テストを実行します。

$ rspec

ここは[1]
ここは[1:1]
ここは[1:2]
ここは[1:2:1]
ここは[1:2:2] (FAILED - 1)

Failures:

1) ここは[1] ここは[1:2] ここは[1:2:2]
Failure/Error: fail 'わざと失敗させる'
RuntimeError:
わざと失敗させる
# ./spec/sample_spec.rb:12:in `block (3 levels) in <top (required)>'

Finished in 0.00108 seconds (files took 0.05769 seconds to load)
3 examples, 1 failure

Failed examples:

rspec ./spec/sample_spec.rb:11 # ここは[1] ここは[1:2] ここは[1:2:2]

spec/examples.txt の中身を覗いてみましょう。

$ cat ./spec/example.txt

example_id | status | run_time |
---------------------------- | ------ | --------------- |
./spec/sample_spec.rb[1:1] | passed | 0.00048 seconds |
./spec/sample_spec.rb[1:2:1] | passed | 0.00006 seconds |
./spec/sample_spec.rb[1:2:2] | failed | 0.00008 seconds |

最後の行だけ status が failed になっています。

--only-failures オプションを付けて実行します。

$ rspec --only-failures

Run options: include {:last_run_status=>"failed"}

ここは[1]
ここは[1:2]
ここは[1:2:2] (FAILED - 1)

Failures:

1) ここは[1] ここは[1:2] ここは[1:2:2]
Failure/Error: fail 'わざと失敗させる'
RuntimeError:
わざと失敗させる
# ./spec/sample_spec.rb:12:in `block (3 levels) in <top (required)>'

Finished in 0.00056 seconds (files took 0.06115 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/sample_spec.rb:11 # ここは[1] ここは[1:2] ここは[1:2:2]

実行結果が 1 example, 1 failure となっている点に注目してください。失敗したテストだけが実行されています。

次にテストコードを元に戻します。


sample_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
describe 'ここは[1:2]' do
it 'ここは[1:2:1]' do
expect(true).to be true
end
it 'ここは[1:2:2]' do
expect(true).to be true
end
end
end


もう一度 --only-failures オプションを付けて実行します。

$ rspec --only-failures

Run options: include {:last_run_status=>"failed"}

ここは[1]
ここは[1:2]
ここは[1:2:2]

Finished in 0.00081 seconds (files took 0.06441 seconds to load)
1 example, 0 failures

さっき失敗したテストがパスしました。

examples.txt の中身も更新されています。

$ cat ./spec/examples.txt                       

example_id | status | run_time |
---------------------------- | ------ | --------------- |
./spec/sample_spec.rb[1:1] | passed | 0.00048 seconds |
./spec/sample_spec.rb[1:2:1] | passed | 0.00006 seconds |
./spec/sample_spec.rb[1:2:2] | passed | 0.00046 seconds |

再度 --only-failures オプションを付けると失敗しているテストはないので1件も実行されません。

$ rspec --only-failures

Run options: include {:last_run_status=>"failed"}

All examples were filtered out

Finished in 0.00013 seconds (files took 0.06193 seconds to load)
0 examples, 0 failures


注意点

ご覧の通り、RSpecはあくまで examples.txt の中身を参考にして実行するテストを決めています。

思ったようにテストが絞り込まれない場合は examples.txt の内容を確認してください。

また、新しいテストを途中で挟み込んだりすると階層の順番が変わってしまうので、思った通りにテストを絞り込めなくなってしまいます。


sample_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
describe 'わざと階層の順番を変える' do
end
describe 'わざと階層の順番を変える' do
end
describe 'わざと階層の順番を変える' do
end
describe 'わざと階層の順番を変える' do
end
# ここから下はもう [1:2] ではない
describe 'ここは[1:2]' do
it 'ここは[1:2:1]' do
expect(true).to be true
end
it 'ここは[1:2:2]' do
expect(true).to be true
end
end
end


--only-failures オプションを付ける場合はこのあたりのクセを理解しておく方が良いと思います。


4. 失敗したテストを1件ずつ修正できる(--next-failure オプション)

RSpec 3.3では失敗したテストを1件ずつ修正するのに便利な機能が追加されています。

まず、spec_helper.rb に次のような設定を入れます。(まだ設定していない場合)


spec_helper.rb

RSpec.configure do |config|

config.example_status_persistence_file_path = "./spec/examples.txt"
end

次に、わざと2箇所でテストを失敗させるテストを作ります。


sample_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
fail 'わざと失敗させる'
expect(true).to be true
end
describe 'ここは[1:2]' do
it 'ここは[1:2:1]' do
fail 'わざと失敗させる'
expect(true).to be true
end
it 'ここは[1:2:2]' do
expect(true).to be true
end
end
end


普通にテストを実行します。

$ rspec

ここは[1]
ここは[1:1] (FAILED - 1)
ここは[1:2]
ここは[1:2:1] (FAILED - 2)
ここは[1:2:2]

Failures:

1) ここは[1] ここは[1:1]
Failure/Error: fail 'わざと失敗させる'
RuntimeError:
わざと失敗させる
# ./spec/sample_spec.rb:5:in `block (2 levels) in <top (required)>'

2) ここは[1] ここは[1:2] ここは[1:2:1]
Failure/Error: fail 'わざと失敗させる'
RuntimeError:
わざと失敗させる
# ./spec/sample_spec.rb:10:in `block (3 levels) in <top (required)>'

Finished in 0.00113 seconds (files took 0.0583 seconds to load)
3 examples, 2 failures

Failed examples:

rspec ./spec/sample_spec.rb:4 # ここは[1] ここは[1:1]
rspec ./spec/sample_spec.rb:9 # ここは[1] ここは[1:2] ここは[1:2:1]

つづいて、--next-failure オプションを付けて実行します。

$ rspec --next-failure

Run options: include {:last_run_status=>"failed"}

ここは[1]
ここは[1:1] (FAILED - 1)

Failures:

1) ここは[1] ここは[1:1]
Failure/Error: fail 'わざと失敗させる'
RuntimeError:
わざと失敗させる
# ./spec/sample_spec.rb:5:in `block (2 levels) in <top (required)>'

Finished in 0.00043 seconds (files took 0.05768 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/sample_spec.rb:4 # ここは[1] ここは[1:1]

さっき失敗した2件のテストのうち、1件だけが実行されます。(成功したテストも実行されません)

最初の1件を修正します。


sample_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
describe 'ここは[1:2]' do
it 'ここは[1:2:1]' do
fail 'わざと失敗させる'
expect(true).to be true
end
it 'ここは[1:2:2]' do
expect(true).to be true
end
end
end


もう一度 --next-failure オプションを付けて実行します。

$ rspec --next-failure

Run options: include {:last_run_status=>"failed"}

ここは[1]
ここは[1:1]
ここは[1:2]
ここは[1:2:1] (FAILED - 1)

Failures:

1) ここは[1] ここは[1:2] ここは[1:2:1]
Failure/Error: fail 'わざと失敗させる'
RuntimeError:
わざと失敗させる
# ./spec/sample_spec.rb:9:in `block (3 levels) in <top (required)>'

Finished in 0.00097 seconds (files took 0.05847 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./spec/sample_spec.rb:8 # ここは[1] ここは[1:2] ここは[1:2:1]

最初に失敗した2件のテストのうち、最初の1件がパスしたので、2件目のテストが実行されます。

2件目のテストも修正しましょう。


sample_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
describe 'ここは[1:2]' do
it 'ここは[1:2:1]' do
expect(true).to be true
end
it 'ここは[1:2:2]' do
expect(true).to be true
end
end
end


もう一度実行します。

$ rspec --next-failure

Run options: include {:last_run_status=>"failed"}

ここは[1]
ここは[1:2]
ここは[1:2:1]

Finished in 0.00079 seconds (files took 0.06857 seconds to load)
1 example, 0 failures

これですべてのテストがパスしました。

すべてのテストがパスすると --next-failure オプションを付けても何も実行されません。

$ rspec --next-failure

Run options: include {:last_run_status=>"failed"}

All examples were filtered out

Finished in 0.00018 seconds (files took 0.05889 seconds to load)
0 examples, 0 failures


注意点

注意点は --only-failures オプションの場合と同じです。

実行するテストの取捨選択は examples.txt の内容に依存している点に注意してください。

なお、 --next-failure オプションを付けると --only-failures --fail-fast --order を指定したのと同じことになるそうです。


5. テストが増減しても seed を指定したランダム実行が同じ順序で実行される

RSpec 3.3 からはランダム実行のアルゴリズムが変更されています。

新しいアルゴリズムではexampleのIDに基づいてシャッフルするため、IDが同じであればテストが増減しても相対的な実行順序が変わりません。

具体例を見てみましょう。

以下のようなテストがあったとします。


random_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
it 'ここは[1:2]' do
expect(true).to be true
end
it 'ここは[1:3]' do
expect(true).to be true
end
it 'ここは[1:4]' do
expect(true).to be true
end
it 'ここは[1:5]' do
expect(true).to be true
end
end


ランダム実行を有効にするために spec_helper.rb に以下の設定を入れます。(実行時に --order random オプションを付けるのもOKです)


spec_helper.rb

RSpec.configure do |config|

config.order = :random
end

テストを実行するとランダムに実行されます。

$ rspec

Randomized with seed 20966

ここは[1]
ここは[1:5]
ここは[1:1]
ここは[1:4]
ここは[1:2]
ここは[1:3]

Finished in 0.00097 seconds (files took 0.05999 seconds to load)
5 examples, 0 failures

Randomized with seed 20966

実行結果に表示されている seed 値をオプションで渡すとその実行順序が再現できます。

$ rspec --seed 20966

Randomized with seed 20966

ここは[1]
ここは[1:5]
ここは[1:1]
ここは[1:4]
ここは[1:2]
ここは[1:3]

Finished in 0.00112 seconds (files took 0.06497 seconds to load)
5 examples, 0 failures

Randomized with seed 20966

次に、新しいテスト( [1:6] )を追加します。


random_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
it 'ここは[1:2]' do
expect(true).to be true
end
it 'ここは[1:3]' do
expect(true).to be true
end
it 'ここは[1:4]' do
expect(true).to be true
end
it 'ここは[1:5]' do
expect(true).to be true
end
it 'ここは[1:6]' do
expect(true).to be true
end
end


以前のRSpecだと、同じseedを渡しても先ほどの実行結果と全く無関係な順序でテストが実行されます。

rspec --seed 20966

Randomized with seed 20966

ここは[1]
ここは[1:1]
ここは[1:2]
ここは[1:5]
ここは[1:6]
ここは[1:4]
ここは[1:3]

Finished in 0.00111 seconds (files took 0.08314 seconds to load)
6 examples, 0 failures

Randomized with seed 20966

しかし、RSpec 3.3であれば相対的な順序が保持されます。

rspec --seed 20966

Randomized with seed 20966

ここは[1]
ここは[1:5]
ここは[1:1]
ここは[1:6]
ここは[1:4]
ここは[1:2]
ここは[1:3]

Finished in 0.00111 seconds (files took 0.08314 seconds to load)
6 examples, 0 failures

Randomized with seed 20966

ぱっと見ただけでは何が改善されたのかわかりにくいですね。

[1-6] を追加する前は 5-1-4-2-3 の順番でテストが実行されていました。

[1-6] を追加した後は 5-1-6-4-2-3 の順番で実行されています。

つまり、6 が間に挟まれたこと以外は同じ順番が保たれています。

テストを減らした場合も同様です。

[1-5] を削除してから、RSpec 3.3で実行してみます。


random_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
it 'ここは[1:1]' do
expect(true).to be true
end
it 'ここは[1:2]' do
expect(true).to be true
end
it 'ここは[1:3]' do
expect(true).to be true
end
it 'ここは[1:4]' do
expect(true).to be true
end
end


rspec --seed 20966

Randomized with seed 20966

ここは[1]
ここは[1:1]
ここは[1:4]
ここは[1:2]
ここは[1:3]

Finished in 0.00111 seconds (files took 0.08314 seconds to load)
4 examples, 0 failures

Randomized with seed 20966

5-1-4-2-3 が 1-4-2-3 の順番で実行されました。

5 がなくなったこと以外は順番が変わっていません。


「この機能の何がうれしいの?」

まず、テストはまれに実行される順序によってパスしたり失敗したりする場合があります。

実行の順序に依存するテストや実装は望ましくありません。

なので、テストをランダム実行して「順番に依存する何か」をあぶりだすことは有効な手法です。

さらに、テストが順番に依存していることがわかったら、その問題を修正する必要があります。

その場合は問題が修正されるまで毎回同じ順番で実行できた方がデバッグがしやすくなります。

なので、seed 値を指定することで毎回実行順序が同じにできた方が便利になります。

言葉だけではわかりづらいかもしれないので、次の項(--bisectオプションの説明)でその具体例をお見せします。


注意:IDが変わると順番も変わります

公式ブログによると、この機能はテストのID("1:1"や"1:2:1"のような番号)とseed値を組み合わせてハッシュ化し、それをソートしているそうです。

なので、末尾のテストが増減する場合は順番が保持されますが、末尾以外のテストが増減すると、順番は保持されなくなります。

たとえば次のようにわざと階層を変えるような変更を入れてみます。


random_spec.rb

require 'spec_helper'

describe 'ここは[1]' do
describe 'わざと階層の順番を変える' do
end
# ここから下は階層がひとつずつズレる
it 'ここは[1:1]' do
expect(true).to be true
end
it 'ここは[1:2]' do
expect(true).to be true
end
it 'ここは[1:3]' do
expect(true).to be true
end
it 'ここは[1:4]' do
expect(true).to be true
end
it 'ここは[1:5]' do
expect(true).to be true
end
end


この状態でテストを実行します。

rspec --seed 20966

Randomized with seed 20966

ここは[1]
ここは[1:4]
ここは[1:5]
ここは[1:3]
ここは[1:1]
ここは[1:2]

Finished in 0.00111 seconds (files took 0.08314 seconds to load)
5 examples, 0 failures

Randomized with seed 20966

ご覧の通り、5-1-4-2-3 が 4-5-3-1-2 に変わってしまいました。

厳密に言えば、この場合は「2=>1、3=>2、4=>3、5=>4」というように規則的に順番が入れ替わっていますが、そのこと自体はテストとして意味がありません。


6. 実行順序に依存するテストの最小セットを抽出できる(--bisect オプション)

RSpec 3.3では実行順序に依存するテストの最小セットを抽出できます。

これにより、不具合を修正するためのテストの再実行時間を短縮することができます。

具体的な例を見てみましょう。

以下のような Calculator クラスがあったとします。


calculator.rb

class Calculator

def self.add(x, y)
x + y
end
end

あまり実践的な例ではありませんが、以下のようなテストを9本追加します。

calculator_1_spec.rbcalculator_9_spec.rb


calculator_1_spec.rb

require 'spec_helper'

RSpec.describe "Calculator" do
it 'adds numbers' do
expect(Calculator.add(1, 2)).to eq(3)
end
end


10本目のテストはわざとモンキーパッチを当てて、 add メソッドの振る舞いを変更します。


calculator_10_spec.rb

require 'spec_helper'

RSpec.describe "Monkey patched Calculator" do
it 'does screwy math' do
# monkey patching Calculator affects examples that are
# executed after this one!
def Calculator.add(x, y)
x - y
end

expect(Calculator.add(5, 10)).to eq(-5)
end
end


これをランダム実行させるとテストが失敗します。

なぜならcalculator_10_spec.rbが実行されると add メソッドの振る舞いが変わってしまうからです。

$ rspec             

Randomized with seed 57110

Calculator
adds numbers

Calculator
adds numbers

Calculator
adds numbers

Calculator
adds numbers

Calculator
adds numbers

Monkey patched Calculator
does screwy math

Calculator
adds numbers (FAILED - 1)

Calculator
adds numbers (FAILED - 2)

Calculator
adds numbers (FAILED - 3)

Calculator
does screwy math

Failures:

1) Calculator adds numbers
Failure/Error: expect(Calculator.add(1, 2)).to eq(3)

expected: 3
got: -1

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

2) Calculator adds numbers
Failure/Error: expect(Calculator.add(1, 2)).to eq(3)

expected: 3
got: -1

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

3) Calculator adds numbers
Failure/Error: expect(Calculator.add(1, 2)).to eq(3)

expected: 3
got: -1

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

Finished in 0.01117 seconds (files took 0.07208 seconds to load)
10 examples, 3 failures

Failed examples:

rspec ./spec/calculator_7_spec.rb:4 # Calculator adds numbers
rspec ./spec/calculator_4_spec.rb:4 # Calculator adds numbers
rspec ./spec/calculator_3_spec.rb:4 # Calculator adds numbers

Randomized with seed 57110

なお、ランダム実行するためには設定の変更が必要です。


spec_helper.rb

RSpec.configure do |config|

config.order = :random
end

seedを指定すれば先ほどと全く同じようにテストを失敗させることができます。

$ rspec --seed 57110

Randomized with seed 57110

(省略)

Finished in 0.01097 seconds (files took 0.06275 seconds to load)
10 examples, 3 failures

Failed examples:

rspec ./spec/calculator_7_spec.rb:4 # Calculator adds numbers
rspec ./spec/calculator_4_spec.rb:4 # Calculator adds numbers
rspec ./spec/calculator_3_spec.rb:4 # Calculator adds numbers

Randomized with seed 57110

しかし、このままだと毎回10本のテストを実行しなければなりません。

もし実行に時間がかかるテストが多く含まれていると、デバッグのたびにすべてのテストが終わるのを待たなければなりません。

そこで --bisect (「バイセクト」と読みます)オプションを使います。

--bisect オプションを付けて実行してみましょう。

$ rspec --seed 57110 --bisect

Bisect started using options: "--seed 57110"
Running suite to find failures... (0.47062 seconds)
Starting bisect with 3 failing examples and 7 non-failing examples.

Round 1: searching for 4 non-failing examples (of 7) to ignore: . (0.46175 seconds)
Round 2: searching for 2 non-failing examples (of 3) to ignore: .. (0.90436 seconds)
Round 3: searching for 1 non-failing example (of 2) to ignore: . (0.45164 seconds)
Round 4: searching for 1 non-failing example (of 1) to ignore: . (0.45217 seconds)
Bisect complete! Reduced necessary non-failing examples from 7 to 1 in 2.27 seconds.

The minimal reproduction command is:
rspec './spec/calculator_10_spec.rb[1:1]' './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' --seed 57110

一番最後に rspec './spec/calculator_10_spec.rb[1:1]' './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' --seed 57110 というコマンドが出てきています。

これが先ほどのテストの失敗を再現させる必要最小限のテストケースの組み合わせです。

実際に実行してみましょう。

$ rspec './spec/calculator_10_spec.rb[1:1]' './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' --seed 57110

Run options: include {:ids=>{"./spec/calculator_10_spec.rb"=>["1:1"], "./spec/calculator_3_spec.rb"=>["1:1"], "./spec/calculator_4_spec.rb"=>["1:1"], "./spec/calculator_7_spec.rb"=>["1:1"]}}

Randomized with seed 57110

Monkey patched Calculator
does screwy math

Calculator
adds numbers (FAILED - 1)

Calculator
adds numbers (FAILED - 2)

Calculator
adds numbers (FAILED - 3)

Failures:

1) Calculator adds numbers
Failure/Error: expect(Calculator.add(1, 2)).to eq(3)

expected: 3
got: -1

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

2) Calculator adds numbers
Failure/Error: expect(Calculator.add(1, 2)).to eq(3)

expected: 3
got: -1

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

3) Calculator adds numbers
Failure/Error: expect(Calculator.add(1, 2)).to eq(3)

expected: 3
got: -1

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

Finished in 0.00946 seconds (files took 0.05858 seconds to load)
4 examples, 3 failures

Failed examples:

rspec ./spec/calculator_7_spec.rb:4 # Calculator adds numbers
rspec ./spec/calculator_4_spec.rb:4 # Calculator adds numbers
rspec ./spec/calculator_3_spec.rb:4 # Calculator adds numbers

Randomized with seed 57110

実行したテストケースは10本から4本に減っています。(4 examples, 3 failures になっています)

一方、失敗したテストは7番, 4番, 3番の3つで、10本全部を実行したときと変わっていません。

これでデバッグのためのテスト実行時間を短くすることができるようになります。

なお、 --bisect=verbose というオプションを付けると、対象のテストを抽出する過程が詳細に表示されます。

rspec --seed 57110 --bisect=verbose

Bisect started using options: "--seed 57110"
Running suite to find failures... (0.46465 seconds)
- Failing examples (3):
- './spec/calculator_3_spec.rb[1:1]'
- './spec/calculator_4_spec.rb[1:1]'
- './spec/calculator_7_spec.rb[1:1]'
- Non-failing examples (7):
- './spec/calculator_10_spec.rb[1:1]'
- './spec/calculator_1_spec.rb[1:1]'
- './spec/calculator_2_spec.rb[1:1]'
- './spec/calculator_5_spec.rb[1:1]'
- './spec/calculator_6_spec.rb[1:1]'
- './spec/calculator_8_spec.rb[1:1]'
- './spec/calculator_9_spec.rb[1:1]'

Round 1: searching for 4 non-failing examples (of 7) to ignore:
- Running: rspec './spec/calculator_10_spec.rb[1:1]' './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_5_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' './spec/calculator_9_spec.rb[1:1]' --seed 57110 (0.46574 seconds)
- Examples we can safely ignore (4):
- './spec/calculator_1_spec.rb[1:1]'
- './spec/calculator_2_spec.rb[1:1]'
- './spec/calculator_6_spec.rb[1:1]'
- './spec/calculator_8_spec.rb[1:1]'
- Remaining non-failing examples (3):
- './spec/calculator_10_spec.rb[1:1]'
- './spec/calculator_5_spec.rb[1:1]'
- './spec/calculator_9_spec.rb[1:1]'
- Round finished (0.46642 seconds)
Round 2: searching for 2 non-failing examples (of 3) to ignore:
- Running: rspec './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_5_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' --seed 57110 (0.47675 seconds)
- Running: rspec './spec/calculator_10_spec.rb[1:1]' './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' './spec/calculator_9_spec.rb[1:1]' --seed 57110 (0.51212 seconds)
- Examples we can safely ignore (1):
- './spec/calculator_5_spec.rb[1:1]'
- Remaining non-failing examples (2):
- './spec/calculator_10_spec.rb[1:1]'
- './spec/calculator_9_spec.rb[1:1]'
- Round finished (0.98965 seconds)
Round 3: searching for 1 non-failing example (of 2) to ignore:
- Running: rspec './spec/calculator_10_spec.rb[1:1]' './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' --seed 57110 (0.46662 seconds)
- Examples we can safely ignore (1):
- './spec/calculator_9_spec.rb[1:1]'
- Remaining non-failing examples (1):
- './spec/calculator_10_spec.rb[1:1]'
- Round finished (0.4671 seconds)
Round 4: searching for 1 non-failing example (of 1) to ignore:
- Running: rspec './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' --seed 57110 (0.45621 seconds)
- Round finished (0.45653 seconds)
Bisect complete! Reduced necessary non-failing examples from 7 to 1 in 2.38 seconds.

The minimal reproduction command is:
rspec './spec/calculator_10_spec.rb[1:1]' './spec/calculator_3_spec.rb[1:1]' './spec/calculator_4_spec.rb[1:1]' './spec/calculator_7_spec.rb[1:1]' --seed 57110


7. デフォルトでスレッドセーフになった let と subject

RSpec 3.3 からは letsubject がデフォルトでスレッドセーフになっています。

これにより以下のようなテストを書いた場合に必ずテストがパスするようになります。


counter_spec.rb

class Counter

def initialize
@mutex = Mutex.new
@count = 0
end

attr_reader :count

def increment
@mutex.synchronize { @count += 1 }
end
end

RSpec.describe Counter do
let(:counter) { Counter.new }

it 'increments the count in a threadsafe manner' do
threads = 10.times.map do
Thread.new { 1000.times { counter.increment } }
end
threads.each &:join
expect(counter.count).to eq 10_000
end
end



スレッドセーフ化の恩恵を受ける実行環境について

環境によっては従来のRSpecでも上のテストは毎回パスするようです。

こちらのプルリクエストによると以前のバージョンで問題が発生するのはRubiniusやJRubyなど、「本当にスレッド化された実行環境(truly threaded environment)」で実行した場合に限定されるそうです。

こちらで試したところ、JRuby + RSpec 3.2の環境では確かに以下のようなエラーが発生しました。(5回に1回程度失敗します)

$ rspec

Counter
increments the count in a threadsafe manner (FAILED - 1)

Failures:

1) Counter increments the count in a threadsafe manner
Failure/Error: expect(counter.count).to eq 10_000

expected: 10000
got: 9999

(compared using ==)
# ./spec/thread_safe_spec.rb:22:in `(root)'

Finished in 0.085 seconds (files took 0.293 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/thread_safe_spec.rb:17 # Counter increments the count in a threadsafe manner

もちろん、RSpec 3.3にバージョンアップするとこのエラーは発生しなくなりました。


スレッドセーフ機能をあえて無効にする設定

スレッドセーフ化によるパフォーマンスの悪化が気になる場合はスレッドセーフ機能をオフにすることもできます。


spec_helper.rb

RSpec.configure do |config|

config.threadsafe = false
end


8. 改善されたテスト失敗時の表示内容

RSpec 3.3ではテスト失敗時の表示内容が全体的に改善されています。

たとえば日時の比較で失敗がした場合、以前は秒以下が表示されておらず、二つの値の違いがわかりませんでした。

Failure/Error: expect([Time.now]).to include(Time.now)

expected [2015-06-09 07:48:06 -0700] to include 2015-06-09 07:48:06 -0700

RSpec 3.3では次のように秒以下まで表示されるため、細かい差異まで把握できます。

Failure/Error: expect([Time.now]).to include(Time.now)

expected [2015-06-09 07:49:16.610635000 -0700] to include 2015-06-09 07:49:16.610644000 -0700
Diff:
@@ -1,2 +1,2 @@
-[2015-06-09 07:49:16.610644000 -0700]
+[2015-06-09 07:49:16.610635000 -0700]

モックを使ったテストで失敗した場合も表示内容が改善されています。

Failure/Error: expect(dbl).to have_received(:foo).with(3)

#<Double (anonymous)> received :foo with unexpected arguments
expected: (3)
got: (1) (2 times)
(2) (1 time)


9. new メソッドをスタブ化するとき、引数を適切に扱っているかどうかを検証できる

RSpec 3.3では new メソッドをスタブ化するとき、引数を適切に扱っているかどうかを検証できます。

たとえば、以下のようなクラスがあったとします。


foo.rb

class Foo

def initialize(a, b)
end
end

以前のバージョンでは以下のようなテストはパスしていました。


foo_spec.rb

describe Foo do

it 'stubs new method' do
expect(Foo).to receive(:new).with(1)
Foo.new(1)
end
end

このテストの問題は initialize が引数を2つ受け取るようになっているのにもかかわらず、スタブが引数を1つしか受け取らないようにクラスのAPIを変更してしまっている点です。

RSpec 3.3では new をスタブ化する際に引数の形式を検証するため、上記のようなテストを実行したときにエラーが発生します。

rspec spec/mock_new_spec.rb 

Foo
stubs new method (FAILED - 1)

Failures:

1) Foo stubs new method
Failure/Error: expect(Foo).to receive(:new).with(1)
Wrong number of arguments. Expected 2, got 1.
# ./spec/mock_new_spec.rb:10:in `block (2 levels) in <top (required)>'

Finished in 0.00459 seconds (files took 0.05998 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/mock_new_spec.rb:9 # Foo stubs new method


10. [rspec-rails] scaffold作成時のルーティングスペックでPATCHのテストが追加される

rspec-rails 3.3 ではscaffold作成時のルーティングスペックでPATCHのテストが追加されています。


spec/routing/blogs_routing_spec.rb

require "rails_helper"

RSpec.describe BlogsController, type: :routing do
describe "routing" do
# ...

it "routes to #update via PATCH" do
expect(:patch => "/blogs/1").to route_to("blogs#update", :id => "1")
end

# ...
end
end



11. [rspec-rails] スペックタイプとしてジョブスペック(job spec)が追加される

rspec-rails 3.3 ではモデルスペックやコントローラスペックといったスペックのタイプとして、新たにジョブスペックが追加されました。

このスペックは ActiveJob をテストするために使用されるスペックです。

describe に type: :job のタグを追加するか、 infer_spec_type_from_file_location! オプションを設定した上で spec/jobs ディレクトリにテストを配置すると、ジョブスペックとして認識されます。

ただし、プルリクエストを見る限り、ジョブスペックとして扱われても特殊な機能が追加されたりはしないようです。


まとめ

というわけでこの記事ではRSpec 3.3で追加された主な新機能を一通り紹介してみました。

aggregate_failures メソッドや --only-failures オプションをうまく活用するとテスト実行時のデバッグ効率がアップしそうな気がしますね。

みなさんもぜひ使ってみてください!


あわせて読みたい

RSpec 3.0-3.2 の新機能についてはこちらの記事をご覧ください。

そもそもRSpecがよくわかっていないのでゼロから勉強したい、という方はこちらの記事をどうぞ。

最後に、web記事ではなく技術書を読んでRSpecやテストの書き方をしっかり学びたいという方には Everyday Rails をオススメします!