はじめに
今年2月から3-4年ぶりにRubyを使うことになったのだが、RSpecについて各メソッドをしっかり理解せずに雰囲気で書いていたので、まとめておこうと思う。
describe, context, it, subject の役割
describe :テストの対象。クラス名やメソッド単位
- テスト対象のクラスやメソッドを説明する。
- インスタンスメソッドなら
#
、クラスメソッドなら.
を慣習的に使う。 - 通常はクラス名やインスタンスメソッド/クラスメソッドに対応。階層構造のルートとして記載されることが多い
context :条件や前提
- 条件や前提を説明する。分岐や状態に応じて書き分ける。条件なのでcontextの中身として”when”が接頭辞としてつきやすい
- 条件の分岐ごとにグルーピングするため、複数あってOK、ネストもOK。ネストは深くなりすぎると逆に読みにくくなるので、2〜3階層くらいまでが目安。
it :期待する具体的な振る舞い
- 期待する具体的な振る舞いを書く場所。英語の文章のように「~であること」と書くのが理想。
describe User do
describe '#greet' do
context 'when the user is an admin' do
it 'returns a greeting with title' do
...
end
end
context 'when the user is a guest' do
it 'returns a generic greeting' do
...
end
end
end
end
describeの中にcontextは複数(テスト対象が同じでも条件が複数ある場合)存在しうるし、contextの中にcontextがネストされることもある(さらに細かい状況分けを行う場合)。
contextとitは必ずしも1:1とは限らず、contextの中にitを複数書く場合がある。contextの条件下で複数の期待する動作をテストする場合
# app/models/order.rb
class Order
attr_reader :paid, :shipped
def initialize(paid:, shipped:)
@paid = paid
@shipped = shipped
end
def status
return 'pending' unless paid
return 'shipped' if shipped
'paid'
end
end
# spec/models/order_spec.rb
require 'rails_helper'
describe Order do
describe '#status' do
context 'when the order is not paid' do
let(:order) { Order.new(paid: false, shipped: false) }
it 'returns pending' do
expect(order.status).to eq 'pending'
end
end
context 'when the order is paid' do
context 'and not shipped' do
let(:order) { Order.new(paid: true, shipped: false) }
it 'returns paid' do
expect(order.status).to eq 'paid'
end
end
context 'and shipped' do
let(:order) { Order.new(paid: true, shipped: true) }
it 'returns shipped' do
expect(order.status).to eq 'shipped'
end
it 'does not return paid or pending' do
expect(order.status).not_to eq 'paid'
expect(order.status).not_to eq 'pending'
end
end
end
end
end
subject :テストの中心となるオブジェクト
subject
は、そのdescribe
ブロック内で共通の「主語(テスト対象)」を定義するもの。基本的にdescribe単位で1つ。context
をいくつも書いても、基本的にそのsubject
は共有される。- subjectを使うと
is_expected.to
のようにシンプルに書ける。一行で表現できるシンプルなテストの場合に使う。 - 2行以上になりそうなときや、
subject
を明示的にしたいときは、it { is_expected.to ... }
ではなくexpect(subject).to …
を使うことが多い。 subject(:something)
のように名前付きsubjectも定義できる。describe下のcontextが複数存在していてsubjectが何だったっけ?となるようなケースで有用。
subject { user.full_name }
it { is_expected.to eq 'Dr. John Doe' }
subectを使うと先ほどのrspecは下記のように書ける。
変更点 | 説明 |
subject { order.status } | 共通の戻り値を subject にまとめた |
expect(order.status) → is_expected.to | より簡潔な書き方に |
# spec/models/order_spec.rb
require 'rails_helper'
describe Order do
describe '#status' do
subject { order.status }
context 'when the order is not paid' do
let(:order) { Order.new(paid: false, shipped: false) }
it { is_expected.to eq 'pending' }
end
context 'when the order is paid' do
context 'and not shipped' do
let(:order) { Order.new(paid: true, shipped: false) }
it { is_expected.to eq 'paid' }
end
context 'and shipped' do
let(:order) { Order.new(paid: true, shipped: true) }
it { is_expected.to eq 'shipped' }
it 'does not return paid or pending' do
expect(subject).not_to eq 'paid'
expect(subject).not_to eq 'pending'
# it { is_expected.not_to eq 'pending' } も同じだが、複数行になっているため上記の書き方が好まれる
end
end
end
end
end
subjectの注意点:メモ化
subjectは各テスト(itブロック)の中で一度だけ実行され、その結果が覚えられる(メモ化される)
→ 1つのテスト(it)の中で何度subjectを呼んでも、最初に実行された結果が使われる。
beforeブロックでsubjectを呼んでしまうと、その時点のパラメータで評価されキャッシュされる
→ 後からletでパラメータを変更しても、すでにキャッシュされた結果は変わらないため、更新の意図が反映されない。
let(:params) { { time_in_bed: 450 } }
subject { described_class.call(params) }
before do
subject # ← ここで評価されてキャッシュされる!
end
context "when time_in_bed is updated" do
let(:params) { super().merge(time_in_bed: 460) }
it "enqueues worker" do
expect(subject.time_in_bed).to eq(460) # ← 古いparamsで評価されたsubjectのままなので失敗!
end
end
なので、更新系のテストでは、beforeブロック内でsubjectを呼ばず、必要な初期状態の生成はdescribed_class.callを直接実行する方法が望ましい
→ その後、各itブロックでsubjectを呼ぶことで、そのテスト内の最新のパラメータが反映された結果が得られる。
let, let!, before の違いと使い分け
比較項目 | let | let! | before |
実行タイミング | 初めて呼ばれたとき(遅延評価) | it 実行前に 必ず実行 | it 実行前に 必ず実行 |
再評価 | キャッシュされる(1度だけ) | 同上 | 同上(副作用がある場合あり) |
使い方 | 値を定義してsubject やit 内で参照 | 強制的に評価して準備したいとき | 副作用(メソッド呼び出しやDB操作)などの前処理用 |
戻り値 | 定義した値 | 定義した値(ただし通常使わない) | before ブロックには戻り値は使わない |
書き方 | let(:user) { ... } | let!(:user) { ... } | before { ... } |
基本的にlet!を使用する。
before:副作用のみの場合に適している
- 何かの「値」を生成・返すわけではなく、アプリケーションの状態を変える処理(副作用)を実行しているだけ、という意味で「副作用のみ」
- 副作用(side effect)= メソッド呼び出しで外部状態を変更すること(例:ログイン、DBの更新、セッションの変更など)
before do
sign_in user # ログイン状態にする。何かの変数に代入して使うわけではない。
end
なのでこのような「処理だけを行う」ものは before
が適している。
テスト前提のオブジェクト生成:let! or before
「参照するものは let!、参照しないものは before」で生成することで、テストの可読性が上がる。
describe 'POST /articles' do
let!(:user) { create(:user) } # テストで参照する前提レコード
let!(:category) { create(:category) } # 使うので let! で生成
let(:params) do
{ title: 'Title', content: '...', category_id: category.id } # let!で定義したuser, categoryが使用されている
end
before do
sign_in user # ログイン状態にする。何かの変数に代入して使うわけではない=副作用のみ
end
it 'creates an article' do
expect {
post '/articles', params: params
}.to change(Article, :count).by(1)
end
end
let!(即時評価):常に必要な前提条件で定義。
- どの
context
/it
でも使う共通の前提(例:ログインユーザー、カテゴリなど) params
の中に入っていて、実質全体で必要になる値- 使われる前提で副作用を伴う処理が含まれるもの(
create
,build
, 外部APIなど)
let!(:user) { create(:user) }
# この時点でDBにユーザーが作成されている(subjectやitが始まる前に)
let (遅延評価):基本let!を使い、必要な時のみletを使う
読み手が「このuserっていつ使われるの?どのテストでも必要なの?」と迷いやすい。
- “遅延評価(実際にその変数が呼び出された瞬間まで評価(実行)されない) = パフォーマンスが良い” にとらわれすぎないこと
- letを使って「呼ばれるか呼ばれないか不明確な状態」にすると、テストの意図が読みにくくなる。テストの読みやすさと意図の明確さを優先すべき
- 「it の中で参照されてる = let にする」ではない!そのテストにとって前提となる存在の場合はlet!で明示的に生成し、いくつかのテストだけで使う重たい処理は避けたい場合に限りletを使って遅延評価で最小コストにするという観点で考える
let(:user) { create(:user) }
it 'does something unrelated to user' do
# userは生成されない(意図通りだけど、読者には伝わらない)
# userを参照した時点で create(:user) が呼ばれ、DBに保存される
end
let(:user) { build(:user) }
it 'is valid' do
# メモリ上のインスタンスのみ(DBには書き込まれない)
expect(user.persisted?).to be false
end
letで同じ名前の変数を再定義するときは注意が必要
(例: let(:params) { params.merge(…)} は無限ループの原因になる)
FactoryBot を使う or RSpec内で定義する?
FactoryBotでは、事前に定義したFactory(ひな型)を使ってオブジェクトを生成する。
Factoryの定義(通常は spec/factories/
配下)
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { 'Taro' }
email { 'taro@example.com' }
password { 'password123' }
trait :admin do
role { 'admin' }
end
end
end
RSpecでの呼び出し
作成(DBに保存)
# 作成(DBに保存)
let!(:user) { create(:user) }
# インスタンスのみ(DBに保存しない)
let(:user) { build(:user) }
# 属性だけ取得(Hashとして):インスタンスでもない、DBにも保存されない
let(:user_attrs) { attributes_for(:user) }
# 上記を実行すると、下記Hashが返ってくる。APIリクエストにパラメーターだけ渡したい場合に使う
# =>
{
name: "Taro",
email: "taro@example.com",
password: "password123"
}
it 'creates a new user' do
expect {
post '/users', params: { user: user_attrs }
}.to change(User, :count).by(1)
end
FactoryBot から持ってくるとき
- 共通化・再利用性が高い。
- 複雑なオブジェクトでも簡単に作成できる。
let(:user) { create(:user) }
RSpec内で簡単に定義する
- テストに特化したシンプルなケースならこちらでもOK。
let(:params) { { name: 'Hanako', age: 20 } }