RSpecを書く時に心がけたい3つの指針

soplana
99

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2015 の投稿記事です。

技術の話よりもMMO(DQX)をずっとしてたいなと思っていますが、こんにちは @soplana です。
タイトルの件みたいな話を最近チーム内で議論し、メンバーが書くRSpecが読みやすくなってきたので記事にしたいと思います。

概要

RSpecは多機能で知れば知るほど奥が深いテストコード用のDSLです。
describe, context, subject, before, it, expect, shared example等沢山の概念・用語が飛び交うので、書き方として何が正しいかよく分からないけど、とりあえずテストコードは書いておかないとな...みたいな気持ちで書いている人も多いのではないかと思います。
このエントリーでは、RSpecの小技の紹介や、概念・用語の説明なんかはしません。DRYにしろとも言いません。ただ、大筋としてRspecを書く時の指針みたいなものを、具体例をあげながら紹介していきたいと思います。

RSpecを書く時の考え方

最初に結論から入ります。
RSpecを書く時、以下の3点にだけ集中して書くことで、圧倒的に読み手に伝わりやすい良いテストコードになります。

  • テストケースに必要となる、データの準備
  • テストケースの前提条件・振る舞い
  • テスト内容

では、これらに対して、具体的にRSpecのどのキーワードが関連付けられるかというと以下のようになります。

  • テストケースに必要となるデータの準備 > let let!
  • テストケースの前提条件・振る舞い > before after
  • テスト内容 > it

はい。これです。正直コレ以外の話は結構どうでもよくて、テスト内容が重複してても別にいいと思います。もう、これだけでこのエントリーで言いたい事の8割くらいを言い切った感じですが、具体例を交えながら説明していきます。
なおこの3点の事を本エントリーでは便宜上、「3つの指針」と呼びます。
3つの指針以外の話はするつもりがないので、本来subject化した方が良いなとかもありますが、もう色々ガン無視して進めます。頼むからこれだけ覚えて帰ってくれ、みたいな私の想いが伝わればいいなと思います。

letについて考える

Userサンプルモデルを作成します。

class User
  DEFAULT_NAME = "default"
  DEFAULT_PASS = "password"

  attr_accessor :name, :password

  def initialize user_data
    @name     = user_data[:name]     || DEFAULT_NAME
    @password = user_data[:password] || DEFAULT_PASS
  end
end

ではこのUserモデルに、 @name が正しく設定されている事を検証したいと思います。前述した、3つの指針に当てはめて考えると以下のようになります。

  • テストケースに必要となる、データの準備 > user そのものと @name に入る値
  • テストケースの前提条件・振る舞い > 特に無し
  • テスト内容 >  user.name に値が設定される(或いはされない)事

それを念頭にRSpecを書くと、以下のようになります。

RSpec.describe User do

  # ユーザモデルのインスタンスは
  # 全テストケースで必要となるので
  # ここで宣言しておく
  # ※ 例なのでクラスメソッドのテストは無いものとする
  let(:user) do
    User.new({
      name:     user_name,
      password: user_password
    })
  end
  let(:user_name)    { "" }
  let(:user_password){ "" }

  describe "#name" do
    context "ユーザネームが指定された場合" do
      # このテストケースにおける【データの準備】
      let(:user_name) { "hoge" }

      # テスト内容
      it "ユーザネームが設定されること" do
        expect(user.name).to eq(user_name)
      end
    end

    context "ユーザネームが指定されなかった場合" do
      # このテストケースにおける【データの準備】
      let(:user_name) { nil }

      # テスト内容
      it "ユーザネームにデフォルト値が設定されている事" do
        expect(user.name).to eq(User::DEFAULT_NAME)
      end
    end
  end
end

ブロックごとに考えます。

let(:user) do
    User.new({
      name:     user_name,
      password: user_password
    })
  end
  let(:user_name)    { "" }
  let(:user_password){ "" }

まず一番外のスコープで宣言されているlet達は、このspecファイルの全てのケースで必要になる 前提データ としての宣言です。
この例にはありませんが、クラスメソッドのテストとかもあって全ケースで必要とならない場合には、contextで区切って宣言すればよいのです。

次に実際、#nameをテストしている部分を見ていきます。

context "ユーザネームが指定された場合" do
      # このテストケースにおける【データの準備】
      let(:user_name) { "hoge" }

      # テスト内容
      it "ユーザネームが設定されること" do
        expect(user.name).to eq(user_name)
      end
    end

    context "ユーザネームが指定されなかった場合" do
      # このテストケースにおける【データの準備】
      let(:user_name) { nil }

      # テスト内容
      it "ユーザネームにデフォルト値が設定されている事" do
        expect(user.name).to eq(User::DEFAULT_NAME)
      end
    end

このRSpecを見た時、読み手はletitにのみ集中すればよく、user.nameの挙動がパッと見でわかりやすく表現できているかと思います。
これは、どんなに複雑な処理であっても基本的にこの形は崩れません。この例の実装が簡易的だから、シンプルなテストになっているという訳では決してないです。

まぁletitだけだと例として貧弱なのは確かなので、次にbeforeを交えたケースを見てみましょう。

