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 の例
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
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
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が含まれている場合に ArgumentError をraiseすることを、その直前でGreenになるような配列に最低限の改変(+ [nil])を行った場合を作成することでテストしようとしています。が、循環参照で死にます。
なぜダメなのか
contextやdescribeはクラス定義を行っています。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 が使えるということになります。
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
まとめ
-
letはdefine_method -
describeexpectはクラス定義 -
describeの内外は継承の関係 -
内側の
define_methodから外側のdefine_methodを利用するにはsuperを使えばいい-
superには引数明示が必要 - 引数なしなら
super()
-
-
そんなコードを書かない