連載
» 2012年08月06日 12時00分 公開

Railsで目指せ、情熱エンジニア(9):Railsのコントローラをテストする

前回はインテグレーションテストとしてCucumberでテストを作成しました。今回はユニットテストとして、RSpecを使ってコントローラのテストを作成します

[井上真,New Bamboo]

 前回はインテグレーションテストとしてCucumberでテストを作成しました。今回はユニットテストとしてRSpecを使ってコントローラのテスト(RSpecのテストコードは“スペック”と呼ばれるので、以降はスペックと呼びます)を作成します。本稿で紹介するスペックの全文はGitHub上にあります。

 最初に、コードレビューの回で述べたコントローラの役割についてもう一度おさらいしてみましょう。

 コントローラは外部から来たリクエストを受け付け、レスポンスを返すのが役割です。具体的には以下の3つの動作をおこないます。

  1. 適切なオブジェクトをとってくる
  2. オブジェクトに対する何らかの操作を指示する
  3. 操作が成功した際と失敗した際のビューの振る舞いを指定する

 重要なのが、コントローラが何から何まで処理を行うのではなく、いろいろなモデルからオブジェクトを呼んできて「こういう処理をしてください」とお願いすることに徹することです。そうしないと他のコントローラで似たような処理をするときに、複数のコントローラにロジックを重複して書かなければいけないからです。

 これらがちゃんと行われているのを確認するためのテストを書くのが今回の目的です。現在のコントローラには上記以外のロジックもたくさん含まれているのですが、コントローラテストを書いていくことで「あれ、このロジックはコントローラでやるべきじゃないよね。モデル層に移すことができるかちょっと試してみよう」と考えていくことが大切です。コントローラテストやモデルテストは自分のコードをデザインするためのツールとして考えるようになると、「テストを先に書く」というTDDや「テストを仕様書として考える」というBDDの考えや習慣が徐々に身に付いていくのではないでしょうか。

 まず、何はともあれスペックの全文を掲載します。

require 'spec_helper'
 
describe ItemsController do
  include Devise::TestHelpers # to give your spec access to helpers
 
  before(:each) do
    @user = FactoryGirl.create(:user)
    @params = {
      :user_id => @user.id,
      :item => {:url => 'http://www.google.com'}
    }
  end
 
  describe "#create" do
    describe "when successful" do
      before(:each) do
        sign_in :user, @user
      end
      it "should create new item" do
        expect{
          post :create, @params
        }.to change(Item, :count).by(1)
      end
 
      it "should redirect to edit page" do
        post :create, @params
        response.should redirect_to edit_user_item_path(@user, Item.last)
      end
 
      it "should assign variables" do
        post :create, @params
        flash[:notice].should == "Created an item. Any changes?"
        assigns[:user].should == @user
        assigns[:item].should_not be_nil
      end
    end
 
    describe "when non author accessed" do
      before(:each) do
        different_user = FactoryGirl.create(:user)
        sign_in :user, different_user
      end
      it "should not create new item" do
        expect{
          post :create, @params
        }.to change(Item, :count).by(0)
      end
 
      it "should redirect to users page" do
        post :create, @params
        response.should redirect_to users_path
      end
    end
 
    describe "when invalid url is passed" do
      before(:each) do
        sign_in :user, @user
        @params[:item] = {:url => 'wrongurl'}
      end
      it "should not create new item" do
        expect{
          post :create, @params
        }.to change(Item, :count).by(0)
      end
 
      it "should redirect to users page" do
        post :create, @params
        response.should redirect_to user_recent_path(@user.username)
      end
 
      it "should assign variables" do
        post :create, @params
        flash[:error].should == "Invalid URL!!"
      end
    end
 
    describe "when times out" do
      before(:each) do
        Timeout.should_receive(:timeout).and_raise(Timeout::Error)
        sign_in :user, @user
      end
 
      it "should not create new item" do
        expect{
          post :create, @params
        }.to change(Item, :count).by(0)
      end
 
      it "should redirect to users page" do
        post :create, @params
        response.should redirect_to user_recent_path(@user.username)
      end
 
      it "should assign variables" do
        post :create, @params
        flash[:error].should == "Timeout! Could not retrieve data from the URL!!"
      end
    end
 
    describe "setting item" do
      before(:each) do
        @item = FactoryGirl.create(:item)
        sign_in :user, @user
      end
 
      %w{edit destroy update}.each do |action|
        it "#{action} should have item" do
          get action.to_sym, @params.merge(:id => @item.id )
          assigns[:item].should_not be_nil
        end
      end
    end
  end
end

 では、上記のスペックの中から重要な部分を順に見て行きましょう。まずは、冒頭の1行。

include Devise::TestHelpers

 これは認証機能を提供するDeviseのモジュールです。このモジュールをインクルードすることで、singn_in、sign_outといったログイン、ログアウトをテスト内でシミュレートするためのいくつかのメソッドを使うことができるようになります。次に、

before(:each) do

ですが、これは各describeブロックが走る前に必要な処理を記述する箇所です。共通の前処理をまとめておく場所です。

テストデータからオブジェクを作る「Factory Girl」

 続いて、以下の行を見てみましょう。

@user = FactoryGirl.create(:user)

 ここではFactory Girlというライブラリを使用してテストユーザーを作成しています。RailsにはFixture(フィクスチャ)というテストデータを以下のようなYAMLファイルで作成し、各テストで共有する仕組みが提供されています。

one:
  url: MyString
  bitly_url: MyString
  title: MyString
  subtitle: MyString
  published_at: 2010-10-26
  summary: MyText
  hatena: 1
  retweet: 1
  private_memo: MyText
  user: 
 
