弊社の一部で「単体だと通るけど、ディレクトリ単位で実行するとコケるSpecがある」と話題になったので書きます。
tl;dr
- describeは名前空間ではないので定数はスコープが漏れますよ
- letでClass作りましょうね
moduleのSpecを書くときに、なんらかのクラスにmixinをさせてテストをしたいという状況に起こりそうなやつです。
ダメな例
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
$ 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ではメソッド名がかぶった場合に後から定義されたほうが採用される後勝ちルールが使われます。
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
ですよね。
# プロダクトコード側は変更がないので省略
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
$ rspec sample2_spec.rb
..
Finished in 0.0015 seconds (files took 0.12009 seconds to load)
2 examples, 0 failures
解説
Class.newした時点で無名クラスを返し、かつその無名クラスは実行するたびに異なるオブジェクトですのでmixinする先も変わるので衝突のしようがありません。
これなら安全です。
テストは通るけど危ない例
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
のメソッドが変わっていてあまり好ましい挙動ではないのでやらないに越したことはないと思います。