🦔

【RSpec】「letかlet!か」に終止符を打つ

2022/09/28に公開約6,800字

letとlet!

RSpecを書いていれば誰もがご存じのletですが、let!との使い分けってどうされていますか?
私はletは遅延評価されるため、後述で参照される場合はletを使用していました。
その方が省エネかなというくらいで、そこまで強い理由ではありません。

let(:user) { create(:user, name: '太郎') }
let(:article) { create(:article, user: user) }

そんなある日、同僚からこちらを紹介していただきました。
弊社、株式会社iCAREの技術顧問でもあるwillnetさんのspeakerdeckです。
https://speakerdeck.com/willnet/clean-test-code-revised

この中には「letとlet!どちらでも良い場合はlet!」という文言があります。
今回こちらについてwillnetさんにいくつか質問をさせていただき、
今後テストを書く際は明確な理由を持って使い分けができることを目指しました。

まず実装を追う

rspec-core 2.14.8
https://github.com/rspec/rspec-core/blob/71823ba11ec17a73b25bdc24ebab195494c270dc/lib/rspec/core/memoized_helpers.rb#L306

質問をする前にそもそも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_moduleletの引数で渡した名称でインスタンスメソッドを作成します。

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から追う

https://github.com/rspec/rspec-core/blob/71823ba11ec17a73b25bdc24ebab195494c270dc/lib/rspec/core/example_group.rb#L246

そもそも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!

https://github.com/rspec/rspec-core/blob/71823ba11ec17a73b25bdc24ebab195494c270dc/lib/rspec/core/memoized_helpers.rb#L400

def let!(name, &block)
  let(name, &block)
  before { __send__(name) }
end

letして名前を登録したらbeforeですぐ実行!
ということでlet!は単純な作りをしていました。

これである程度letlet!について知ることができたと思います。

本題

let!beforeについてwillnetさんにいくつか質問しました。

なぜletではなくlet!なのか

次のコードは先ほどのspeakerdeckを真似て書きました。
この場合、need_in_baで使われていない、bだけで使われているという判断を実装者にさせることになります。

let(:need_in_b) {}

context 'a' do
end

context 'b' do
end

しかし次のように書くとneed_in_bbでしか使われておらず、また確実に存在していることが一目で分かるようになりました。
こちらは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!letbeforeに渡しているだけです。
では次の場合はどうでしょうか。

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!letbeforeに渡しているだけです。
ということは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さんには感謝申し上げます。

あとがき

もちろんですがこの記事はべき論で書いていません。
テスト(だけではありませんが)は組織内での書き方、ルールも重要だと思っています。
状況に合わせてチームメンバーが読みやすい書き方を行ない、
迷いが生じたときにはスタイルガイドを指標にしてみるといいのかもしれません。

GitHubで編集を提案

Discussion

ログインするとコメントできます