こんにちは、黒曜(@kokuyouwind)です。
最近見つけた面白いポケストップは「シーサーに似た守り神(メイドインタイランド)」です。
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をまとめるだけで済むようになります。
また、次の節で紹介するlet
とsubject
の遅延評価を使うことで、関心事のみを記述した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
を使っています。
これは少し不思議ですが、RSpecのlet
はinvoice
の値が必要になるまでブロックを評価しないため、実際に式が評価されるのは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_context
や shared_examples
を使った共通化は読みやすいため、活用することでSpecを読みやすく保守しやすいものにすることができます。
ただし文字列でのlook upになるため、メタプログラミングと同じくgtagsなどでのコードジャンプの恩恵が受けられず、処理を追いにくくなることには注意が必要です。
なるべく同じファイル内で定義するか、全体で使われるものはファイルの命名・配置規約を定めて見つけやすくすると良いでしょう。
また shared_examples
の内部からは、呼び出し元で let
した変数を参照することができます。
うまく使うことで読みやすく再利用性の高いSpecを書くことができますが、多用すると予期せぬ変数を見てしまうことになるため注意が必要です。
コメントなどで「何の変数をどういった目的で使うか」明示すると良いでしょう。
余談ですが、「メタプログラミングでshared_examples
を複数定義するコードを作りspec/support
以下に配置する」なんてことをした日には、利用箇所から定義箇所を見つけ出すのが非常に困難になります。
最悪デバッガーを使えば見つけ出せるとはいえ避けるようにしましょう。
まとめ
今回書いた内容は「自分が読みやすい・保守しやすい」と感じたものなので、人によっては分かりづらいと感じるものもあるかもしれません。
またSpecを記述する対象によっても適した書き方というのは変わってくると思います。
この記事が多少なりSpecを書く際の参考になれば幸いです。
MisocaではRSpecにも妥協しないエンジニアを募集しています。