プロジェクトが進んで行くと、どんどんテストに時間が掛かるようになります。
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

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です