beforeについて考える

Userモデルに、authenticate周りの処理を追記します。

class User
  DEFAULT_NAME = "default"
  DEFAULT_PASS = "password"

  attr_accessor :name, :password

  def initialize user_data
    @name     = user_data[:name]     || DEFAULT_NAME
    @password = user_data[:password] || DEFAULT_PASS
    @authenticate = false
  end

  def authenticate!
    @authenticate = (@name != DEFAULT_NAME)
  end

  def authenticated?
    @authenticate
  end
end

user.authenticated?true/falseになる事を検証します。このテストも前述した、3つの指針に当てはめて考えると以下のようになります。

  • テストケースに必要となる、データの準備 > userそのものと@nameに入る値
  • テストケースの前提条件・振る舞い > user.authenticate!が実行されている事
  • テスト内容 > データと振る舞いによって変動する@authenticateの値

先ほどのspecファイルに追記します。

RSpec.describe User do

  # ここは一緒
  let(:user) do
    User.new({
      name:     user_name,
      password: user_password
    })
  end
  let(:user_name)    { "" }
  let(:user_password){ "" }
  
  # 省略
  describe "#name" do
  	#....
  end
  	
  describe "#authenticate" do
    # テストケースの前提条件・振る舞い
    before do
      user.authenticate!
    end

    context "ユーザネームが指定されて、認証された場合" do
      # このテストケースにおける【データの準備】
      let(:user_name) { "hoge" }

      # テスト内容
      it "認証済みになること" do
        expect(user.authenticated?).to be true
      end
    end

    context "ユーザネームが指定されずに、認証された場合" do
      # このテストケースにおける【データの準備】
      let(:user_name) { nil }

      # テスト内容
      it "認証済みにならないこと" do
        expect(user.authenticated?).to be false
      end
    end
  end
end

このspecではdescribe "#authenticate" doの最初のスコープのbeforeuser.authenticate!が実行されています。ということはuser.authenticate!が実行された後の値の変化をテストしているのだなと、一目で分かります。続けて、以降のletを見てみると、user.nameの値を各ケースで書き換えているのもわかります。
つまり、user.nameの値によって、user.authenticate!の結果が変動する事を期待している事が、このspecから容易に想像できます。
そうすると記述するspecは自然と以下のようになるはずです。

  • specファイルから@userのようなインスタンス変数が消え去る。
  • itの中はexpectのみになる。

itの中にexpect以外をどうしても書かなければならないケースもあるかもしれませんが、今のところ私はそのようなケースに遭遇していません。
capybaraを使ったE2Eテストであっても、controllerテストであっても、これらの原則は崩れません。

let!について考える

ではlet!はいつどういう時に使うのでしょう?
letと違い、遅延評価はされません。let!before(:each)と同様各テストケースの前に実行されます。実行順序に関しては記述順になります。
つまりlet!は、beforeitに到達する前にかならずデータの準備が必要な場合に有用です。
よく利用されるケースとしては、controllerやE2Eのテストでbefore { get(...) }などのリクエストが走る前に、Userモデルが必要となる場合などです。

let!(:user)  { User.create(id: 1, name: hoge) }
let(:user_id){ 1 }

before do
  get :users, id: user_id
end

他にもlet!に関して「こういう使い方がナイス」みたいなのがあれば、教えてください。

これらを徹底することで生じるメリット

変更に強い

このエントリーで書いたような事が実践されていて始めてテスト駆動開発(死語っぽい)が機能するように思います。
前提とするデータや振る舞い、結果を大まかにイメージしてRSpecを記述してから、実装していく中で柔軟に前提データや振る舞いを修正していく事が可能だからです。

specファイルが仕様書になる

前提とするデータ、振る舞い、返り値が記述されているため、後で自分以外の人がコードに手を加えたい時はまずRSpecから読めば良いので負荷が軽減されます。
これは、レビュアーの負荷を軽減することにもつながります。前提データや振る舞いが明確なので、仕様に沿ってないコードの発見にもつながり、レビューの精度も上がります。

まとめ

これらの3つの指針が徹底されている事に比べれば、itの内容が重複していたり、RSpecの機能を十分に活かせてないテストコードなどは、大した問題じゃないと考えています。
RSpecを書き慣れている人なんかにとっては当たり前の事を書いているかもしれませんが、意外とこの辺りの話を知らずに手探りでRSpecを書いてる人も多いようで、この話をすると大体すごく納得してくれて格段に綺麗なテストを書いてくれるようになるので、今回記事にまとめてみました。
勿論、最初に述べた通りRSpecは知れば知る程奥が深く楽しいDSLなので、その機能を遺憾なく発揮し美しいコードが書けるに越した事はありません。ですが、前提としてこれらの事を念頭において書いてくれれば、というお話です。

基本的にここで挙げた3つの指針は、どんなに複雑な処理であっても、E2Eのテストであっても守っていける事です。書く方にとっても、読む方にとっても、心地良いRSpecになればいいなと思っています。
なお今回は、会社のテックブログなので口調も内容も大人し目で書きました。

参考リンク

いいこと書いていますね。