はじめに
2015年6月12日にRSpec 3.3がリリースされました。
APIが大きく変更されたり、派手な新機能が追加されたりはしていませんが、うまく活用するとテストを効率よく書いていけそうな実践的な新機能がたくさん導入されています。
この記事ではそんなRSpec 3.3の新機能を紹介していきます。
新機能一覧
RSpec 3.3で追加された主な新機能は以下の11個です。
これから各新機能の内容を紹介していきます。
- 特定のエクスペクテーション群をまとめて検証できる(aggregate_failures メソッド)
- グループやexampleをID指定して実行できる
- 失敗したテストだけを再実行できる(--only-failures オプション)
- 失敗したテストを1件ずつ修正できる(--next-failure オプション)
- テストが増減しても seed を指定したランダム実行が同じ順序で実行される
- 実行順序に依存するテストの最小セットを抽出できる(--bisect オプション)
- デフォルトでスレッドセーフになった let と subject
- 改善されたテスト失敗時の表示内容
- new をスタブ化するとき、引数を適切に扱っているかどうかを検証できる
- [rspec-rails] scaffold作成時のルーティングスペックでPATCHのテストが追加される
- [rspec-rails] スペックタイプとしてジョブスペック(job spec)が追加される
参考文献
この記事はRSpecの公式ブログの内容をベースにしています。
ただし、ブログの内容を丸写しするのではなく、自分で実際に動かしてみた結果とあわせて内容を再構成しています。
1. 特定のエクスペクテーション群をまとめて検証できる(aggregate_failures メソッド)
RSpec 3.3では特定のエクスペクテーション群をまとめて検証できます。
たとえば以下のようなテストがあったとします。
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回しか実行されません。
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
メソッドです。
これを使うと失敗の有無にかかわらずブロックで囲まれたエクスペクテーションをすべて実行してくれます。
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
が有効になります。
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があったとします。
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
に次のような設定を入れます。
RSpec.configure do |config|
config.example_status_persistence_file_path = "./spec/examples.txt"
end
gitを使っている場合は .gitignore
に以下の設定も追記します。
spec/examples.txt
わざとテストを失敗させてみましょう。
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
となっている点に注目してください。失敗したテストだけが実行されています。
次にテストコードを元に戻します。
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
の内容を確認してください。
また、新しいテストを途中で挟み込んだりすると階層の順番が変わってしまうので、思った通りにテストを絞り込めなくなってしまいます。
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
に次のような設定を入れます。(まだ設定していない場合)
RSpec.configure do |config|
config.example_status_persistence_file_path = "./spec/examples.txt"
end
次に、わざと2箇所でテストを失敗させるテストを作ります。
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件を修正します。
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件目のテストも修正しましょう。
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が同じであればテストが増減しても相対的な実行順序が変わりません。
具体例を見てみましょう。
以下のようなテストがあったとします。
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です)
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] )を追加します。
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で実行してみます。
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値を組み合わせてハッシュ化し、それをソートしているそうです。
なので、末尾のテストが増減する場合は順番が保持されますが、末尾以外のテストが増減すると、順番は保持されなくなります。
たとえば次のようにわざと階層を変えるような変更を入れてみます。
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 クラスがあったとします。
class Calculator
def self.add(x, y)
x + y
end
end
あまり実践的な例ではありませんが、以下のようなテストを9本追加します。
(calculator_1_spec.rb
~calculator_9_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
メソッドの振る舞いを変更します。
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
なお、ランダム実行するためには設定の変更が必要です。
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 からは let
と subject
がデフォルトでスレッドセーフになっています。
これにより以下のようなテストを書いた場合に必ずテストがパスするようになります。
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にバージョンアップするとこのエラーは発生しなくなりました。
スレッドセーフ機能をあえて無効にする設定
スレッドセーフ化によるパフォーマンスの悪化が気になる場合はスレッドセーフ機能をオフにすることもできます。
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
メソッドをスタブ化するとき、引数を適切に扱っているかどうかを検証できます。
たとえば、以下のようなクラスがあったとします。
class Foo
def initialize(a, b)
end
end
以前のバージョンでは以下のようなテストはパスしていました。
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のテストが追加されています。
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がよくわかっていないのでゼロから勉強したい、という方はこちらの記事をどうぞ。
- 使えるRSpec入門・その1「RSpecの基本的な構文や便利な機能を理解する」 - Qiita
- 使えるRSpec入門・その2「使用頻度の高いマッチャを使いこなす」 - Qiita
- 使えるRSpec入門・その3「ゼロからわかるモック(mock)を使ったテストの書き方」 - Qiita
- 使えるRSpec入門・その4「どんなブラウザ操作も自由自在!逆引きCapybara大辞典」 - Qiita
最後に、web記事ではなく技術書を読んでRSpecやテストの書き方をしっかり学びたいという方には Everyday Rails をオススメします!