読みやすいRSpecを書くためのTips

こんにちは、黒曜(@kokuyouwind)です。
最近見つけた面白いポケストップは「シーサーに似た守り神(メイドインタイランド)」です。

f:id:kokuyouwind:20160822180501p:plain

PokemonGOは飽きてきて、代わりに最新作のサン・ムーンが欲しくなってきました。
アローラロコンかわいいよアローラロコン。あとミミッキュ。

さて、Misocaに入社して一年弱が経ち、RSpecの書き方にも大分慣れてきたのですが、油断すると冗長で複雑なSpecを書いてしまいがちです。
既存のSpecが複雑だと新しいexampleを追加するときにも書きづらくなってしまうため、気づいたらなるべくリファクタリングするように心がけています。

今回は、最近自分が「こういう書き方だと読みやすく保守もしやすいのではないか」と思っているRSpecの書き方について紹介していきたいと思います。

Specを記述する対象と文脈に着目して分割する

最近書くSpecでは、大体

  • Specを記述する対象のメソッド/属性について(describe)
  • そのメソッド/属性に影響を与える文脈について(context)
  • 期待される出力について(it、出力グループが複数ある場合はdescribeでまとめる)

という順序でSpecを分割して書いています。
例えば、UserモデルのSpecであれば以下のような感じです。

RSpec.describe User, type: :model do
  describe '#valid?' do
    context '妥当なユーザーのとき' do
      let(:user) { create(:user) }
      it { expect(user).to be_valid }
    end

    context '名前が空のとき' do
      let(:user) { create(:user, name: '') }
      it { expect(user).not_to be_valid }
    end
  end
end

この定義順にすることで、Specを記述する対象のメソッドが増えた場合には単純にdescribeを一つ増やし、そこに新しいメソッドのexampleをまとめるだけで済むようになります。
また、次の節で紹介するletsubjectの遅延評価を使うことで、関心事のみを記述したSpecが書きやすくなります。

let, subjectの遅延評価を使い、関心事のみを記述する

