【RSpec】「letかlet!か」に終止符を打つ
letとlet!
RSpecを書いていれば誰もがご存じのlet
ですが、let!
との使い分けってどうされていますか?
私はlet
は遅延評価されるため、後述で参照される場合はlet
を使用していました。
その方が省エネかなというくらいで、そこまで強い理由ではありません。
let(:user) { create(:user, name: '太郎') }
let(:article) { create(:article, user: user) }
そんなある日、同僚からこちらを紹介していただきました。
弊社、株式会社iCAREの技術顧問でもあるwillnetさんのspeakerdeckです。
この中には「letとlet!どちらでも良い場合はlet!」という文言があります。
今回こちらについてwillnetさんにいくつか質問をさせていただき、
今後テストを書く際は明確な理由を持って使い分けができることを目指しました。
まず実装を追う
rspec-core 2.14.8
質問をする前にそもそもlet
の仕組みが分からなかったので実装を追うことにしました。
let
実行されるとmodule_for
メソッドで定数LetDefinitions
に定義されたModuleを受け取ります。
module_for
はdescribeされたclassにLetDefinitions
という名のModuleを付け、そのmoduleそのものを返すメソッドです。
our_module = MemoizedHelpers.module_for(self)
# our_module
# => RSpec::ExampleGroups::DescribeClass::LetDefinitions
# our_module.class
# => Module
次にour_module
にlet
の引数で渡した名称でインスタンスメソッドを作成します。
our_module.__send__(:define_method, name, &block)
# our_module.instance_methods => [:user]
# show-method RSpec::ExampleGroups::DescribeClass::LetDefinitions#user
# => let(:user) {
# create(:user, name: '太郎')
# }
最後にメモ化。
fetch_or_store
は引数name
が未登録の場合、our_module
が持つ同名のメソッドの結果を@memoized
に保持します。
そして2度目であれば保持していたものをを返す仕組みです。
if block.arity == 1
define_method(name) { __memoized.fetch_or_store(name) { super(RSpec.current_example, &nil) } }
else
define_method(name) { __memoized.fetch_or_store(name) { super(&nil) } }
end
describeから追う
そもそもRSpec.describe
しているクラスからなぜuser
を実行できるのでしょうか。
追ってみるとdescribe
実行時の処理のなかでsubclass
が実行されていました。
def self.define_example_group_method(name, metadata={})
idempotently_define_singleton_method(name) do |*args, &example_group_block|
thread_data = RSpec::Support.thread_local_data
top_level = self == ExampleGroup
registration_collection =
if top_level
if thread_data[:in_example_group]
raise "Creating an isolated context from within a context is " \
"not allowed. Change `RSpec.#{name}` to `#{name}` or " \
"move this to a top-level scope."
end
thread_data[:in_example_group] = true
RSpec.world.example_groups
else
children
end
begin
description = args.shift
combined_metadata = metadata.dup
combined_metadata.merge!(args.pop) if args.last.is_a? Hash
args << combined_metadata
# これ↓
subclass(self, description, args, registration_collection, &example_group_block)
ensure
thread_data.delete(:in_example_group) if top_level
end
end
subclass
のなかではMemoizedHelpers.define_helpers_on
が実行されていて。
# https://github.com/rspec/rspec-core/blob/71823ba11ec17a73b25bdc24ebab195494c270dc/lib/rspec/core/example_group.rb#L395
def self.subclass(parent, description, args, registration_collection, &example_group_block)
subclass = Class.new(parent)
subclass.set_it_up(description, args, registration_collection, &example_group_block)
subclass.module_exec(&example_group_block) if example_group_block
# The LetDefinitions module must be included _after_ other modules
# to ensure that it takes precedence when there are name collisions.
# Thus, we delay including it until after the example group block
# has been eval'd.
MemoizedHelpers.define_helpers_on(subclass) # <-これ
subclass
end
親クラスにmodule_for
でreturnされたLetDefinitions
がincludeされています。
これでuser
が参照できるようになることがわかりました。
def self.define_helpers_on(example_group)
example_group.__send__(:include, module_for(example_group))
end
# example_group => RSpec::ExampleGroups::DescribedClass
let!
def let!(name, &block)
let(name, &block)
before { __send__(name) }
end
let
して名前を登録したらbefore
ですぐ実行!
ということでlet!
は単純な作りをしていました。
これである程度let
とlet!
について知ることができたと思います。
本題
let!
やbefore
についてwillnetさんにいくつか質問しました。
なぜletではなくlet!なのか
次のコードは先ほどのspeakerdeckを真似て書きました。
この場合、need_in_b
はa
で使われていない、b
だけで使われているという判断を実装者にさせることになります。
let(:need_in_b) {}
context 'a' do
end
context 'b' do
end
しかし次のように書くとneed_in_b
はb
でしか使われておらず、また確実に存在していることが一目で分かるようになりました。
こちらはneed_in_b
がどこで使われるかというスコープの話も入っています。
context 'a' do
end
context 'b' do
let!(:need_in_b) {}
end
共通で使われるletの場合も同様
すべてのcontext
で参照する場合も同様にlet!
を。
let
だと本当に参照されているのか、どこで参照されているのかを見る必要があるためコードの可読性が下がります。
let!(:user) { create(:user, '太郎') }
let!(:article) { create(:article, user: user) }
context 'a' do
end
context 'b' do
end
参照予定のないlet!ならbeforeでいいのでは?
前述のとおり、let!
はlet
をbefore
に渡しているだけです。
では次の場合はどうでしょうか。
let!(:user) { create(:user, name: '太郎') }
it 'a' do
expect(User.count).to eq 1
end
user
は参照されていませんがあらかじめ作成される必要があるためlet!
にしている例です。
この場合「無理にlet!
を使わずbefore
でいいのでは」と質問したところ、参照される否かで使い分ける方法を教えてくださいました。
before do
# 参照されないものはbeforeに
# 参照するならlet!で書く
create(:user, name: '太郎')
end
it 'a' do
expect(User.count).to eq 1
end
参照しているかが分かるだけでもだいぶ読みやすくなると思います。
こちらについてはwillnetさんのRSpec スタイルガイドに詳しく記載がありますので是非目を通してみてください。
let!はbeforeでインスタンス変数に詰めちゃえばいいのでは?
繰り返しになりますがlet!
はlet
をbefore
に渡しているだけです。
ということはbefore
で参照したいものをインスタンス変数に詰めても良いのではないでしょうか。
before do
@user = create(:user, name: '太郎')
end
willnetさんの回答は「どちらでも良い」でした。
確かに@user
でもlet!
でも参照できることに変わりはありません。
ただrspec内でのインスタンス変数はあまりポジティブな記事を見かけないのと、
let!
を使用すればuser
がgetterとなるため@
を付けなくても良いなどの利点はあります。
ここは素直にlet!
を使用するのがよさそうです。
let!で納得
let
なのかlet!
なのかという題で書いた記事ですが、
仕組みを理解したうえで「letとlet!どちらでも良い場合はlet!」に納得できました。
(ちなみに遅延評価が有効である場合にはlet
を...としています。)
before
との使い分けもはっきりしたので、今後テストを書くときには明確な答えを持ってメソッドを選べるでしょう。
また私はテスト可読性や脳への負担をここまで考えていませんでした。
RSpecってほんと、書いてる時は読めるんですけど自分が描いていないコードだと途端に理解に時間がかかります。
テストではないコードよりも「???」となりやすい気がします。
その点でも良い気づきとなりました。
お付き合いくださったwillnetさんには感謝申し上げます。
あとがき
もちろんですがこの記事はべき論で書いていません。
テスト(だけではありませんが)は組織内での書き方、ルールも重要だと思っています。
状況に合わせてチームメンバーが読みやすい書き方を行ない、
迷いが生じたときにはスタイルガイドを指標にしてみるといいのかもしれません。
Discussion