これはなに
RSpecを利用したコントローラの機能テストは、Rails4まではcontroller specで行われて来ました。しかしRails5からはrequest specで記述することが推奨され、assigns
とassert_template
の使用が非推奨となりました。
rails-controller-testing
gemを使用すればassigns
やassert_template
を使うことはできますが、やはりrequest specへ移行することが望ましいと考えられています。
これから新しく作成する Rails アプリケーションについては、 rails-controller-testing gem を追加するのはおすすめしません。 Rails チームや RSpec コアチームとしては、代わりに request spec を書くことを推奨します。
RSpec 3.5 がリリースされました!
私は上記の話を聞いたとき、「controller specからrequest specへ移行しないとダメなんだな」と漠然と理解しましたが、そもそもrequest specを書いたことが無かったので両者の違いがわかりませんでした。
request specで色々調べてみましたが出てくるのは「WebAPIのテスト」や「Capybaraを使用したインテグレーションテスト」のサンプルが多く、私が求めている情報とは少し異なっていました。
しかしeverydayRailsに正に移行の手引きと言える投稿がありました。
Replacing RSpec controller specs, part 1: Request specs
Replacing RSpec controller specs, part 2: Feature specs
この内容を参考に、既存のcontroller specをrequest specに置き換えていき、そのうえでどのような点に注意すべきかを見ていきたいと思います。
以後内容において、そもそも理解が間違っている点や修正すべき点などありましたら、ご指摘頂けたら幸いです。
何をテストすべきか
controller specで行うべきテストは主に以下の項目です(でした)。
- Webリクエストが成功したか
- 正しいページにリダイレクトされたか
- ユーザー認証が成功したか
- レスポンスのテンプレートに正しいオブジェクトが保存されたか
- ビューに表示されたメッセージは適切か
request specでもテストすべき内容に大きな変更はありません。
リクエストに対するレスポンスが仕様に沿っているか検証します。
しかし「レスポンスのテンプレートに正しいオブジェクトが保存されたか」とあるような
コントローラの内部実装に関わる点はテストすべきではありません。1
これはrequest specはリクエスト/レスポンスにのみ関心を持つブラックボックステストであるからです。
複数のリクエスト、複数のコントローラ
request specのドキュメントを読むと、単一のリクエストを指定する他に、複数のコントローラや複数のセッションで複数のリクエストを指定できるとあり、そのサンプルも例示してあります。
https://relishapp.com/rspec/rspec-rails/v/3-7/docs/request-specs/request-spec
しかしrequest specにおいて、一つのテストケースでは単一のリクエストとレスポンスを処理することが望ましいように思えます。
コントローラを横断し、複数のリクエストを投げる必要があるケースはfeature spec2に委ねるべきです。
※そもそもドキュメントの内容がRSpec2.6の頃と同じだったり、サンプル中でassigns
やrender_template
を使用していることから内容が古いのではないかと思います。
なぜassignsとassert_templateは使用すべきではないのか
これらのヘルパーを使用したテストはコントローラの実装に依存する為、脆弱であると考えられます。
コントローラのテストで注視すべき点はリクエストとレスポンスです。
そこにどのような変化があるかをテストすべきで、インスタンス変数の状態であったり、どのビューが呼び出されたかと言ったコントローラの実装に関わる点はテストすべきではないということです。
動作環境
rails 5.1.4
rspec-rails 3.7.2
今回使用したリポジトリ
https://github.com/t-kojima/controller-spec-to-request-spec
事前準備
Userモデルを作成し、ファクトリを定義しておきます。
rails generate scaffold User name:string email:string
class User < ApplicationRecord
validates :name, presence: true
end
FactoryBot.define do
factory :user do
name "hoge"
email "hoge@example.com"
trait :invalid do
name nil
end
end
factory :takashi, class: User do
name "Takashi"
email "takashi@example.com"
end
factory :satoshi, class: User do
name "Satoshi"
email "satoshi@example.com"
end
end
GET#index
まずはじめにindexページへアクセスするテストを見てみたいと思います。
describe UsersController, type: :controller do
describe 'GET #index' do
let(:users) { FactoryBot.create_list :user, 2 }
it 'リクエストが成功すること' do
get :index
expect(response.status).to eq 200
end
it 'indexテンプレートで表示されること' do
get :index
expect(response).to render_template :index
end
it '@usersが取得できていること' do
get :index
expect(assigns :users).to eq users
end
end
end
assigns
とrender_template
でエラーが出るので、request specに書き換えていきます。
describe UsersController, type: :request do
describe 'GET #index' do
before do
FactoryBot.create :takashi
FactoryBot.create :satoshi
end
it 'リクエストが成功すること' do
get users_url
expect(response.status).to eq 200
end
it 'ユーザー名が表示されていること' do
get users_url
expect(response.body).to include "Takashi"
expect(response.body).to include "Satoshi"
end
end
end
get :index
はget users_url
に置き換わります。(get '/users'
のようにパスを直接記載してもOK)
controller specではindexメソッドを呼んでいますが、request specではエンドポイントを指定する形になり、controllerに依存しない形になっています。
また、テンプレートやインスタンス変数を評価する代わりにresponse.body
を評価します。
GET#show
describe 'GET #show' do
context 'ユーザーが存在する場合' do
let(:takashi) { FactoryBot.create :takashi }
it 'リクエストが成功すること' do
get :show, params: { id: takashi }
expect(response.status).to eq 200
end
it 'showテンプレートで表示されること' do
get :show, params: { id: takashi }
expect(response).to render_template :show
end
it '@userが取得できていること' do
get :show, params: { id: takashi }
expect(assigns :user).to eq takashi
end
end
context 'ユーザーが存在しない場合' do
subject { -> { get :show, params: { id: 1 } } }
it { is_expected.to raise_error ActiveRecord::RecordNotFound }
end
end
describe 'GET #show' do
context 'ユーザーが存在する場合' do
let(:takashi) { FactoryBot.create :takashi }
it 'リクエストが成功すること' do
get user_url takashi.id
expect(response.status).to eq 200
end
it 'ユーザー名が表示されていること' do
get user_url takashi.id
expect(response.body).to include 'Takashi'
end
end
context 'ユーザーが存在しない場合' do
subject { -> { get user_url 1 } }
it { is_expected.to raise_error ActiveRecord::RecordNotFound }
end
end
基本的にindexと同じです。
テンプレートやインスタンス変数ではなくresponse.body
を評価します。
GET#new
describe 'GET #new' do
it 'リクエストが成功すること' do
get :new
expect(response.status).to eq 200
end
it 'newテンプレートで表示されること' do
get :new
expect(response).to render_template :new
end
it '@userがnewされていること' do
get :new
expect(assigns :user).to_not be_nil
end
end
describe 'GET #new' do
it 'リクエストが成功すること' do
get new_user_url
expect(response.status).to eq 200
end
end
ここではリクエストの成否のみテストできればよいです。
ビューに関わるテストは「フォームにName,Emailを入力してUserを作成する」というfeature specのシナリオでカバーできます。
GET#edit
describe 'GET #edit' do
let(:takashi) { FactoryBot.create :takashi }
it 'リクエストが成功すること' do
get :edit, params: { id: takashi }
expect(response.status).to eq 200
end
it 'editテンプレートで表示されること' do
get :edit, params: { id: takashi }
expect(response).to render_template :edit
end
it '@userが取得できていること' do
get :show, params: { id: takashi }
expect(assigns :user).to eq takashi
end
end
describe 'GET #edit' do
let(:takashi) { FactoryBot.create :takashi }
it 'リクエストが成功すること' do
get edit_user_url takashi
expect(response.status).to eq 200
end
it 'ユーザー名が表示されていること' do
get edit_user_url takashi
expect(response.body).to include 'Takashi'
end
it 'メールアドレスが表示されていること' do
get edit_user_url takashi
expect(response.body).to include 'takashi@example.com'
end
end
editもnewとあまり変わりはありません。name,emailがビューに表示されていることがテストできれば良いと思います。
POST#create
createアクションではパラメータが妥当な場合と不正な場合でテストを行います。
describe 'POST #create' do
context 'パラメータが妥当な場合' do
it 'リクエストが成功すること' do
post :create, params: { user: FactoryBot.attributes_for(:user) }
expect(response.status).to eq 302
end
it 'ユーザーが登録されること' do
expect do
post :create, params: { user: FactoryBot.attributes_for(:user) }
end.to change(User, :count).by(1)
end
it 'リダイレクトすること' do
post :create, params: { user: FactoryBot.attributes_for(:user) }
expect(response).to redirect_to User.last
end
end
context 'パラメータが不正な場合' do
it 'リクエストが成功すること' do
post :create, params: { user: FactoryBot.attributes_for(:user, :invalid) }
expect(response.status).to eq 200
end
it 'ユーザーが登録されないこと' do
expect do
post :create, params: { user: FactoryBot.attributes_for(:user, :invalid) }
end.to_not change(User, :count)
end
it 'newテンプレートで表示されること' do
post :create, params: { user: FactoryBot.attributes_for(:user, :invalid) }
expect(response).to render_template :new
end
it 'エラーが表示されること' do
post :create, params: { user: FactoryBot.attributes_for(:user, :invalid) }
expect(assigns(:user).errors.any?).to be_truthy
end
end
end
describe 'POST #create' do
context 'パラメータが妥当な場合' do
it 'リクエストが成功すること' do
post users_url, params: { user: FactoryBot.attributes_for(:user) }
expect(response.status).to eq 302
end
it 'ユーザーが登録されること' do
expect do
post users_url, params: { user: FactoryBot.attributes_for(:user) }
end.to change(User, :count).by(1)
end
it 'リダイレクトすること' do
post users_url, params: { user: FactoryBot.attributes_for(:user) }
expect(response).to redirect_to User.last
end
end
context 'パラメータが不正な場合' do
it 'リクエストが成功すること' do
post users_url, params: { user: FactoryBot.attributes_for(:user, :invalid) }
expect(response.status).to eq 200
end
it 'ユーザーが登録されないこと' do
expect do
post users_url, params: { user: FactoryBot.attributes_for(:user, :invalid) }
end.to_not change(User, :count)
end
it 'エラーが表示されること' do
post users_url, params: { user: FactoryBot.attributes_for(:user, :invalid) }
expect(response.body).to include 'prohibited this user from being saved'
end
end
end
パラメータが不正でエラーが発生した場合、controller specでは@user.errorsを直接参照していますが、request specでは'prohibited this user from being saved'がビューに表示されるかどうかをテストしています。
PUT#update
updateアクションでもcreateアクションと同様にパラメータが妥当な場合と不正な場合でテストを行います。
describe 'PUT #update' do
let(:takashi) { FactoryBot.create :takashi }
context 'パラメータが妥当な場合' do
it 'リクエストが成功すること' do
put :update, params: { id: takashi, user: FactoryBot.attributes_for(:satoshi) }
expect(response.status).to eq 302
end
it 'ユーザー名が更新されること' do
expect do
put :update, params: { id: takashi, user: FactoryBot.attributes_for(:satoshi) }
end.to change { User.find(takashi.id).name }.from('Takashi').to('Satoshi')
end
it 'リダイレクトすること' do
put :update, params: { id: takashi, user: FactoryBot.attributes_for(:satoshi) }
expect(response).to redirect_to User.last
end
end
context 'パラメータが不正な場合' do
it 'リクエストが成功すること' do
put :update, params: { id: takashi, user: FactoryBot.attributes_for(:user, :invalid) }
expect(response.status).to eq 200
end
it 'ユーザー名が変更されないこと' do
expect do
put :update, params: { id: takashi, user: FactoryBot.attributes_for(:user, :invalid) }
end.to_not change(User.find(takashi.id), :name)
end
it 'editテンプレートで表示されること' do
put :update, params: { id: takashi, user: FactoryBot.attributes_for(:user, :invalid) }
expect(response).to render_template :edit
end
it 'エラーが表示されること' do
put :update, params: { id: takashi, user: FactoryBot.attributes_for(:user, :invalid) }
expect(assigns(:user).errors.any?).to be_truthy
end
end
end
putメソッドではcontroller specとrequest specで若干異なります。(request specではparams
にidを含めない)
describe 'PUT #update' do
let(:takashi) { FactoryBot.create :takashi }
context 'パラメータが妥当な場合' do
it 'リクエストが成功すること' do
put user_url takashi, params: { user: FactoryBot.attributes_for(:satoshi) }
expect(response.status).to eq 302
end
it 'ユーザー名が更新されること' do
expect do
put user_url takashi, params: { user: FactoryBot.attributes_for(:satoshi) }
end.to change { User.find(takashi.id).name }.from('Takashi').to('Satoshi')
end
it 'リダイレクトすること' do
put user_url takashi, params: { user: FactoryBot.attributes_for(:satoshi) }
expect(response).to redirect_to User.last
end
end
context 'パラメータが不正な場合' do
it 'リクエストが成功すること' do
put user_url takashi, params: { user: FactoryBot.attributes_for(:user, :invalid) }
expect(response.status).to eq 200
end
it 'ユーザー名が変更されないこと' do
expect do
put user_url takashi, params: { user: FactoryBot.attributes_for(:user, :invalid) }
end.to_not change(User.find(takashi.id), :name)
end
it 'エラーが表示されること' do
put user_url takashi, params: { user: FactoryBot.attributes_for(:user, :invalid) }
expect(response.body).to include 'prohibited this user from being saved'
end
end
end
DELETE#destroy
最後はdestroyアクションです。
updateアクションと同様にdeleteメソッドの引数のみ注意して下さい。
describe 'DELETE #destroy' do
let!(:user) { FactoryBot.create :user }
it 'リクエストが成功すること' do
delete :destroy, params: { id: user }
expect(response.status).to eq 302
end
it 'ユーザーが削除されること' do
expect do
delete :destroy, params: { id: user }
end.to change(User, :count).by(-1)
end
it 'ユーザー一覧にリダイレクトすること' do
delete :destroy, params: { id: user }
expect(response).to redirect_to(users_url)
end
end
describe 'DELETE #destroy' do
let!(:user) { FactoryBot.create :user }
it 'リクエストが成功すること' do
delete user_url user
expect(response.status).to eq 302
end
it 'ユーザーが削除されること' do
expect do
delete user_url user
end.to change(User, :count).by(-1)
end
it 'ユーザー一覧にリダイレクトすること' do
delete user_url user
expect(response).to redirect_to(users_url)
end
end
JSON API
JSON APIについては触れてきませんでしたが、最後に少しだけ触れたいと思います。
controller specではresponse.body
をテストすることができません。(render_view
を呼ばない限りresponse.body
は""
になる)その為JSON APIのテストはrequest specで行うのが適切です。
indexアクションの場合は以下のような感じになるはずです。3
describe 'GET /users.json' do
let(:headers) do
{ 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
end
before do
FactoryBot.create :takashi
FactoryBot.create :satoshi
end
it 'リクエストが成功すること' do
get users_url, headers: headers
expect(response.status).to eq 200
end
it 'ユーザー一覧が取得できていること' do
get users_url, headers: headers
expect(response.body).to have_json_size 2
end
end
さいごに
scaffoldで生成されるアクションについては一通り置き換えを行ってみましたが、ほとんど変わらないような印象を持たれたのではないでしょうか。
参考元でも構造と構文に大きな違いは無いとしていますが、request specのアプローチにはより将来性があると結んでいます。
テストを書いている途中に「これはrequest spec?feature specで書くべき?」と悩む所がいくつかあったので、次はfeature specを書いてみたいと思います。
参考
RSpecでRequest Describer
RequestのRSpecを実装する
Changes to test controllers in Rails 5
Replacing RSpec controller specs, part 1: Request specs
Replacing RSpec controller specs, part 2: Feature specs