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/ディレクトリを有効化する必要があるので注意)
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
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 操作のシミュレーションよりも処理が速くなる。
# spec/rails_helper.rb
RSpec.configure do |config|
config.include Devise::Test::IntegrationHelpers, type: :system
end
上記の設定により、前項のシステムスペックは以下のように書き換えられる。
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 ブロックとの違いは以下の通り。
- インスタンス変数ではなくローカル変数として作成したデータを参照可能となる
- テストデータの作成はテスト内で参照されたタイミングで実行される
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!(:note1) {
FactoryBot.create(:note,
project: project,
user: user,
message: "This is the first note.",
)
}
let! による即時読み込みは、このような問題を解決できるが、同じスコープ内の全テストを実行するたびに毎回実行されるという before ブロックによるテストデータ作成時と同じ問題が発生するので注意が必要。しかし、
認知負荷を防ぐために let! を使用するという考えもあるので、開発チームのルール・慣習により柔軟な使い分けが必要。
shared_context で共通のセットアップを切り出して再利用する
例えば、下記のテストでセットアップしている複数のテストデータの定義を別のファイルでも利用したくなった場合、 shared_context を使用して共通のセットアップを切り出すことで簡単に再利用することができる。
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
ディレクトリに共通のセットアップを切り出したファイルを作成する。
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
ディレクトリにファイルを作成することで行う。
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
カスタムマッチャファイルを改善することでこのテスト失敗時のメッセージをより読みやすくすることができる。
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
参考文献
この記事は以下の情報を参考にして執筆しました。