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
-
describe
expect
はクラス定義 -
describe
の内外は継承の関係 -
内側の
define_method
から外側のdefine_method
を利用するにはsuper
を使えばいい-
super
には引数明示が必要 - 引数なしなら
super()
-
-
そんなコードを書かない