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

以上のテストでは、わかりやすく書くために、テストとしてはあまり完結ではない書き方をしている箇所も含まれていますので、 より効率的に書ける場所は自分のアプリに合わせて工夫してみてください。

次回は、フィーチャースペック(統合テスト)について解説していきます。