0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

すぐ使える!RSpec を DRY にするTIP集!

Last updated at Posted at 2025-07-19

RSpec を DRY にする TIP 集

サポートモジュールを作成する

例えば、以下のようなログイン処理が複数のシステムスペックで用いられている場合、ログイン処理に実装の変更が発生すると、その変更をすべてのスペックに反映する必要がある。

visit root_path
click_link "Sign in"
fill_in "Email", with: user.email
fill_in "Password", with: user.password
click_button "Log in"

このような問題を特定の処理をサポートモジュールに切り出すことで解決する。
(spec/support/ディレクトリを有効化する必要があるので注意)

spec/support/login_support.rb
module LoginSupport
  def sign_in_as(user)
    visit root_path
    click_link "Sign in"
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    click_button "Log in"
  end
end

# テストコードへincludeする
# テストコードごとに個別にincludeすることもできる
RSpec.configure do |config|
  config.include LoginSupport, type: :system
end
テストコードへincludeする
require 'rails_helper'
RSpec.describe "Projects", type: :system do
  # ユーザーは新しいプロジェクトを作成する
  scenario "user creates a new project" do
    user = FactoryBot.create(:user)
    sign_in_as user # サポートモジュールをincludeしているので、ここでログイン処理メソッドを呼び出せる

    expect {
      click_link "New Project"
      fill_in "Name", with: "Test Project"
      fill_in "Description", with: "Trying out Capybara"
      click_button "Create Project"
    }.to change(user.projects, :count).by(1)

    expect(page).to have_content "Project was successfully created"
    expect(page).to have_content "Test Project"
    expect(page).to have_content "Owner: #{user.name}"
  end
end

Devise のログイン処理ヘルパーを使用する方法

Devise を使用している場合は、提供されているログイン処理用のヘルパーメソッドを使用することで、即座にセッションを作成でき、サポートモジュールによる UI 操作のシミュレーションよりも処理が速くなる。

システムスペックでDeviseのログイン処理ヘルパーを使用する設定
# spec/rails_helper.rb
RSpec.configure do |config|
  config.include Devise::Test::IntegrationHelpers, type: :system
end

上記の設定により、前項のシステムスペックは以下のように書き換えられる。

システムスペックでDeviseのログイン処理ヘルパーを使用する設定
require 'rails_helper'
RSpec.describe "Projects", type: :system do
  # ユーザーは新しいプロジェクトを作成する
  scenario "user creates a new project" do
    user = FactoryBot.create(:user)
    sign_in user # Deviseのログイン処理ヘルパーを使用

    # 以下略
  end
end

実際のページ操作と Devise ヘルパーの挙動の違い

例えば実際のページ操作ではログイン後にホームページへリダイレクトされる副作用がある場合、上記の設定でテストを実行すると失敗する。
これは Devise のログイン処理ヘルパーはセッションの作成のみを行い、リダイレクトなどの副作用処理は行わないため。
その場合は追加の処理をログインヘルパーの後に記述する。

sign_in user

visit root_path

let による遅延読み込みでテストデータを作成する

before ブロックの内部に記述したテストデータを作成は、スコープ内のすべての describe と context の内部に書いたテストを実行するたびに毎回実行される。
これはテストに予期しない影響を及ぼす可能性があり、またテストによっては使用しないデータを作成することとなりテスト速度の低下につながる。

このような問題を解決するために、let を使用して遅延読み込みでテストデータを作成することができる。
before ブロックとの違いは以下の通り。

  • インスタンス変数ではなくローカル変数として作成したデータを参照可能となる
  • テストデータの作成はテスト内で参照されたタイミングで実行される
let による遅延読み込みのテストデータ作成例
require 'rails_helper'

RSpec.describe Task, type: :model do
  let(:project) { FactoryBot.create(:project) }

  # プロジェクトと名前があれば有効な状態であること
  it "is valid with a project and name" do
    task = Task.new(
      project: project, # ローカル変数として参照可能
      name: "Test task",
    )
    expect(task).to be_valid
  end

  # ここから下のテストではprojectが参照されないのでテストデータは作成されない

  # プロジェクトがなければ無効な状態であること
  it "is invalid without a project" do
    task = Task.new(project: nil)
    task.valid?
    expect(task.errors[:project]).to include("must exist")
  end

  # 名前がなければ無効な状態であること
  it "is invalid without a name" do
    task = Task.new(name: nil)
    task.valid?
    expect(task.errors[:name]).to include("can't be blank")
  end
end

let! による即時読み込み

let による遅延読み込みは、テスト内で参照されたタイミングでテストデータを作成するがそれによって不都合が発生するケースも存在する。
例えば、以下のようなテストの場合、テスト内で let で作成したデータが参照されていないので必要なテストデータが作成されていない状態となってしまう。

