RSpecのletの遅延評価を利用してよりコンテクスチュアルなSpecを書く

皆さんこんにちは。もう今年も残すところわずか3日となりました。早いものです。
さて、年末感のかけらもないネタですが、今回はRSpecのletについて、最近自分の中で1つの理解が得られた(気がする)ので書いてみようと思います。

これまでのletへの理解

ちょっと賢い単なる変数への束縛だと思っていました。

公式ドキュメントであるRelishのLet and let!によると、let は、

$count = 0
RSpec.describe "let" do
  let(:count) { $count += 1 }

  it "memoizes the value" do
    expect(count).to eq(1)
    expect(count).to eq(1)
  end

  it "is not cached across examples" do
    expect(count).to eq(2)
  end
end

同じexampleの中(👆で言うと “it” の中)では常に値が一緒である事が保証されるようです。
また、 let! は、

$count = 0
RSpec.describe "let!" do
  invocation_order = []

  let!(:count) do
    invocation_order << :let!
    $count += 1
  end

  it "calls the helper method in a before hook" do
    invocation_order << :example
    expect(invocation_order).to eq([:let!, :example])
    expect(count).to eq(1)
  end
end

exampleが実行される前に評価&束縛されるようです。

また、日本語Googleで「RSpec let」と検索すると、次のような記事がヒットします。

ご覧の通り、大抵の記事では let と let! の評価順の違いと、メリットとしては公式ドキュメント同様にexmaple内での値の一貫性と、記事によっては可読性や、typoに気づきやすい等が挙げられています。

しかし、いずれも単に(以下のように)example内で変数を自前でバインドする事に比べて、大したメリットに感じません。

$count = 0
RSpec.describe "let" do
  it "memoizes the value" do
    count = $count += 1
  
    expect(count).to eq(1)
    expect(count).to eq(1)
  end

  it "is not cached across examples" do
    count = $count += 1
  
    expect(count).to eq(2)
  end
end

さらにはこんな記事まであります。

👆ではパフォーマンスの問題や、他で挙げられていた可読性が逆に損なわれていると言われています。
確かに、単なる変数束縛との違いが分からない状態では、初めてRSpecのコードを見た時のカオス感の大きな要因の一つとも思えます(実際思ってました)

理解した事

先程挙げた公式ドキュメントの中には、こんな言及もあります。

Note that let is lazy-evaluated: it is not evaluated until the first time the method it defines is invoked.

なるほど、どうやら let は遅延評価されるようです。ここで、ある思いが頭をよぎりました。

「letはsubjectを使った時に効果を発揮するのでは・・・?」

試しに書いてみた

テスト対象のクラスは、引数として与えられた同クラスのインスタンスの性別から異なった文言を使って挨拶を返す次のような物とします:

class Person
  SEX_MALE = 1
  SEX_FEMALE = 2

  attr_reader :name, :sex

  def initialize(name, sex)
    @name = name
    @sex = sex
  end

  # @param [Person] to_be_greeted
  def greet(to_be_greeted)
    title = to_be_greeted.sex == SEX_MALE ? "Mr." : "Ms."

    "Hi, #{title}#{to_be_greeted.name}, my name is #{@name}. It's a pleasure to meet you."
  end
end

これに対して、RSpecは次のような感じになりました:

RSpec.describe Person do
  describe "#greet" do
    let(:person) { Person.new("Issei", Person::SEX_MALE) }

    subject { person.greet(to_be_greeted) }

    context "with a male person" do
      let(:to_be_greeted) { Person.new("Taro", Person::SEX_MALE) }

      it { is_expected.to eq "Hi, Mr.Taro, my name is Issei. It's a pleasure to meet you." }
    end

    context "with a female person" do
      let(:to_be_greeted) { Person.new("Hanako", Person::SEX_FEMALE) }

      it { is_expected.to eq "Hi, Ms.Hanako, my name is Issei. It's a pleasure to meet you." }
    end
  end
end

主題である person.greet の呼び出しだけは先に宣言しておいて、example毎に、引数である to_be_greeted を別途用意しています。これは、 let が遅延評価であるから実現できています。

もちろんメソッドの呼び出し自体を、各exampleの it の中で行っても良いのですが、このくらいシンプルな振る舞いの検証であれば、この書き方の方がDSLを使っている感があって(RSpecの意図としても)良いように思います。好みの問題かもしれませんが。

因みに、スタブを使う場合も同様にできます。同じくPersonを例にすると:
(同じクラスのインスタンスを引数に取るので、この場合本来スタブを使うまでも無いのですが)

RSpec.describe Person do
  describe "#greet" do
    let(:person) { Person.new("Issei", Person::SEX_MALE) }

    subject { person.greet(to_be_greeted) }

    context "with a male person" do
      let(:to_be_greeted) { double(:to_be_greeted, name: "Taro", sex: Person::SEX_MALE) }

      it { is_expected.to eq "Hi, Mr.Taro, my name is Issei. It's a pleasure to meet you." }
    end
    
    # ...
  end
end

まとめ

RSpecはDSLを使っていて読みやすいSpecを書けるのはいいのですが、書き方が多岐に渡りすぎていて統率が難しいのです。
ですが、綺麗な書き方を発見すると楽しいので皆さんも色々探してみて下さい。

それでは良いお年を🎅

we are hiring

優秀な技術者と一緒に、好きな場所で働きませんか

株式会社もばらぶでは、優秀で意欲に溢れる方を常に求めています。働く場所は自由、働く時間も柔軟に選択可能です。

現在、以下の職種を募集中です。ご興味のある方は、リンク先をご参照下さい。