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

[Rails] RSpec memorandum: describe / context / it / subject / let / let!

rails-rspec
This article can be read in about 19 minutes.

Introduction.

I have been using Ruby for the first time in 3-4 years since February this year, and I have been writing about RSpec by mood without a solid understanding of each method, so I thought I would summarize it here.

 

Roles of describe, context, it, subject

describe :Target of the test. Class name or method unit.

  • Describe the class or method under test.
  • Use # for instance methods and . is conventionally used for instance methods.
  • Usually corresponds to a class name or instance method/class method. Often listed as the root of a hierarchical structure.

context: conditions and assumptions

  • Explain conditions and assumptions. Write the context according to the branch and the condition. Since it is a condition, it is easy to prefix “when” as the content of context.
  • Nesting is also acceptable, since it is difficult to read if the nesting is too deep.

it: specific behavior expected

  • A place to write the specific behavior you expect. Ideally, write “to be” as in an English sentence.
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

There can be multiple contexts within a description (when there are multiple conditions for the same test object), and contexts can be nested within contexts (for more detailed contextualization).

Context and it are not always 1:1, and you may write more than one it in a context. if you want to test multiple expected behaviors under the conditions of a 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: The central object of the test

  • The subject defines the common “subject (test object)” within the describe block. Basically, there is one subject for each describe block, and the subject is basically shared even if multiple contexts are written.
  • The subject can be written as simple as is_expected.to. It is used for simple tests that can be expressed in one line.
  • If it is likely to be more than two lines, or if you want to make the SUBJECT explicit, use it { is_expected.to ... } instead ofexpect(subject).to ....
  • You can also define a named subject like subject(:something), which is useful in cases where there are multiple contexts under describe and you may wonder what the subject is. This is useful in cases where there are multiple contexts under describe and you may wonder what the subject is.
Ruby
subject { user.full_name }
it { is_expected.to eq 'Dr. John Doe' }

With subect, the rspec described earlier can be written as follows.

ChangesDescription.
subject { order.status }Common return values are summarized in the subject
expect(order.status)is_expected.toFor a more concise writing style

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

 

 

Note on SUBJECT: Memorization

Thesubject is executed only once in each test (it block) and the result is remembered (memoized)

→ No matter how many times a SUBJECT is called in one test (IT), the result of the first execution is used.

 

If a subject is called in a before block, it is evaluated and cached with the parameters at that time.

→ Changing parameters later with let does not change the already cached results, so the intent of the update is not reflected.

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

So, for update-type tests, it is preferable not to call the subject in the before block, but to directly execute the required initial state generation by executing the described_class.call
→ Then, by callings the subject in each block, the result will reflect the latest parameters in that test.

 

 

Difference and usage of let, let!, before

Comparison itemsletLet!before
Execution timingWhen first called ( delayed evaluation )Always do it before you do it.Always do it before you do it.
revaluationCached (only once)same as aboveDitto (may have side effects)
treatmentDefine a value and reference it in the subject oritWhen you want to force evaluation and preparationFor pre-processing side effects (method calls, DB operations, etc.)
return valueDefined valueDefined value (but not normally used)No return value is used in the before block.
manner of writinglet(:user) { ... }let!(:user) { ... }before { ... }

Basically, use let!

Before: suitable for side effects only

  • Only side effects” in the sense that it does not generate or return any “value” but only executes a process (side effect) that changes the state of the application.
  • Side effect = changing external state with a method call (e.g., login, DB update, session change, etc.)
Ruby
before do
  sign_in user  # ログイン状態にする。何かの変数に代入して使うわけではない。
end

Therefore, for this kind of “processing only”, before is appropriate.

 

Test premise object creation: let! or before

Generating with “let! for references and before for non-references” will increase the readability of the test.

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! (immediate evaluation): always defined with the necessary preconditions.

  • Common assumptions used in any context / it (e.g., logged-in users, categories, etc.)
  • Value that is contained in the params and is required for the entire real
  • Processes that involve side effects on the assumption that they will be used ( create, build, external APIs, etc.)
Ruby
let!(:user) { create(:user) }
# この時点でDBにユーザーが作成されている(subjectやitが始まる前に)

 

let (delayed evaluation): use basic let!

The reader may ask, “When is this USER used? Is it needed in every test?” It is easy to get lost.

  • Don’t get too caught up in the “lazy evaluation (the variable is not evaluated (executed) until the moment it is actually called) = better performance”.
  • Using let to make it “unclear whether it is called or not” makes it difficult to read the intent of the test. Readability of the test and clarity of intent should be prioritized.
  • It’s not “referenced in it = let.” Think about generating explicitly with let! If it is a prerequisite entity for that test, and use let only if you want to avoid heavy processing used only for a few tests, to minimize cost with lazy evaluation.
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

Be careful when redefining variables with the same name in let
(e.g. let(:params) { params.merge(…)} will cause an infinite loop)

 

Use FactoryBot or define it in RSpec?

FactoryBot uses a predefined Factory (template) to generate objects.
Definition of Factory (usually under 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

Calling in RSpec

Create (save to 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

 

When bringing from FactoryBot

  • High commonality and reusability.
  • Even complex objects can be created easily.
Ruby
let(:user) { create(:user) }

 

Easily defined within RSpec

  • A simple case dedicated to testing is also acceptable here.
Ruby
let(:params) { { name: 'Hanako', age: 20 } }
Copied title and URL