プロジェクトが進んで行くと、どんどんテストに時間が掛かるようになります。
let_it_be(Gem)も良さそうですが、その前に共通化で可読性を上げたり、
使われない処理が走らないようにRSpecを見直しました。
結論、subjectやlet(遅延実行)を上手く使うと可読性が上がる。
挙動やトランザクション範囲を把握しておくと速度を上げられる。
(1つ1つは小さいけど、数や回数の積み上げの影響が大きい)
お作法や共通化の基本
RSpecは上から順番に処理するので、
定義した参照先(shared_contextやshared_examples_for)が
参照元(include_contextやit_behaves_like)よりも前にないとエラーになる。
ゆえに、テストケースや内容(条件や状態作成以外)は、下から上に書いて行く事になります。
describe: テスト対象(リクエスト先や目的単位で分けるの良さそう) subject ← is_expected: 実行したいリクエストを定義(1つしか定義できない。値はletで渡せる) context: テスト内容(コメントに条件や状態を書くのが良さそう) shared_examples_for ← it_behaves_like: 条件や状態で分岐(contextの数を減らせる) shared_context ← include_context: 条件を満たす処理(共有できる) let(またはlet!): 値や処理した結果を保持(再定義は無視される。定義漏れはエラーになるので安全) before: テスト前に実行したい処理(トランザクション内。トランザクション外はbefore(:all)) after: テスト後に実行したい処理(トランザクション内。トランザクション外はafter(:all)) it: 期待値を記載して、ここまでに作り上げた条件や状態でリクエストして結果を検証
spec/requests/top_spec.rb
describe 'GET #index' do subject { get root_path } # テスト内容 shared_examples_for 'ToOK' do it '成功ステータス' do is_expected.to eq(200) end end # テストケース context '未ログイン' do it_behaves_like 'ToOK' end context 'ログイン中' do include_context 'ログイン処理' it_behaves_like 'ToOK' end context 'ログイン中(削除予約済み)' do include_context 'ログイン処理', :user_destroy_reserved it_behaves_like 'ToOK' end end
supportに入れれば、他のテストからも参照可能。
※spec/rails_helper.rbの下記コメントアウトを外す必要がある。
> Dir[Rails.root.join(‘spec’, ‘support’, ‘**’, ‘*.rb’)].each { |f| require f }
spec/support/user_contexts.rb
shared_context 'ログイン処理' do |target = :user, use_image = false| let(:image) { use_image ? fixture_file_upload(TEST_IMAGE_FILE, TEST_IMAGE_TYPE) : nil } let!(:user) { FactoryBot.create(target, image: image) } include_context '画像削除処理' if use_image before { sign_in user } end shared_context '画像削除処理' do after do user.remove_image! user.save! end end
subjectでまとめる
it毎に同じリクエストを毎回書かなくて良くなる。
is_expectedは、expect(subject)なので、リクエストとexpect(response)を1行で書ける。
spec/requests/users/registrations_spec.rb
describe 'GET #new' do + subject { get new_user_registration_path } + shared_examples_for 'ToOK' do it '成功ステータス' do - get new_user_registration_path - expect(response).to be_successful + is_expected.to eq(200) end end shared_examples_for 'ToTop' do |alert, notice| it 'トップページにリダイレクト' do - get new_user_registration_path - expect(response).to redirect_to(root_path) + is_expected.to redirect_to(root_path)
ただ、be_successfulだとエラーになったので、eq(200)に変更。
Failure/Error: is_expected.to be_successful expected 200 to respond to `successful?`
パラメータを渡す
letで定義した値をsubjectで使える。
ただ、letは値を再定義しても無視される(最初のが使われる)
また、itの中には書けないので、shared_examples_forを分ける必要がある。
spec/requests/users/auth/passwords_spec.rb
describe 'GET #edit' do + subject { get edit_user_auth_password_path(reset_password_token: reset_password_token, redirect_url: redirect_url) } + shared_examples_for 'ToOK' do + let(:redirect_url) { FRONT_SITE_URL } - it '[リダイレクトURLがある]指定URL(成功パラメータ)にリダイレクト' do + it '指定URL(成功パラメータ)にリダイレクトする' do - get edit_user_auth_password_path(reset_password_token: reset_password_token, redirect_url: FRONT_SITE_URL) - expect(response).to redirect_to("#{FRONT_SITE_URL}?reset_password_token=#{reset_password_token}") + is_expected.to redirect_to("#{FRONT_SITE_URL}?reset_password_token=#{reset_password_token}") end + end + + shared_examples_for 'リダイレクトURLがない' do + let(:redirect_url) { nil } - it '[リダイレクトURLがない]失敗ステータス。対象項目が一致する' do + it '失敗ステータス。対象項目が一致する' do - get edit_user_auth_password_path(reset_password_token: reset_password_token, redirect_url: nil) - expect(response).to have_http_status(422) + is_expected.to eq(422)
let(:redirect_url)をsubject(:redirect_url)にするとエラーになりました。
NoMethodError: undefined method `get?' for nil:NilClass
インスタンス変数で妥協
letは未定義だとエラーになるので、安全で良いのですが、letでやろうとすると、
必要以上に分岐が多くなり、逆にコード量が増えて可読性が下がるケースもあります。
今回は、あきらめて、インスタンス変数で渡す事にしました。
欠点は変数の記載を忘れてもエラーにならない。nilが渡される事。
テストは落ちる事が多いですが、ハマるかも。
spec/requests/users/auth/confirmations_spec.rb
describe 'GET #show' do + subject { get user_auth_confirmation_path(confirmation_token: confirmation_token, redirect_url: @redirect_url) } + shared_examples_for 'OK' do let!(:start_time) { Time.now.utc - 1.second } it '[リダイレクトURLがある]確認日時が現在日時に変更される' do - get user_auth_confirmation_path(confirmation_token: confirmation_token, redirect_url: valid_redirect_url) + @redirect_url = FRONT_SITE_URL + subject expect(User.find(send_user.id).confirmed_at).to be_between(start_time, Time.now.utc) end it '[リダイレクトURLがない]確認日時が現在日時に変更される' do - get user_auth_confirmation_path(confirmation_token: confirmation_token, redirect_url: nil) + @redirect_url = nil + subject expect(User.find(send_user.id).confirmed_at).to be_between(start_time, Time.now.utc) end
ちなみに、その行では単に実行するだけなら、is_expectedを使わずsubjectだけの方が無駄がなさそう。
let(遅延実行)を上手く使う
結局の所、it数分だけDB更新(トランザクションを貼ってロールバック)が走る。
before(= before(:each))も同様、before(:all)はトランザクション外で1回で済むが、
ゴミが残るので、after(:all)で明示的に元に戻す必要がある。
shared_contextをletに変更
include_context → shared_context(let!)を、let → letに変更した例。
下記では実行回数は変わらないので、速度は変わらなそうだけど、
shared_contextが無くなった分だけ、見やすくなった。
let!だとその段階で実行されるが、letの場合は実際に使われた時(今回はexpect(user))の直前で実行される。
spec/models/user_spec.rb
describe 'validates :name' do - shared_context 'データ作成' do |name| - let!(:user) { FactoryBot.build(:user, name: name) } - end + let(:user) { FactoryBot.build(:user, name: name) } shared_examples_for 'ToOK' do it 'OK' do expect(user).to be_valid end end shared_examples_for 'ToNG' do it 'NG' do expect(user).not_to be_valid end end context 'ない' do - include_context 'データ作成', '' + let(:name) { nil } it_behaves_like 'ToNG' end context '最小文字数よりも少ない' do - include_context 'データ作成', 'a' * (Settings['user_name_minimum'] - 1) + let(:name) { 'a' * (Settings['user_name_minimum'] - 1) } it_behaves_like 'ToNG' end
letの方が良いケース
今まで、バグりやすいという記事を見て、あえてlet!に統一していたが、
実際に書いてみるとletの方が読みやすかったり、処理回数が減ったりで利点の方が大きかった。
例えば、下記のletをlet!にすると、valid_attributes(new_user)が欲しいケースで、exist_userまで作成されてしまう。
spec/requests/users/registrations_spec.rb
describe 'POST #create' do subject { post create_user_registration_path, params: { user: attributes } } let(:new_user) { FactoryBot.attributes_for(:user) } let(:exist_user) { FactoryBot.create(:user) } let(:valid_attributes) { { name: new_user[:name], email: new_user[:email], password: new_user[:password] } } let(:invalid_attributes) { { name: exist_user.name, email: exist_user.email, password: exist_user.password } } <省略> shared_examples_for '[未ログイン]有効なパラメータ' do let(:attributes) { valid_attributes } it_behaves_like 'OK' it_behaves_like 'ToLogin', nil, 'devise.registrations.signed_up_but_unconfirmed' end
beforeの方が良いケース
下記で、let!(:exist_user)に!が無かったら、参照元がない為、実行されず、意図したテストにならなくなってしまう。
spec/models/user_spec.rb
describe 'validates :code' do let(:user) { FactoryBot.build(:user, code: code) } let(:valid_code) { Digest::MD5.hexdigest(SecureRandom.uuid) } shared_examples_for 'ToNG' do it 'NG' do expect(user).not_to be_valid end end context '重複' do let!(:exist_user) { FactoryBot.create(:user, code: code) } let(:code) { valid_code } it_behaves_like 'ToNG' end
このケースでは、exist_userは参照してないので、before使った方が良い。
before { FactoryBot.create(:user, code: code) }
let!の方が良いケース
下記のstart_timeは参照された時ではなく、
コマンド実行前の値を残しておきたので、let!でないといけない。
DBにより1秒未満の誤差が出る為、1秒減らしている。
結果、1秒掛からない処理ではどちらでも成功しますが。
spec/models/user_spec.rb
context '削除依頼日時' do let!(:start_time) { Time.current - 1.second } it '現在日時に変更される' do user.set_destroy_reserve expect(user.destroy_requested_at).to be_between(start_time, Time.current) end end
let!でもダメなケース
そもそもかもですが、let!(:before_user) { user }だと、
subjectが呼ばれた後にuserが更新される場合(下記では更新されないケースだけど)
before_userの値が更新されてしまう。単にbefore_userがuserを参照しているから。
itの中で、before_user = userとした場合も同様。
なので、必要な値を明示的に保存しておく。
spec/requests/users/registrations_spec.rb
shared_examples_for 'NG' do - let!(:before_user) { user } + let!(:before_destroy_requested_at) { user.destroy_requested_at } + let!(:before_destroy_schedule_at) { user.destroy_schedule_at } it '削除依頼日時・削除予定日時が変更されない' do subject - expect(user.destroy_requested_at).to eq(before_user.destroy_requested_at) - expect(user.destroy_schedule_at).to eq(before_user.destroy_schedule_at) + expect(user.destroy_requested_at).to eq(before_destroy_requested_at) + expect(user.destroy_schedule_at).to eq(before_destroy_schedule_at) end end
itをまとめて実行回数を減らす
subjectやrenderの回数を減らせば、速度向上が見込める。シンプルになるので可読性が上がる。
デメリットはitを細かく書けない事だけど、それそれにコメント入れるとかでも十分かも。
ソース読めば解るよね。という発想は避けたい。将来の自分が苦労する(=他の人も苦労する)
spec/views/layouts/application.html.erb_spec.rb
RSpec.describe 'layouts/application', type: :view do shared_examples_for '未ログイン表示' do - it 'ログインのパスが含まれる' do + it '対象のパスが含まれる' do render - expect(rendered).to include("\"#{new_user_session_path}\"") + expect(rendered).to include("\"#{new_user_session_path}\"") # ログイン - end - it 'アカウント登録のパスが含まれる' do - render - expect(rendered).to include("\"#{new_user_registration_path}\"") + expect(rendered).to include("\"#{new_user_registration_path}\"") # アカウント登録 end - it '登録情報変更のパスが含まれない' do + it '対象のパスが含まれない' do render - expect(rendered).not_to include("\"#{edit_user_registration_path}\"") + expect(rendered).not_to include("\"#{edit_user_registration_path}\"") # 登録情報変更 - end - it 'ログアウトのパスが含まれない' do - render - expect(rendered).not_to include("\"#{destroy_user_session_path}\"") + expect(rendered).not_to include("\"#{destroy_user_session_path}\"") # ログアウト end end
参考までに、今回のコミット内容
※nilと空で挙動が違ったりしたので、テストケースを追加しています。(DeviseMailerのテストも)
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/ab91a582a096015f4731b67af3325584bf68bd30
“RSpecをリファクタリングして可読性と速度を上げる” に対して2件のコメントがあります。