two:
  url: MyString
  bitly_url: MyString
  title: MyString
  subtitle: MyString
  published_at: 2010-10-26
  summary: MyText
  hatena: 1
  retweet: 1
  private_memo: MyText
  user: 
  @user = Factory(:user)

 以前は私もこのFixtureの仕組みを使っていたのですが、何十にも渡るテストケースに合致するデータを作るのは結構大変な上に、テストコードを読むときに、テストファイルとFixtureのファイルを行ったり来たりする必要があるので結構使いづらいです。

 そこで「テストデータの雛形ファイルは用意するけれど、実際にはテストの度に必要なオブジェクトを作成する」という方式が取られるようになってきました。そのためのツールとしてはFactory GirlFixture Replacementが有名です。今回はFactory Girlを使用します。

 まずは、spec/factories/item_factory.rbに以下のような雛形を作成します。

FactoryGirl.define do
  factory :user do
    sequence(:email) {|n| "bob#{n}@example.com" }
    sequence(:username) {|n| "bob#{n}" }
    password "password"
    invite_code "dummy_code"
  end
end

 「password "password"」というところは、「passwordコラムに"password"という文字を入れる」ということです。emailとusernameには「sequence」が使われています。これはvalidates_uniqunessなどのバリデーションが設定されているときに、同じユーザー名のオブジェクトを複数作成されるとエラーが出るのを避けるためです。これによって「bob1」「bob2」といったように毎回違うユーザ名を作成することができます。

 FacotryGirl.build(:user) を呼ぶと、新しいUserオブジェクトを作成しますが、データベースには保存しません。データベースに依存しないことをテストするとき(例えばvalidates_presence_ofなど)には、こちらを使った方がデータベース書き込みの時間を節約でき、テスト時間を少し短縮することが可能です。

 FactoryGirl.create(:user)あるいはFactoryGirl(:user)はどちらともデータベースにオブジェクトを保存します。

 例えばパスワードの値の有無を調べるときに以下のようにすることで、雛形に指定された値を上書きすることができます。

FactoryGirl.create(:user, :password => nil)

 これは以下と全く同じですが、自分がテストするときに関係する箇所だけをFactoryに指定してあげるだけで、テストがぐっと読みやすくなるのではないでしょうか。

User.create(:email => "bob@example.com", :username => "bob", :password => nil, :invite_code "dummy_code")

expectを使って状態変化をスマートに記述

 それでは最初のテスト項目です。ここでは「いくつかのパラメータをcreateメソッドに渡した結果itemオブジェクトが作られる」ということを意味しています。

it "should create new item" do
  expect{
    post :create, @params
  }.to change(Item, :count).by(1)
end

 「expect」のブロックの中でテストする処理を行い、その処理が行なった結果起きる変化を「change」内で記述しています。

 これは以下のテストコードと全く同じなのですが、上の方が読みやすくないですか? テストを簡潔に記述するための、こういった仕組みが随所に含まれているのもRSpecの魅力でしょう。

it "should create new item" do
  item_count_before = Item.count
  post :create, @params
  item_count_after = Item.count
  (item_count_after - item_count_before).should == 1
end

リダイレクトなどの挙動もテストできる「response」

 「response」もrspecがコントローラテストのために提供してるオブジェクトで、各メソッドを読んだ後「レスポンスはこういう動きをするべきだ」といったExpectation(期待/予想)を含んでいます。今回のスペックでは以下のように使っています。

it "should redirect to edit page" do
  post :create, @params
  response.should redirect_to edit_user_item_path(@user, Item.last)
end

 responseでは主に以下のような期待値を指定することができます。今回の例ではedit_user_item_pathにリダイレクトするかどうかをテストしています。

response.should be_success  # 200を返す
response.should be_redirect  # 30xを返す
response.should render_template("path/to/template/for/action") # ビューを描画する
response.should have_text("expected text") # レスポンスボディにある文字列を含む

インスタンス変数やFlashメッセージをテストする

 次に、以下のテストコードを見てみましょう。

it "should assign variables" do
  post :create, @params
  flash[:notice].should == "Created an item. Any changes?"
  assigns[:user].should == @user
  assigns[:item].should_not be_nil
end

 ここでは「1.適切なオブジェクトをとってくる」の部分をテストしています。「assigns[:key]」の「key」の部分にインスタンス変数名を指定します。インスタンス変数以外にもflashやsessionの値をテストすることもできます。

assigns[:item].should_not be_nil

 この部分は「@item変数に何か入っている」かをテストしているのですが、この書き方には賛否両論があるかもしれません。なぜなら「@item = "foo"」のような値が入った場合でもテストが通ってしまうからです。そこで、もう少し具体的に、

assigns[:item].url should == 'http://www.google.com'

と、期待値を指定する方が良いかもしれません。ただ、厳格にしすぎると、今度はちょっとしたコードの変更でも修正しなければならないテストコードが増えてしまって困りものです。「どのテストがどういった振る舞いに対して責任を持つか」ということを常に意識しながら緩いテストを書くか、厳格なテストを書くかを使い分けると良いです。

 今回はコントローラテストをRspecで作成しました。テストもコードなので、重複した部分を1つの変数にまとめたり、FactoryやRSpecの便利な機能を利用してどんどん可読性を高めるようにしていきましょう。また今回は特に触れていませんが、成功したときのテストを書くだけでなく、エラーケースのテストもどんどん付け加えていきましょう。テストがしっかりしていれば、後でコードのリファクタリングをしても、何かおかしい変更があればテストがフェイルすることで警告してくれます。テストコードは、そういう変化を受け止めてくれるためのセーフティネットの役割を果たしてくれるはずです。

 次回はコントローラのコードを見ていきましょう。

Copyright © ITmedia, Inc. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。