作成したデータを検索するテスト
RSpec.describe Note, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }

  describe 'search message for a term' do
    let(:note1) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "This is the first note.",
      )
    }
    let(:note2) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "This is the second note.",
      )
    }
    let(:note3) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "First, preheat the oven.",
      )
    }

    # 一致するデータが1件も見つからないとき
    context 'when no match is found' do
      # 空のコレクションを返すこと
      it 'returns an empty collection' do
        expect(Note.search('message')).to be_empty
        # let による遅延読み込みのため、ここではテストデータは作成されていないので、Note.count は 0 となりエラーとなる
        # 本来はテストデータが作成された上でデータがヒットしないことをテストしたい
        expect(Note.count).to eq 3
      end
    end
  end
end

このような場合は let! を使用して即時読み込みでテストデータを定義することで必要なテストデータを参照不要で事前に作成することができる。

let! による即時読み込みのテストデータ作成例
let!(:note1) {
  FactoryBot.create(:note,
    project: project,
    user: user,
    message: "This is the first note.",
  )
}

let! による即時読み込みは、このような問題を解決できるが、同じスコープ内の全テストを実行するたびに毎回実行されるという before ブロックによるテストデータ作成時と同じ問題が発生するので注意が必要。しかし、
認知負荷を防ぐために let! を使用するという考えもあるので、開発チームのルール・慣習により柔軟な使い分けが必要。

shared_context で共通のセットアップを切り出して再利用する

例えば、下記のテストでセットアップしている複数のテストデータの定義を別のファイルでも利用したくなった場合、 shared_context を使用して共通のセットアップを切り出すことで簡単に再利用することができる。

このファイルの複数のletブロックを共通化したい
require 'rails_helper'

RSpec.describe TasksController, type: :controller do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }
  let(:task) { project.tasks.create!(name: "Test task") }

  describe "#show" do
    # 略
  end

  describe "#create" do
    # 略
  end
end

以下のようにspec/support/contextsディレクトリに共通のセットアップを切り出したファイルを作成する。

spec/support/contexts/project_setup.rb
RSpec.shared_context "project setup" do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }
  let(:task) { project.tasks.create!(name: "Test task") }
end

上記を各ファイルで include することで、簡単に利用可能となる。

require 'rails_helper'

RSpec.describe TasksController, type: :controller do
  include_context "project setup" # 共通のセットアップを切り出したファイルをincludeする

  describe "#show" do
    # 略
  end

  describe "#create" do
    # 略
  end
end

カスタムマッチャを作成してテストをより読みやすくする

例えば、以下のようなテストが存在するコントローラスペック上で、レスポンスの Content-Type が JSON であることを他のテストでも何度も検証している場合、カスタムマッチャを作成してテストをより読みやすくすることができる。

# JSON 形式でレスポンスを返すこと
it "responds with JSON formatted output" do
  new_task = { name: "New test task" }
  sign_in @user
  post :create, format: :json, params: { project_id: @project.id, task: new_task }
  expect(response.content_type).to include "application/json" # 通常のマッチャによる検証
end

カスタムマッチャの作成は、spec/support/matchersディレクトリにファイルを作成することで行う。

spec/support/matchers/content_type.rb
RSpec::Matchers.define :have_content_type do |expected|
  match do |actual|
    content_types = {
      html: "text/html",
      json: "application/json",
    }

    # actualは検証対象のオブジェクト
    actual.content_type.include? content_types[expected.to_sym]
  end
end

このカスタムマッチャを使用することで、以下のようにテストをより読みやすくすることができる。

# JSON 形式でレスポンスを返すこと
it "responds with JSON formatted output" do
  new_task = { name: "New test task" }
  sign_in @user
  post :create, format: :json, params: { project_id: @project.id, task: new_task }
  expect(response).to have_content_type(:json) # カスタムマッチャによる検証
end

カスタムマッチャ検証によるテスト失敗のメッセージを読みやすくする

上記で作成したカスタムマッチャは、テスト失敗時に以下のようなメッセージを表示する。

# 期待値をcsvにした場合のテスト失敗時のメッセージ
Failures:
   1) TasksController#show responds with JSON formatted output
      Failure/Error: expect(response).to have_content_type :csv
        expected #<ActionDispatch::TestResponse:0x007fc6d1d353c0
        @mon_owner=nil, @mon_count=0,
        @mon_mutex=#<Thread::Mu...:Headers:0x007fc6d1d0f170
        @req=#<ActionController::TestRequest:0x007fc6d1d356b8 ...>>,
        @variant=[]>> to have content type :csv

カスタムマッチャファイルを改善することでこのテスト失敗時のメッセージをより読みやすくすることができる。

spec/support/matchers/content_type.rb
RSpec::Matchers.define :have_content_type do |expected|
  match do |actual|
    begin
      actual.content_type.include? content_type(expected)
    rescue ArgumentError
      false
    end
  end

  # failure_messageブロックでテスト失敗時のメッセージをカスタマイズできる
  failure_message do |actual|
    "Expected \"#{content_type(actual.content_type)} " +
    "(#{actual.content_type})\" to be Content Type " +
    "\"#{content_type(expected)}\" (#{expected})"
  end

  # こちらはnot_toなど否定形のマッチャで失敗した場合のメッセージのカスタマイズ
  failure_message_when_negated do |actual|
    "Expected \"#{content_type(actual.content_type)} " +
    "(#{actual.content_type})\" to not be Content Type " +
    "\"#{content_type(expected)}\" (#{expected})"
  end

  def content_type(type)
    types = {
      html: "text/html",
      json: "application/json",
    }
    types[type.to_sym] || "unknown content type"
  end
