方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Rails】RSpec備忘録:describe / context / it / subject / let / let! / before / FactoryBot

rails-rspec
この記事は約14分で読めます。

はじめに

今年2月から3-4年ぶりにRubyを使うことになったのだが、RSpecについて各メソッドをしっかり理解せずに雰囲気で書いていたので、まとめておこうと思う。

 

describe, context, it, subject の役割

describe :テストの対象。クラス名やメソッド単位

  • テスト対象のクラスやメソッドを説明する。
  • インスタンスメソッドなら #、クラスメソッドなら . を慣習的に使う。
  • 通常はクラス名やインスタンスメソッド/クラスメソッドに対応。階層構造のルートとして記載されることが多い

context :条件や前提

  • 条件や前提を説明する。分岐や状態に応じて書き分ける。条件なのでcontextの中身として”when”が接頭辞としてつきやすい
  • 条件の分岐ごとにグルーピングするため、複数あってOK、ネストもOK。ネストは深くなりすぎると逆に読みにくくなるので、2〜3階層くらいまでが目安。

it :期待する具体的な振る舞い

  • 期待する具体的な振る舞いを書く場所。英語の文章のように「~であること」と書くのが理想。
Ruby
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の条件下で複数の期待する動作をテストする場合

Ruby
# 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
Ruby
# 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が何だったっけ?となるようなケースで有用。
Ruby
subject { user.full_name }
it { is_expected.to eq 'Dr. John Doe' }

 subectを使うと先ほどのrspecは下記のように書ける。

変更点説明
subject { order.status }共通の戻り値を subject にまとめた
expect(order.status)is_expected.toより簡潔な書き方に

Ruby
# 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でパラメータを変更しても、すでにキャッシュされた結果は変わらないため、更新の意図が反映されない。

Ruby
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 の違いと使い分け

比較項目letlet!before
実行タイミング初めて呼ばれたとき(遅延評価it 実行前に 必ず実行it 実行前に 必ず実行
再評価キャッシュされる(1度だけ)同上同上(副作用がある場合あり)
使い方値を定義してsubjectit内で参照強制的に評価して準備したいとき副作用(メソッド呼び出しやDB操作)などの前処理用
戻り値定義した値定義した値(ただし通常使わない)beforeブロックには戻り値は使わない
書き方let(:user) { ... }let!(:user) { ... }before { ... }

基本的にlet!を使用する。

before:副作用のみの場合に適している

  • 何かの「値」を生成・返すわけではなく、アプリケーションの状態を変える処理(副作用)を実行しているだけ、という意味で「副作用のみ」
  • 副作用(side effect)= メソッド呼び出しで外部状態を変更すること(例:ログイン、DBの更新、セッションの変更など)
Ruby
before do
  sign_in user  # ログイン状態にする。何かの変数に代入して使うわけではない。
end

 なのでこのような「処理だけを行う」ものは before が適している。

 

テスト前提のオブジェクト生成:let! or before

「参照するものは let!、参照しないものは before」で生成することで、テストの可読性が上がる。

Ruby
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など)
Ruby
let!(:user) { create(:user) }
# この時点でDBにユーザーが作成されている(subjectやitが始まる前に)

 

let (遅延評価):基本let!を使い、必要な時のみletを使う

読み手が「このuserっていつ使われるの?どのテストでも必要なの?」と迷いやすい。

  • “遅延評価(実際にその変数が呼び出された瞬間まで評価(実行)されない) = パフォーマンスが良い” にとらわれすぎないこと
  • letを使って「呼ばれるか呼ばれないか不明確な状態」にすると、テストの意図が読みにくくなる。テストの読みやすさと意図の明確さを優先すべき
  • 「it の中で参照されてる = let にする」ではない!そのテストにとって前提となる存在の場合はlet!で明示的に生成し、いくつかのテストだけで使う重たい処理は避けたい場合に限りletを使って遅延評価で最小コストにするという観点で考える
Ruby
let(:user) { create(:user) }

it 'does something unrelated to user' do
  # userは生成されない(意図通りだけど、読者には伝わらない)
  # userを参照した時点で create(:user) が呼ばれ、DBに保存される
end
Ruby
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/ 配下)

Ruby
# 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に保存)

Ruby
# 作成(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 から持ってくるとき

  • 共通化・再利用性が高い。
  • 複雑なオブジェクトでも簡単に作成できる。
Ruby
let(:user) { create(:user) }

 

RSpec内で簡単に定義する

  • テストに特化したシンプルなケースならこちらでもOK。
Ruby
let(:params) { { name: 'Hanako', age: 20 } }
タイトルとURLをコピーしました