Edited at

【RSpec】describeは名前空間ではないのでスコープが漏れるという話

More than 1 year has passed since last update.

弊社の一部で「単体だと通るけど、ディレクトリ単位で実行するとコケるSpecがある」と話題になったので書きます。


tl;dr


  • describeは名前空間ではないので定数はスコープが漏れますよ

  • letでClass作りましょうね


moduleのSpecを書くときに、なんらかのクラスにmixinをさせてテストをしたいという状況に起こりそうなやつです。


ダメな例


sample_spec.rb

module Test1

def hoge
'hoge1'
end
end

module Test2
def hoge
'hoge2'
end
end

describe Test1 do
class Target
include Test1
end
let(:target) { Target.new }
subject { target.hoge }
it { is_expected.to eq 'hoge1' }
end

describe Test2 do
class Target
include Test2
end
let(:target) { Target.new }
subject { target.hoge }
it { is_expected.to eq 'hoge2' }
end



sample_spec.rb実行結果

$ rspec sample_spec.rb

F.

Failures:

1) Test1 should eq "hoge1"
Failure/Error: it { is_expected.to eq 'hoge1' }

expected: "hoge1"
got: "hoge2"

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

Finished in 0.01866 seconds (files took 0.118 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./sample_spec.rb:19 # Test1 should eq "hoge1"



解説

Rubyではメソッド名がかぶった場合に後から定義されたほうが採用される後勝ちルールが使われます。


last_victory.rb(後勝ちルール検証)

def hoge

'hoge1'
end

def hoge
'hoge2'
end

puts hoge # hoge2


mixinの場合も同様で、メソッド名がかぶった場合は後からincludeした方のメソッドで上書きされます。

メソッド名がかぶるのは困るのでmodule Hogeなどを用いて名前空間を定義し、衝突を防ぐのが一般的な手法です。

しかし今回のケースにおいてRSpecのdescribeは名前空間ではないのでTargetクラスのスコープが漏れて、Test2の方でhogeメソッドの挙動が上書きされています。

なのでmoduleのSpecを書くときに「適当なクラスを定義してそれにmixinさせようぜ」みたいな運用をしていると、クラス名とメソッド名がかぶると上書きされてしまいます。

前述の例では1ファイルでプロダクトコードとテストコードを書きましたが、複数のファイルに分かれていても

rspec spec/のようにディレクトリ単位でSpecを実行した際にクラス名とメソッド名がかぶっていればこの現象は起きます。

なまじファイル単体で実行した場合はテストが通ってしまうため気づきにくい現象です。


解決策

moduleとしてメソッド名がかぶることはあるでしょうから、メソッド名を変えるというのは根本的解決策にはなりませんし、Specのためにプロダクトコードのメソッド名を考慮するとか辛すぎます。

Specでしか使わないクラス名も同様です。別のSpecで使われているかチェックするコストが発生してしまうので本質的ではありません。

なのでテスト対象のdescribe(あるいはcontext)だけにスコープを留めることが最善です。

RSpecでスコープを制限すると言えばletですよね。


sample2_spec.rb

# プロダクトコード側は変更がないので省略

describe Test1 do
let(:target) do
Class.new { include Test1 }.new
end
subject { target.hoge }
it { is_expected.to eq 'hoge1' }
end

describe Test2 do
let(:target) do
Class.new { include Test2 }.new
end
subject { target.hoge }
it { is_expected.to eq 'hoge2' }
end



sample2_spec.rb実行結果

$ rspec sample2_spec.rb

..

Finished in 0.0015 seconds (files took 0.12009 seconds to load)
2 examples, 0 failures


解説

Class.newした時点で無名クラスを返し、かつその無名クラスは実行するたびに異なるオブジェクトですのでmixinする先も変わるので衝突のしようがありません。

これなら安全です。


テストは通るけど危ない例


sample3_spec.rb

describe Test1 do

let(:target) do
class Target
include Test1
end
p Target.object_id # デバッグ用
Target.new
end
subject { target.hoge }
it { is_expected.to eq('hoge1') }
end

describe Test2 do
let(:target) do
class Target
include Test2
end
p Target.object_id # デバッグ用
Target.new
end
subject { target.hoge }
it { is_expected.to eq('hoge2') }
end



解説

危なっかしいのはlet内でTargetクラスを定義しているものです。

p Target.object_idは同じ値を返す、つまり同じクラスに対してmixinをしていることを指しています。

letのおかげで遅延評価され、テストのタイミングでincludeが実行され、後勝ちルールによってうまくテストが回ってます。

しかしながら都度Targetのメソッドが変わっていてあまり好ましい挙動ではないのでやらないに越したことはないと思います。