end

# エイリアスを定義して、別のマッチャ名でも使用できるようにする
RSpec::Matchers.alias_matcher :be_content_type , :have_content_type
修正後の失敗メッセージ
# 期待値をhtmlにした場合のテスト失敗時のメッセージ
Failures:
  1) TasksController#show responds with JSON formatted output
     Failure/Error: expect(response).to have_content_type :html
       Expected "unknown content type (application/json; charset=utf-8)"
       to be Content Type "text/html" (html)

aggregate_failures で複数のテスト失敗をまとめて表示する

例えば以下のような複数のエクスペクテーションを持つテストの場合、先に記述されているエクスペクテーションが失敗した場合、後に記述されているエクスペクテーションは実行されずエラーの詳細が表示されない。
以下のような場合は二つ目のエクスペクテーションのエラーメッセージが表示されればすぐに未ログインであることが原因であることがわかるので、デバッグが容易になる。

it "responds successfully" do
  # sign_in @user
  get :index
  expect(response).to be_successful # ログイン処理をコメントアウトしてるのでここで失敗
  expect(response).to have_http_status "200" # ここは実行されず原因がわからない
end

このようなケースの場合、aggregate_failuresブロックを使用することでブロック内に含めたすべてのエクスペクテーションを実行してエラーを出力できる。

it "responds successfully" do
  # sign_in @user
  get :index
  aggregate_failures do
    expect(response).to be_successful
    expect(response).to have_http_status "200"
  end
end
修正後の失敗メッセージ
Failures:
  1) ProjectsController#index as an authenticated user responds
  successfully
     Got 2 failures from failure aggregation block.
     # ./spec/controllers/projects_controller_spec.rb:14:in `block (4
     levels) in <main>'
     
    1.1) Failure/Error: expect(response).to be_successful
           expected `#<ActionDispatch::TestResponse:0x0000000119cf28 ...省略...>.successful?` to be truthy, got fals
         # ./spec/controllers/projects_controller_spec.rb:15:in `block
         (5 levels) in <main>'
    # ここで認証エラーであることがわかる
    1.2) Failure/Error: expect(response).to have_http_status "200"
           expected the response to have status code 200 but it was 302
         # ./spec/controllers/projects_controller_spec.rb:16:in `block
         (5 levels) in <main>'

ただし、注意点として、aggregate_failuresはエクスペクテーションのみに作用するので、例えばシステムスペックのヘルパーであるclick_linkなどでなんらかのエラーが発生した場合は、その時点でエラーが発生、テストが中断しその後のエクスペクテーションは実行されない。

テストを抽象化して可読性を改善する

例えば、以下のような統合テストの場合、機能の拡張などでタスクの完了状態の切り替えのエクスペクテーションがどんどん増えてしまった時に確認項目が羅列した読みづらいテストになってしまう。

# ユーザーがタスクの状態を切り替える
scenario "user toggles a task", js: true do
  # セットアップとログインは省略 ...

  check "Finish RSpec tutorial"
  expect(page).to have_css "label#task_#{task.id}.completed"
  expect(task.reload).to be_completed
  # 機能拡張した場合、expectが追加されていく

  uncheck "Finish RSpec tutorial"
  expect(page).to_not have_css "label#task_#{task.id}.completed"
  expect(task.reload).to_not be_completed
 end

このようなケースではテストの部分ごとに抽象化メソッドを作成し分離することで検証項目をまとめて一括で管理し、かつテスト本体の可読性を改善することができる。

require 'rails_helper'

RSpec.describe "Tasks", type: :system do
  let(:user) { FactoryBot.create(:user) }
  let(:project) {
    FactoryBot.create(
      :project,
      name: "RSpec tutorial",
      owner: user
    )
  }
  let(:task) { project.tasks.create!(name: "Finish RSpec tutorial") }

  # テスト本体は内部の検証内容を記述せず可読性を上げる
  # ユーザーがタスクの状態を切り替える
  scenario "user toggles a task", js: true do
    sign_in user
    go_to_project "RSpec tutorial"

    complete_task "Finish RSpec tutorial"
    expect_complete_task "Finish RSpec tutorial"

    undo_complete_task "Finish RSpec tutorial"
    expect_incomplete_task "Finish RSpec tutorial"
  end

  def go_to_project(name)
    visit root_path
    click_link name
  end

  def complete_task(name)
    check name
  end

  def undo_complete_task(name)
    uncheck name
  end

  # エクスペクテーションはメソッドで一括管理する
  # 検証項目が増えたらこのヘルパーメソッドのみを変更すれば良い
  def expect_complete_task(name)
    aggregate_failures do
      expect(page).to have_css "label.completed", text: name
      expect(task.reload).to be_completed
    end
  end

  def expect_incomplete_task(name)
    aggregate_failures do
      expect(page).to_not have_css "label.completed", text: name
      expect(task.reload).to_not be_completed
    end
  end
end

参考文献

この記事は以下の情報を参考にして執筆しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?