Rails5でコントローラのテストをController specからRequest specに移行する

これはなに

RSpecを利用したコントローラの機能テストは、Rails4まではcontroller specで行われて来ました。しかしRails5からはrequest specで記述することが推奨され、assignsassert_templateの使用が非推奨となりました。
rails-controller-testinggemを使用すればassignsassert_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の頃と同じだったり、サンプル中でassignsrender_templateを使用していることから内容が古いのではないかと思います。

なぜassignsassert_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

app/models/user.rb
class User < ApplicationRecord
    validates :name, presence: true
end
spec/factories/users.rb
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ページへアクセスするテストを見てみたいと思います。
index-001.PNG

controller_spec
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

assignsrender_templateでエラーが出るので、request specに書き換えていきます。

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 :indexget users_urlに置き換わります。(get '/users'のようにパスを直接記載してもOK)
controller specではindexメソッドを呼んでいますが、request specではエンドポイントを指定する形になり、controllerに依存しない形になっています。
また、テンプレートやインスタンス変数を評価する代わりにresponse.bodyを評価します。

GET#show

controller_spec
  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
request_spec
  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

controller_spec
  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
request_spec
  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

controller_spec
  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
request_spec
  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アクションではパラメータが妥当な場合と不正な場合でテストを行います。

controller_spec
  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
request_spec
  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’がビューに表示されるかどうかをテストしています。

create-001.PNG

PUT#update

updateアクションでもcreateアクションと同様にパラメータが妥当な場合と不正な場合でテストを行います。

controller_spec
  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を含めない)

request_spec
  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メソッドの引数のみ注意して下さい。

controller_spec
  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
request_spec
  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

request_spec
  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


  1. 後述:なぜassignsとassert_templateは使用すべきではないのか 

  2. Rspec3.7からはSystemSpecが推奨のようです 

  3. json_specを使っています