上記の例は単純な例でしたが、少し複雑な例を考えてみましょう。 請求書の合計金額を求めるメソッド(Invoice#total_price)について、以下のようなSpecがあるとします。

RSpec.describe Invoice, type: :model do
  describe '#total_price' do
    context '明細が空のとき' do
      let(:invoice) { create(:invoice, body: create(:body, items: [])) }
      it { expect(invoice.total_price).to eq(0) }
    end

    context '明細があるとき' do
      let(:invoice) {
        create(:invoice, body: create(:body, items: [ create(:item, quantity: 2, price: 50) ]))
      }
      it { expect(invoice.total_price).to eq(100) }
    end
  end
end

このような書き方では、本来着目したいitemsの定義がinvoiceの定義の中に紛れてしまっています。
こういった場合、contextの中では関心事に関する定義だけを行うようにすることで、Specの意図がわかりやすくなります。

RSpec.describe Invoice, type: :model do
  describe '#total_price' do
    let(:invoice) { create(:invoice, body: create(:body, items: items)) }
    subject { invoice.total_price }

    context '明細が空のとき' do
      let(:items) { [] }
      it { expect(subject).to eq(0) }
    end

    context '明細があるとき' do
      let(:items) { [ create(:item, quantity: 2, price: 50)] }
      it { expect(subject).to eq(100) }
    end
  end
end

invoiceはSpec全体で共有し、各 context では items の定義のみを行っています。
このようにすることで「明細があるとき」のexampleでは「数量2で価格50の明細がある」ことから「合計金額が100になる」という意図を読み取りやすくなっています。

invoiceの定義では、まだ定義処理が書かれていないitemsを使っています。
これは少し不思議ですが、RSpecletinvoiceの値が必要になるまでブロックを評価しないため、実際に式が評価されるのはsubjectを定義する式が評価された時です。
subjectの値は各example内で使われており、このタイミングではitemsが定義された後になるため、意図したとおりに動作します。
慣れないうちは分かりづらいかもしれませんが、このように「Specを記述する対象データの一部のみを差し替える」といった用途では便利なテクニックです。

Controller Specでリクエスト処理をsubjectにする

もうひとつ遅延評価が役立つ例として、Controller Specでのリクエスト処理を紹介します。
以下のような UsersController#new のSpecを考えてみます。

RSpec.describe UsersController, type: :controller do
  describe '#create' do
    context '妥当なユーザー名とパスワードの場合' do
      let(:params) { name: 'test', pass: 'pass' }
      it { expect { post :create, user: params }.to change(User, :count).by(1) }
      it do
        post :create, user: params
        expect(response).to be_ok
      end
    end

    context 'ユーザー名が空の場合' do
      let(:params) { name: '', pass: 'pass' }
      it { expect { post :create, user: params }.not_to change(User, :count) }
      it do
        post :create, user: params
        expect(response).to be_bad_request
      end
    end
  end
end

postが全てのexampleに出てきており、responseを得るためにpostしている箇所もあるため、どういった意図でSpecを書いているのかが分かりづらくなっています。

実は、postを評価した返り値はresponseになっています。
これを利用することで、以下のように書き換えることができます。

RSpec.describe UsersController, type: :controller do
  describe '#create' do
    subject { post :create, user: params }

    context '妥当なユーザー名とパスワードの場合' do
      let(:params) { name: 'test', pass: 'pass' }
      it { expect { subject }.to change(User, :count).by(1) }
      it { expect(subject).to be_ok }
    end

    context 'ユーザー名が空の場合' do
      let(:params) { name: '', pass: 'pass' }
      it { expect { subject }.not_to change(User, :count) }
      it { expect(subject).to be_bad_request }
    end
  end
end

それぞれの行が何を意図しているか、だいぶすっきりしました。
このように、Controller Specではリクエスト処理自体をsubjectにすることで「文脈に応じたリクエスト内容の差し替え」と「リクエスト処理の集約」を両立することができ、更にレスポンスの検査もsubjectで行うことができるため、Specを記述する対象が明確になります。

注意が必要な点として、subject は初回評価時にのみリクエストが実行され、以降はリクエストが実行されず値を返すだけになります。
このためchangeを検査できるのはsubjectの最初の評価時のみになります。
1つのit内で複数のモデルについて change を検査しないかぎりは問題にならないはずですが、そのようなケースでもandを使ってマッチャーを結合すれば正しく検査することができます。

# これは必ず失敗する
it {
  expect { subject }.to change(User, :count).by(1)
  expect { subject }.to change(UserHistory, :count).by(1)
}

# こう書くと意図通りに動作する
it {
  expect { subject }.to change(User, :count).by(1)
    .and change(UserHistory, :count).by(1)
}

また、「リクエストした後にデータベースに作成されたオブジェクトを検査する」など、レスポンス以外の検査が必要な場合は先にsubjectを評価する必要があります。
before { subject } といった記述をすることになりますが、本来のsubjectの使い方とは異なるため、こういったexampleが必要な場合は素直に書いたほうがいいかもしれません。

あまり凝ったことをしない

Specをシンプルに書くためのテクニックを紹介しましたが、「Specではあまり凝ったことをしない」というのも重要です。
なぜなら「SpecのSpec」はなく、バグが紛れ込んだ場合に検知することが難しいためです。

メタプログラミングは避ける

「凝ったこと」のなかでも、特にメタプログラミングを使った共通化は避けたほうが良いと考えています。
一般にメタプログラミングは「動かしてみないと正しいかわからない」部分が多く、Specによる正しさの保証がより重要になるからです。

また、メタプログラミングは基本的に書いた本人以外には理解しづらく、保守やexample追加が難しくなりがちなことも問題です。
仕様を把握するためにSpecを確認したらSpecの仕様が把握できなかった、という事態に陥る可能性があります。

shared_examplesは注意して適用する

独自のメタプログラミングに比べると shared_contextshared_examples を使った共通化は読みやすいため、活用することでSpecを読みやすく保守しやすいものにすることができます。
ただし文字列でのlook upになるため、メタプログラミングと同じくgtagsなどでのコードジャンプの恩恵が受けられず、処理を追いにくくなることには注意が必要です。 なるべく同じファイル内で定義するか、全体で使われるものはファイルの命名・配置規約を定めて見つけやすくすると良いでしょう。

また shared_examples の内部からは、呼び出し元で let した変数を参照することができます。
うまく使うことで読みやすく再利用性の高いSpecを書くことができますが、多用すると予期せぬ変数を見てしまうことになるため注意が必要です。 コメントなどで「何の変数をどういった目的で使うか」明示すると良いでしょう。

余談ですが、「メタプログラミングshared_examplesを複数定義するコードを作りspec/support以下に配置する」なんてことをした日には、利用箇所から定義箇所を見つけ出すのが非常に困難になります。
最悪デバッガーを使えば見つけ出せるとはいえ避けるようにしましょう。

まとめ

今回書いた内容は「自分が読みやすい・保守しやすい」と感じたものなので、人によっては分かりづらいと感じるものもあるかもしれません。
またSpecを記述する対象によっても適した書き方というのは変わってくると思います。
この記事が多少なりSpecを書く際の参考になれば幸いです。

MisocaではRSpecにも妥協しないエンジニアを募集しています。