RSpecでRailsアプリのコントローラーをテストしてみる
前回はRailsアプリのモデルテストについて書いたので、 今回はコントローラーのテストについて説明していきます。
コンローラースペックを書き始める
早速、コントローラーのテストを作成していきたいと思います。
# terminal $ rails g rspec:controller pins
上のコマンドを打つことで、specフォルダ内にcontrollersフォルダが作成され、 その中に、articles_controller_spec.rbが作成されます。
ここでもモデルテストの時と同様に、rspec用に僕のアプリ(github参照)をサンプルとしてテストの紹介をしていきます。 今一度説明しておきますと、このアプリには、記事を登録できるArticleモデルがあり、登録したUserのみがArticleを作成することができるようになっています。 また、未登録のゲストユーザーは、indexのページにしかアクセスできないようになっています。
それでは作成されたファイルを確認してみましょう。
# rspec_test/spec/controllers/articles_controller_spec.rb require 'rails_helper' RSpec.describe ArticlesController, type: :controller do end
となっていると思います。 テストを書く前に、まずはこのアプリのコントローラーファイルを見ていきたいと思います。
# rspec_test/app/controllers/articles_controller.rb class ArticlesController < ApplicationController before_action :authenticate_user!, except: [:index] before_action :article_owner?, only: [:edit, :update, :destroy] def index @articles = Article.all end def show @article = Article.find(params[:id]) end def new @article = Article.new end def create @article = Article.new(article_params) @article.user_id = current_user.id respond_to do |format| if @article.save format.html { redirect_to article_path(@article), notice: "You successfully created a new article." } else format.html { redirect_to new_article_path } end end end def edit @article = Article.find(params[:id]) end def update @article = Article.find(params[:id]) respond_to do |format| if @article.update(article_params) format.html { redirect_to article_path, notice: "You successfully updated your article."} else format.html { redirect_to edit_article_path } end end end def destroy @article = Article.find(params[:id]) @article.destroy redirect_to root_path end private def article_owner? @article = Article.find(params[:id]) unless @article.user_id == current_user.id redirect_to root_path end end def article_params params.require(:article).permit(:title, :text, :user_id) end end
コントローラーでテストでは、 ページの表示が正常になされているかどうか 要素の受け渡しが正常になされているかどうか 権限が有効になっていて、且つそれが正常になされているかどうか といった点を点検していきます。
各アクションごとに点検していくので、その分長くなっていきますし、冗長さに退屈を感じてしまうかもしれませんが、中身は至ってシンプルなので理解しやすいと思います。
権限のないページの、シンプルなテスト
まずは、controller#indexのテストをしていきたいと思います。 indexのページの仕事内容は、上のコードからもわかるように、データベースに格納されたArticlesテーブルのレコードを全て引っ張り出してくる、というものです。 ですので、ここで重要なのは、ページが正常にひらけているかどうか、という点です。
# rspec_test/app/controllers/articles_controller.rb RSpec.describe ArticlesController, type: :controller do describe "#index" do # 正常なレスポンスか? it "responds successfully" do get :index expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do get :index expect(response).to have_http_status "200" end end end
上記のコードでは、まずはじめに、 indexへのアクセスに対して正常なレスポンスが返ってきているかをテストしています。 indexをgetし、期待したresponseがbe_successであったかどうか、ということです。
次に、 indexのアクセスに対して返ってきたレスポンスが200レスポンスであったかどうかをテストしています。 indexをgetし、期待したresponseがhttp_statusである200をhaveしているかどうか、ということです。
今回テストするアプリのcontroller#indexのテストは以上となります。 非常に簡単だと思いませんか。
しかしながら、ここまでシンプルなのはindexだけです。 indexは、Deviseで生成されたuserの権限とは関係のないページでしたので、比較的分量も少なかったですが、ここに権限の関係が入ってくると、少しずつ記述が長くなっていきます。
権限のあるページの、少し長めのテスト
今度はcontroller#showのテストを実施していきます。
# rspec_test/spec/controllers/articles_controller_spec.rb describe "#show" do # 正常なレスポンスか? it "responds successfully" do get :show expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do get :show expect(response).to have_http_status "200" end end
showのテストをindexと同じ内容にすると、以下の内容のエラーが発生します。
# terminal Failures: 1) ArticlesController#show responds successfully Failure/Error: get :show ActionController::UrlGenerationError: No route matches {:action=>"show", :controller=>"articles"} # ./spec/controllers/articles_controller_spec.rb:17:in `block (3 levels) in <top (required)>' 2) ArticlesController#show returns a 200 response Failure/Error: get :show ActionController::UrlGenerationError: No route matches {:action=>"show", :controller=>"articles"} # ./spec/controllers/articles_controller_spec.rb:21:in `block (3 levels) in <top (required)>'
ここではshowの性質について考える必要があります。 そもそも、showのページでは何が表示されることになるのでしょうか?
articles_controller.rbによると、showのアクションでは、 データベースにあるarticlesテーブルから、指定されたidを有するレコードを抽出するように指示しています(以下コード)。
# articles_controller.rb def show @article = Article.find(params[:id} end
つまり、このアクションによって呼ばれるページは、articlesテーブルのレコードを抽出するためのidを欲しがっているわけです。 この問題を解決するためには、テストで指定したページであるshowにparams[id]を渡してあげれば良いのです。
rspec_test/spec/controllers/articles_controller_spec.rb describe "#show" do # 正常なレスポンスか? it "responds successfully" do get :show, params: {id: id} expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do get :show, params: {id: id} expect(response).to have_http_status "200" end end
上記のようにidを書けば良いのはわかりましたが、ここで、そもそもarticleのインスタンスが存在しないことに気がつきます。 Article.newをすれば良いわけですが、showのテストの中には、 it "responds successfully"・・・と、it "returns a 200 response"・・・のテストの2種類があります。 1つ1つのためにそれぞれarticleのインスタンスを書くのは、少し面倒なので、 モデルスペックの方で利用したbeforeを活用していきましょう。
# rspec_test/spec/controllers/articles_controller_spec.rb describe "#show" do before do @article = Article.new( title: "加藤純一", text: "加藤純一? 神", ) end it "responds successfully" do get :show, params: {id: @article.id} expect(response).to be_success end it "returns a 200 response" do get :show, params: {id: @article.id} expect(response).to have_http_status "200" end end
Articleのインスタンスを作成し、そのインスタンスのidをshowアクションに渡しました。
ですが、思い出して欲しいのですが、ArticleにはUserという所有者が必須となっています。 つまり、Articleにはuser_idという外部キーがあるわけです。 この外部キーを作成するために、まずはUserのインスタンスを作成しましょう。 UserはすでにFactoryBotで作成しているので、それを持ってきましょう。
# rspec_test/spec/controllers/articles_controller_spec.rb describe "#show" do before do @user = FactoryBot.create(:user) @article = @user.articles.create( title: "加藤純一", text: "加藤純一? 神", ) end it "responds successfully" do get :show, params: {id: @article.id} expect(response).to be_success end it "returns a 200 response" do get :show, params: {id: @article.id} expect(response).to have_http_status "200" end end # showはすでにデータベースに保存された内容を表示するアクションです。 # before内でarticleのインスタンスを作る際は、create(newとbuildはインスタンスを保存しない)を使います。
持ってきたUserのインスタンスを利用して、Articleをcreateします。 bundle exec rspecでテストを実行しましょう。 すると、・・・・・・エラーが発生します。。。
というのも、controllerのファイルを見返して欲しいのですが、 before_actionで、authenticate_user!が、indexアクションを除いたアクション全てに適用されています。 つまり、Userがログインしていなければ、そもそもそのページに入れないというわけです。
ですが、テスト内でログインするためには、ログイン専用のヘルパーを利用できるようにしないといけません。
# rspec_test/spec/rails_helper.rb RSpec.configure do |config| config.include Devise::Test::ControllerHelpers, type: :controller end
上記のようにRSpec.configure以下に config.include Devise::Test::ControllerHelpers, type: :controller を記入します。 ControllerHelpersというモジュールをincludeすることで、ログインヘルパーを利用可能にしています。
以下のコードを実行すれば、テストが通るはずです。
# rspec_test/spec/controllers/articles_controller_spec.rb describe "#show" do before do @user = FactoryBot.create(:user) @article = @user.articles.create( title: "加藤純一", text: "加藤純一? 神", ) end it "responds successfully" do sign_in @user get :show, params: {id: @article.id} expect(response).to be_success end it "returns a 200 response" do sign_in @user get :show, params: {id: @article.id} expect(response).to have_http_status "200" end end
# terminal ArticlesController #index responds successfully returns a 200 response #show responds successfully returns a 200 response Article has a valid factory of user has a valid factory of another_user is valid with title, text and user_id is invalid without title is invalid without text is invalid without user_id does not allow a single user to have articles which has the same title does not allow a single user to have articles which has the same text does not allow a single user to have articles which have the same user_id does allow each user to have an article which has the same title Finished in 0.37984 seconds (files took 1.66 seconds to load) 14 examples, 0 failures
成功しました!
アクセスしてきたユーザーの場合分け
showのテストでは、Userがログインしているかどうかを確認しましたが、 反対に、ログインしていない人間がshowのページに入ってきたときに、その人がshowページの代わりにログイン画面へリダイレクトされるようになっているのかもテストしておきたいものです。
そのためには、登録したUserとゲストとして入ってきたユーザーを場合分けして処理する必要があります。
# rspec_test/spec/controllers/articles_controller_spec.rb describe "#show" do # 権限を有するUserの場合 context "as an authorized user" do before do @user = FactoryBot.create(:user) @article = @user.articles.create( title: "加藤純一", text: "加藤純一? 神" ) end # 正常なレスポンスか? it "responds successfully" do sign_in @user get :show, params: {id: @article.id} expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do sign_in @user get :show, params: {id: @article.id} expect(response).to have_http_status "200" end end # 権限を有しないゲストユーザーの場合 context "as a guest user" do before do @user = FactoryBot.create(:user) @article = @user.articles.create( title: "加藤純一", text: "加藤純一? 神" ) end # 正常にレスポンスが返ってきていないか? it "does not respond successfully" do get :show, params: {id: @article.id} expect(response).to_not be_success end # 302レスポンスが返ってきているか? it "returns a 302 response" do get :show, params: {id: @article.id} expect(response).to have_http_status "302" end # ログイン画面にリダイレクトされているか? it "redirects the page to /users/sign_in" do get :show, params: {id: @article.id} expect(response).to redirect_to "/users/sign_in" end end end
このように、contextを利用すれば、authorized userとguest userのテストを別々で実行することができるようになるのです。
権限を有するUserの場合のテスト自身の説明はこれ以上しませんが、 ゲストユーザーのテスト側との変更点だけ解説します。
まず、ゲストユーザー側の一番最初のテストでは、 正常にレスポンスが返ってきていないことを確認しています。 中身は権限を有するUser側でのテストとほとんど同じですが、ひとつ違う点は、 expect(response).to_not be_success となっていて、 responseがbe_successとなっていないことを期待する という意味になっています。 つまり、ややこしいですが、レスポンスが成功していなければ成功するテストだというわけです。
その次のテストでは、302レスポンスが返っていきているかどうかを確認しています。 ユーザーの認証ができなかったときには、この302レスポンスが返ってくるようになっているので、そこのところは何故302なのか、と悩む必要はありません。これは決まり事です。 権限を有するUser側のテストでは、200になっていたので、これを302にするだけで問題ありません。
最後のテストでは、ゲストユーザーがログイン画面にリダイレクトされているかどうかを確認しています。 showをgetして、paramsにidをセットしたところまではこれまでと変わりありませんが、 期待したresponseがredirect_to "users/sign_in"になっているかをテストしています。 redirect_toマッチャを使っているのがこれまでと異なります。
長くなってしまいましたが、以上のindexとshowの仕組みを理解することができれば、他のアクションについてもテストしやすくなると思います。 前回と同様に、コントローラーテストの例を以下に掲載しておきます。 詳細はgithubでも確認いただけます。 https://github.com/yutaro1204/rspec_demonstration
コントローラーテストの例
改めて説明すると、このアプリには、記事を登録できるArticleモデルがあり、登録したUserのみがArticleを作成することができるようになっています。 また、未登録のゲストユーザーは、indexのページにしかアクセスできないようになっています。
# rspec_test/spec/controllers/articles_controller_spec.rb RSpec.describe ArticlesController, type: :controller do before do @user = FactoryBot.create(:user) @another_user = FactoryBot.create(:another_user) @article = FactoryBot.create(:article) end describe "#index" do # 正常なレスポンスか? it "responds successfully" do get :index expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do get :index expect(response).to have_http_status "200" end end describe "#show" do context "as an authorized user" do # 正常なレスポンスか? it "responds successfully" do sign_in @user get :show, params: {id: @article.id} expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do sign_in @user get :show, params: {id: @article.id} expect(response).to have_http_status "200" end end context "as a guest user" do # 正常にレスポンスが返ってきていないか? it "does not respond successfully" do get :show, params: {id: @article.id} expect(response).to_not be_success end # 302レスポンスが返ってきているか? it "returns a 200 response" do get :show, params: {id: @article.id} expect(response).to have_http_status "302" end # ログイン画面にリダイレクトされているか? it "redirects the page to /users/sign_in" do get :show, params: {id: @article.id} expect(response).to redirect_to "/users/sign_in" end end end describe "#new" do context "as an authorized user" do # 正常なレスポンスか? it "responds successfully" do sign_in @user get :new expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do sign_in @user get :new expect(response).to have_http_status "200" end end context "as a guest user" do # 正常なレスポンスが返ってきていないか? it "does not respond successfully" do get :new expect(response).to_not be_success end # 302レスポンスが返ってきているか? it "returns a 302 response" do get :new expect(response).to have_http_status "302" end # ログイン画面にリダイレクトされているか? it "redirects the page to /users/sign_in" do get :new expect(response).to redirect_to "/users/sign_in" end end end describe "#create" do context "as an authorized user" do # 正常に記事を作成できるか? it "adds a new pin" do sign_in @user expect { post :create, params: { article: { title: "うんこちゃん", text: "ねもうすだよなあ?!", user_id: 1 } } }.to change(@user.articles, :count).by(1) end # 記事作成後に作成した記事の詳細ページへリダイレクトされているか? it "redirects the page to /articles/article.id(2)" do sign_in @user post :create, params: { article: { title: "うんこちゃん", text: "ねもうすだよなあ?!", user_id: 1 } } expect(response).to redirect_to "/articles/2" end end context "with invalid attributes" do # 不正なアトリビュートを含む記事は作成できなくなっているか? it "does not add a new pin" do sign_in @user expect { post :create, params: { article: { title: nil, text: "ねもうすだよなあ?!", user_id: 1 } } }.to_not change(@user.articles, :count) end # 不正な記事を作成しようとすると、再度作成ページへリダイレクトされるか? it "redirects the page to /articles/new" do sign_in @user post :create, params: { article: { title: nil, text: "ねもうすだよなあ?!", user_id: 1 } } expect(response).to redirect_to "/articles/new" end end context "as a guest user" do # 302レスポンスが返ってきているか? it "returns a 302 request" do get :create expect(response).to have_http_status "302" end # ログイン画面にリダイレクトされているか? it "redirects the page to /users/sign_in" do get :create expect(response).to redirect_to "/users/sign_in" end end end describe "#edit" do context "as an authorized user" do # 正常なレスポンスか? it "responds successfully" do sign_in @user get :edit, params: {id: @article.id} expect(response).to be_success end # 200レスポンスが返ってきているか? it "returns a 200 response" do sign_in @user get :edit, params: {id: @article.id} expect(response).to have_http_status "200" end end context "as an unauthorized user" do # 正常なレスポンスが返ってきていないか? it "does not respond successfully" do sign_in @another_user get :edit, params: {id: @article.id} expect(response).to_not be_success end # 他のユーザーが記事を編集しようとすると、ルートページへリダイレクトされているか? it "redirects the page to root_path" do sign_in @another_user get :edit, params: {id: @article.id} expect(response).to redirect_to root_path end end context "as a guest user" do # 302レスポンスが返ってきているか? it "returns a 302 response" do get :edit, params: {id: @article.id} expect(response).to have_http_status "302" end # ログイン画面にリダイレクトされているか? it "redirects the page to /users/sign_in" do get :edit, params: {id: @article.id} expect(response).to redirect_to "/users/sign_in" end end end describe "#update" do context "as an authorized user" do # 正常に記事を更新できるか? it "updates an article" do sign_in @user article_params = {title: "うんこちゃん"} patch :update, params: {id: @article.id, article: article_params} expect(@article.reload.title).to eq "うんこちゃん" end # 記事を更新した後、更新された記事の詳細ページへリダイレクトするか? it "redirects the page to /articles/article.id(1)" do sign_in @user article_params = {title: "うんこちゃん"} patch :update, params: {id: @article.id, article: article_params} expect(response).to redirect_to "/articles/1" end end context "with invalid attributes" do # 不正なアトリビュートを含む記事は更新できなくなっているか? it "does not update an article" do sign_in @user article_params = {title: nil} patch :update, params: {id: @article.id, article: article_params} expect(@article.reload.title).to eq "加藤純一" end # 不正な記事を更新しようとすると、再度更新ページへリダイレクトされるか? it "redirects the page to /articles/article.id(1)/edit" do sign_in @user article_params = {title: nil} patch :update, params: {id: @article.id, article: article_params} expect(response).to redirect_to "/articles/1/edit" end end context "as an unauthorized user" do # 正常なレスポンスが返ってきていないか? it "does not respond successfully" do sign_in @another_user get :edit, params: {id: @article.id} expect(response).to_not be_success end # 他のユーザーが記事を編集しようとすると、ルートページへリダイレクトされているか? it "redirects the page to root_path" do sign_in @another_user get :edit, params: {id: @article.id} expect(response).to redirect_to root_path end end context "as a guest user" do # 302レスポンスを返すか? it "returns a 302 response" do article_params = { title: "加藤純一", text: "加藤純一? 神", user_id: 1 } patch :update, params: {id: @article.id, article: article_params} expect(response).to have_http_status "302" end # ログイン画面にリダイレクトされているか? it "redirects the page to /users/sign_in" do article_params = { title: "加藤純一", text: "加藤純一? 神", user_id: 1 } patch :update, params: {id: @article.id, article: article_params} expect(response).to redirect_to "/users/sign_in" end end end describe "#destroy" do context "as an authorized user" do # 正常に記事を削除できるか? it "deletes an article" do sign_in @user expect { delete :destroy, params: {id: @article.id} }.to change(@user.articles, :count).by(-1) end # 記事を削除した後、ルートページへリダイレクトしているか? it "redirects the page to root_path" do sign_in @user delete :destroy, params: {id: @article.id} expect(response).to redirect_to root_path end end context "as an unauthorized user" do # 記事を投稿したユーザーだけが、記事を削除できるようになっているか? it "does not delete an article" do sign_in @user another_article = @another_user.articles.create( title: "じゅん?!", text: "南原清隆" ) expect { delete :destroy, params: {id: another_article.id} }.to_not change(@another_user.articles, :count) end # 他のユーザーが記事を削除しようとすると、ルートページへリダイレクトされるか? it "redirects the page to root_path" do sign_in @user another_article = @another_user.articles.create( title: "じゅん?!", text: "南原清隆" ) delete :destroy, params: {id: another_article.id} expect(response).to redirect_to root_path end end context "as a guest user" do # 302レスポンスを返すか? it "returns a 302 response" do delete :destroy, params: {id: @article.id} expect(response).to have_http_status "302" end # ログイン画面にリダイレクトされているか? it "redirects the page to /users/sing_in" do delete :destroy, params: {id: @article.id} expect(response).to redirect_to "/users/sign_in" end end end end
# spec/factories/users.rb FactoryBot.define do # 複数のfactoryを書く場合は、以下のように、class: User(model)の部分だけを統一し、 # factoryの後ろの:user, :another_userの部分のように、それぞれのfactoryに名前を付けられます。 factory :user, class: User do email "test@user" password "000000" end factory :another_user, class: User do email "test@another_user" password "000000" end end
# spec/factories/articles.rb FactoryBot.define do factory :article do title "加藤純一" text "加藤純一? 神" user_id 1 end end
以上のテストでは、わかりやすく書くために、テストとしてはあまり完結ではない書き方をしている箇所も含まれていますので、 より効率的に書ける場所は自分のアプリに合わせて工夫してみてください。
次回は、フィーチャースペック(統合テスト)について解説していきます。