RSpecのletを使うのはどんなときか?(翻訳)

  • 308
    いいね
  • 5
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

RSpecにはletという機能があります。
これを使うとインスタンス変数を次のように置き換えることができます。

# インスタンス変数を使う場合
before do
  @user = User.new(name: 'Taro', email: 'taro@example.com')
end

it 'is valid' do
  expect(@user).to be_valid
end

# letを使う場合
let(:user) { User.new(name: 'Taro', email: 'taro@example.com') }

it 'is valid' do
  expect(user).to be_valid
end

RSpecを使い慣れている人であれば、おそらくletを使うことが多いと思いますが、初心者の人には違いやメリットがよくわからないと思います。
また、使い慣れている人であっても具体的な違いをぱっと即答できる人は少ないんじゃないでしょうか?

ネットを調べていたところ、Stack OverflowにRSpecの開発者であるMyron Marston氏の模範回答が載っていたので、本記事ではこの内容を翻訳してみます。

When to use rspec let()?

質問

私はbeforeブロックでインスタンス変数を定義し、それらを各exampleで使う、というコードをよく書いています。
しかし、最近私はlet()という機能があることに気付きました。RSpecのドキュメントにはこのように書かれています。

letはメモ化のためのヘルパーメソッド(memoized helper method)を定義するために使います。
値はキャッシュされ、同じexampleの中では何度呼ばれても同じ値(同じオブジェクト)が返ります。
しかし、exampleが異なる場合は異なるオブジェクトが返ります。

私の疑問はbeforeブロックの中でインスタンス変数を定義することとどんな違いがあるのか、という点です。
また、みなさんはどういうときに let()before() を使うようにしていますか?

Myron Marston氏の回答(letの利点)

私は以下のような理由からインスタンス変数ではなくletをいつも使っています。

1. typoにすぐ気づける

インスタンス変数は参照された瞬間に生成されます。
これが問題になるのはインスタンス変数の名前をtypoしたときです。
typoすると新しいインスタンス変数が生成されてnilで初期化されます。
これはわかりにくいバグや、パスすべきでないテストがパスする問題(false positive)につながります。

letはメソッドを作るのでtypoするとNameErrorが発生します。
本来であればこちらの動きの方が望ましいでしょう。
また、このような特性(訳注:テストコードの問題をすぐ検知できること)を持つのでテストのリファクタリングもしやすくなります。

2. 無駄な初期化の時間を無くせる

before(:each)フックは各exampleの前に実行されます。
その際、exampleで使われないインスタンス変数があっても、before(:each)フックの中で定義されていればその変数が生成されます。
ほとんどのケースではこれは大した問題になりませんが、 もしインスタンス変数のセットアップに長い時間がかかるのであれば、毎回無駄な時間を食うことになります。

一方、 letで定義されたメソッドを使うと、初期化処理が実行されるのはそのメソッドが呼ばれたときだけ です。

3. ローカル変数をそのままletに置き換えられる

exampleの中でローカル変数を使っている場合、 シンタックスを変更することなくそのままletを使ったコードにリファクタリングできます。

一方、 インスタンス変数を使うようにリファクタリングすると、参照方法を変更する必要が出てきます。 (例:@を付ける)

4. letを使った方が読みやすい

これはちょっと主観的な話になりますが、Mike Lewis氏(訳注:別の回答者)も述べているようにlet を使った方がテストが読みやすくなる と思います。
テストが依存するオブジェクトはすべてletを使って整理し、itブロックの中のコードは短くスッキリさせるのが私の好みです。

コメント欄での補足(もう少し詳しく知りたいというコメントに対して)

letはテストが依存するオブジェクトを定義し、before(:each)は必要な設定やモック/スタブのセットアップで使うのが便利だと思います。
一つの巨大なbeforeフックで全部のことをやろうとするより、こんなふうに分ける方が私は好きです。

また、before(:each) { @foo = Foo.new }よりもlet(:foo) { Foo.new }と書いた方がスッキリしますし、意味もわかりやすいと思います。

具体的なコード例はこちらを見てください。

# https://github.com/myronmarston/vcr/blob/v1.7.0/spec/vcr/util/hooks_spec.rb#L9-16

subject { HooksClass.new }

# letではテストが依存するオブジェクトを定義
let(:invocations) { [] }

# beforeではテストに必要なセットアップコードを記述
before(:each) do
  subject.instance_eval do
    define_hook :before_foo
    define_hook :before_bar
  end
end

describe '#clear_hooks' do
  # letやbeforeのセットアップコードを使ってテストを実行する
  it 'clears all hooks' do
    subject.before_foo { invocations << :callback }
    subject.clear_hooks
    subject.invoke_hook(:before_foo, nil)
    invocations.should be_empty
  end
end

まとめ(と注意点)

さすがRSpecの開発者だけあって、letの利点をうまくまとめてくれています。
特にtypoにすぐ気づけるというのはletの一番大きな利点だと思います。

ただし、気をつけるべき点もあります。
回答の中には書かれていませんが、letは遅延初期化されるという特性があり、この動きを理解していないと「パスするはずのテストがパスしない」という問題を引き起こす場合があります。

# なぜかパスしないRailsのテストの例
let(:blog) { Blog.create(title: 'RSpec必勝法', content: 'あとで書く') }

it 'ブログの取得ができること' do
  expect(Blog.first).to eq blog
end

上のようなテストはletの代わりにlet!を使うとパスするようになります。
詳しくは以前書いたこちらのQiita記事を参考にしてみてください。

使えるRSpec入門・その1「RSpecの基本的な構文や便利な機能を理解する」