Ruby
RSpec

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

弊社の一部で「単体だと通るけど、ディレクトリ単位で実行するとコケる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のメソッドが変わっていてあまり好ましい挙動ではないのでやらないに越したことはないと思います。