Edited at

RSpecでlet(:foo)を再定義したい場合

RSpecを利用していて、let(:foo)fooの再定義がしたくなったときに少々困ったののまとめ


let再定義

letの再定義はRSpecではよく行うと思います。

RSpec.describe "Hoge.hoge" do

subject { Hoge.hoge(foo) }

let(:foo) { 100 }

it { expect { subject }.not_to raise_error }

context "when foo is not a number" do
let(:foo) { "foo" }

it { expect { subject }.to raise_error(ArgumentError) }
end
end

let(:foo)で定義されている foo はまるで変数のように見える何かで、評価は it の実行時に行われます。


困る場合

外で定義したfooを利用してfooを再定義しようとすると困ります。

RSpec.describe "Hoge.hoge" do

subject { Hoge.hoge(foo) }

let(:foo) { 100 }

it { expect { subject }.not_to raise_error }

context "when foo is negative" do
let(:foo) { foo * -1 } # これ

it { expect { subject }.to raise_error(ArgumentError) }
end
end

これをやろうとすると、fooが再帰的に評価されて stack level too deep. と言われて死にます。

「そんなlet書くなよ」という話もあるのですが、こういうことをしたくなった理由は shared_examples でした。


shared_examples の例


strings_converter_spec.rb

RSpec.describe StringsConverter do

let(:converter) { StringsConverter.new }
let(:items) { ["rock", "paper", "scissor"] }
subject { converter.run(items) }

it_behaves_like "a strict-typed Converter"
end



integers_converter_spec.rb

RSpec.describe IntegersConverter do

let(:converter) { IntegersConverter.new }
let(:items) { [100, 200, 300] }
subject { converter.run(items) }

it_behaves_like "a strict-typed Converter"
end



strict_converter_behaviour.rb

RSpec.shared_examples "a strict-typed Converter" do

describe "#run" do
subject { converter.run(items) }

it "is implemented" { expect { subject }.not_to raise_error(NotImplementedError) }

context "when argument `items` include other types" do
# ここでitemsを it_behaves_like 元の定義に基づいて再定義したい
let(:items) { items + [nil] }

it { expect { subject }.to raise_error(ArgumentError) }
end
end
end


ここでは各 Converter にわたす配列の要素の型は各specファイルで定義しつつ、共通のふるまいだけを切り出そうとしています。

処理する配列にnilが含まれている場合に ArgumentErrorraiseすることを、その直前でGreenになるような配列に最低限の改変(+ [nil])を行った場合を作成することでテストしようとしています。が、循環参照で死にます。


なぜダメなのか

contextdescribeはクラス定義を行っています。contextがネストされると、内側で定義されたcontextクラスは外側のものを継承します。

そして let() はおおよそ define_method() のエイリアスです。

describe "some Foo" do

let(:name) { "hoge" }
describe "when name is fuga"
let(:name) { "fuga" }
end
end

class SomeFoo

define_method :name { "hoge" }
class WhenNameIsFuga < SomeFoo
define_method :name { "fuga" }
end
end

っぽくなります。

つまり let(:foo) { foo } と書いてしまうと同じクラスの中で自分自身を最初に見つけるので、無限に再帰が起きることになります。

記述形式から期待するような foo = foo + 1 とは全く挙動が異なるのがポイントでした


どうすればいいのか



  • letが変数定義ではなく動的なメソッドである


  • it_behaves_like で最低でも一つ間にクラス生成を挟んでいて、外側を継承している

つまり let の中で外側のcontextのものを参照するためには super が使えるということになります。


strict_converter_behaviour.rb

RSpec.shared_examples "a strict-typed Converter" do

describe "#run" do
subject { converter.run(items) }

it "is implemented" { expect { subject }.not_to raise_error(NotImplementedError) }

context "when argument `items` include other types" do
# super を呼んで定義済み配列GET~!
let(:items) { super + [nil] }

it { expect { subject }.to raise_error(ArgumentError) }
end
end
end


と思いきや、今度は

implicit argument passing of super from method defined by

define_method() is not supported. Specify all arguments explicitly.

などと怒られます。define_method 内でsuperする場合は明示的にarityを指定する必要があるので、 super() にして解決です。

    context "when argument `items` include other types" do

# super() で引数がないことを示す
let(:items) { super() + [nil] }

it { expect { subject }.to raise_error(ArgumentError) }
end


まとめ



  • letdefine_method


  • describe expect はクラス定義


  • describeの内外は継承の関係


  • 内側の define_method から外側の define_method を利用するには superを使えばいい



    • super には引数明示が必要

    • 引数なしなら super()



  • そんなコードを書かない