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 methodsand
.
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.
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
# 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: The central object of the test
- The
subject
defines the common “subject (test object)” within thedescribe
block. Basically, there is onesubject
for each describe block, and thesubject
is basically shared even if multiplecontexts
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, useit { is_expected.to ... } instead of
expect(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.
subject { user.full_name }
it { is_expected.to eq 'Dr. John Doe' }
With subect, the rspec described earlier can be written as follows.
Changes | Description. |
subject { order.status } | Common return values are summarized in the subject |
expect(order.status) → is_expected.to | For a more concise writing style |
# 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.
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 items | let | Let! | before |
Execution timing | When first called ( delayed evaluation ) | Always do it before you do it . | Always do it before you do it . |
revaluation | Cached (only once) | same as above | Ditto (may have side effects) |
treatment | Define a value and reference it in the subject or it | When you want to force evaluation and preparation | For pre-processing side effects (method calls, DB operations, etc.) |
return value | Defined value | Defined value (but not normally used) | No return value is used in the before block. |
manner of writing | let(: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.)
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.
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.)
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.
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
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/
)
# 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)
# 作成(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.
let(:user) { create(:user) }
Easily defined within RSpec
- A simple case dedicated to testing is also acceptable here.
let(:params) { { name: 'Hanako', age: 20 } }