ActiveSupport_TestCase + shoulda によるテスト のバックアップ差分(No.3)

更新


  • 追加された行はこの色です。
  • 削除された行はこの色です。
[[公開メモ]]

#contents

* ActiveSupport::TestCase + shoulda による Ruby on Rails のテスト [#ge5f5cd1]

- ActiveSupport::TestCase は Rails 標準のテストフレームワーク
- [[shoulda>https://github.com/thoughtbot/shoulda]] は読みやすいテストケースを書くための拡張

これらを使って Rails アプリケーションをテストするためのノウハウをメモとして残したい

* いろいろ検討中 [#rc1df26c]

まだちゃんとしたテストは書き始めたばかりなので全然やり方が定まってません。
このレベルの物をこんなところに晒すのも何なのですが、とりあえずメモとして書いちゃいます。

** 便利な gem [#d7228abc]

- development 用
:better_errors+binding_of_caller| エラー画面でコールスタック上の変数値を参照するなどいろいろできるようになる
:tapp| 昔ながらの puts によるデバッグに於いて caller チェーンを切らずにデバッグ出力するのに便利
- test 用
:shoulda|読みやすいテストケースを書けるようにする
:database_rewinder|データベースを高速に初期化する
:tapp|上記参照

Gemfile
 group :development do
   gem 'better_errors'
   gem 'binding_of_caller'
   gem 'tapp'
 end
 
 group :test do
   gem 'database_rewinder'
   gem 'shoulda'
   gem 'tapp'
 end

** shoulda の基本 [#w7e40d51]

shoulda では、

- context の setup で操作を行い
- should で検証を行う
- テストの内容説明は context + should で自動生成される

というのが基本になります。

生の ActiveSupport::TestCase とは考え方が異なるので始めは戸惑います。

ActiveSupport::TestCase であれば、
- setup ですべての test に共通の前処理を行い
- 個々の test で操作を行い、さらに assert も行う
- テストの内容説明は test の名前 + assert の記述で行う

となります。

shoulda の場合は「個々の should の中で操作を行わない」
という点が大きな違いなわけです。

無理矢理対応関係を書けば次のようになります。

||~ shoulda       |~ assert       |
|~操作   |  context #1|  setup|
|~|  context #2|~|
|~|  context #3|  test|
|~検証   |  should|  assert|

下で言えば、

 LANG:ruby
 [:ja,:en].each do |locale|
   context "with locale = #{locale}," do
     setup {
       # ロケール設定
     }
 
     context "with auto-login," do
       setup {
         # オートログイン準備
       }
         
       context 'GET #home' do
         setup { 
           get :home     # 実際の操作
         }
         should 期待される結果1
         should 期待される結果2
       end
   end
 end

などとなっています。

これで、
- with locale ja, with auto-login, GET #home should 期待される結果1
- with locale ja, with auto-login, GET #home should 期待される結果2
- with locale en, with auto-login, GET #home should 期待される結果1
- with locale en, with auto-login, GET #home should 期待される結果2

のようにすべてのテストに適切な説明が付きます。

エラーメッセージの例:

 LANG:console
 ...
 
  1) Failure:
 AccountControllerTest#test_: with locale en, without login, valid POST #create 
 should send email to registered address.  
 [/home/osamu/MyApp/test/controllers/account_controller_test.rb:141]:
 Expected true to be nil or false
 
 ...
 

should の中に get :home を書いてしまうと、
should にうまく名前を付けられなくて困ることになります。

- context の setup は、中に含まれるすべての should に対して毎回呼ばれます。
- すべての should に先駆けて一回だけ行いたいような初期化処理がもしあれば、
テストケースの宣言部分(クラススコープ)で行えば良いのだと思います。
ただ、個々のテストは独立していないといろいろややこしいので、
そういう初期化が必要になることはあまり無いんじゃないかとも思います。

** コントローラのテスト [#m9c4f2cd]

途中ロケールの処理が入っていますが、そのあたりの構成は 
[[ソフトウェア/rails/国際化]] に書いた通りです。

test/controllers/account_controller_test.rb
 LANG:ruby(linenumber)
 #coding: utf-8
 require 'test_helper'
 require_relative '../models/_test_params.rb'
 
 class AccountControllerTest < ActionController::TestCase
   tests AccountController   # この例のように標準的な命名をしている限り、
                             # コントローラ名の指定はしなくてもOK
                             # コントローラ名の指定は省略してもよい
 
   # すべての context に共通の初期化処理
   setup {
   # Params::param_name の形で Hash 形式のモデル作成用
   # パラメータを参照可能にする
   # Params::param_name(key1: value1, key2: value2) として
   # いくつかのキーをオーバーライドすることも可能
   include TestParams
 
     clear_all
   # setup とテストケース should の中とで情報をやりとりするのに
   # 普通にインスタンス変数 @xxx を使うか、反則技でクラス変数 @@xxx を
   # 使うか迷い中
   #
   # インスタンス変数を使うのが普通だが、
   #   - クラススコープからインスタンス変数にアクセスできなくて
   #     残念に感じることがたまにある
   #   - 比較的多数のインスタンス変数がフレームワークによって
   #     使われているため、命名の衝突が怖い
   #
   # クラス変数を使うのはかなり異端
   #   - 本来の目的からずれまくってるので完全に反則技
   #   - 初期化し忘れると直前のテストケースの値が残ってしまうのも問題
   #   - その代わり、どこからでもアクセスできるのがかなり便利?
   
   # ActionController::TestCase にて、以下のクラス変数が使われている。
   #
   #   @@required_fixture_classes: false
   #   @@already_loaded_fixtures: {}
   #   @@test_suites: {ActiveSupport::TestCase=>true, ...}
   #   @@current: #<AccountControllerTest:0xb192174>
   #
   # クラス変数を使う場合は、これらおよび未来の TestCase のクラス変数と
   # 名前が被らないよう注意する必要あり
   
   # インスタンス変数として以下が定義されるので、名前が
   # 被らないように注意が必要
   #
   # インスタンス変数      利用時の記法   内容
   # @__name__             @__name__      現在実行中のテストのタイトルが入る
   # @__io__
   # @passed
   # @_assertions
   # @fixture_cache
   # @fixture_connections
   # @loaded_fixtures
   # @tagged_logger
   # @_partials
   # @_templates
   # @_layouts
   # @_files
   # @request              request        #<ActionController::TestRequest>
   # @response             response       #<ActionController::TestResponse>
   # @controller           @controller    #<Controller under testing>
   # @routes               @routes        #<ActionDispatch::Routing::RouteSet>
   # @shoulda_context
   # @html_document        html_document  #<HTML::Document>
   # @assigns              assigns        @controller 内で定義されたインスタンス変数
 
     # ActionController::TestCase にて、以下のクラス変数が使われている。
     #
     #   @@required_fixture_classes: false
     #   @@already_loaded_fixtures: {}
     #   @@test_suites: {ActiveSupport::TestCase=>true, ...}
     #   @@current: #<AccountControllerTest:0xb192174>
     #
     # クラス変数を使う場合は、これらおよび未来の TestCase のクラス変数と
     # 名前が被らないよう注意する必要あり
  
   # すべての should に先駆けて1回だけ行いたい処理がもしもあれば
   # ここに書けばいい(通常あまり必要なさそう?)
 
     # setup とテストケース should の中とで情報をやりとりするのに
     # 普通にインスタンス変数 @xxx を使うか、反則技でクラス変数 @@xxx を
     # 使うのが良いか迷い中
     #
     # インスタンス変数を使うのが普通だが、
     #   - クラススコープからインスタンス変数にアクセスできなくて
     #     残念に感じることがたまにある
     #   - 比較的多数のインスタンス変数がフレームワークによって
     #     使われているため、命名の衝突が怖い
     #
     # クラス変数を使うのはかなり異端
     #   - 本来の目的からずれまくってるので完全に反則技
     #   - 初期化し忘れると直前のテストケースの値が残ってしまうのも問題
     #   - その代わり、どこからでもアクセスできるのがかなり便利?
   # 初期化処理1
   # 初期化処理2
 
     @@valid_account_params = { 
       name: "Foo Baa", 
       email: "account@example.com",
       affiliation: "My institute",
       address: "My address",
       telephone: "+00-000-0000"
     }
     
     @@invalid_account_params = {
       name: ''
     }
     
     @@valid_registration_params = {
       gender: 1,
       membership: 1,
       student: false,
       receipt_address: 'My address',
       payaccount: 'My Account'
     }
     
     # インスタンス変数として以下が定義されるので、名前が
     # 被らないように注意が必要
     #
     # インスタンス変数      利用時の記法   内容
     # @__name__             @__name__      現在実行中のテストのタイトルが入る
     # @__io__
     # @passed
     # @_assertions
     # @fixture_cache
     # @fixture_connections
     # @loaded_fixtures
     # @tagged_logger
     # @_partials
     # @_templates
     # @_layouts
     # @_files
     # @request              request        #<ActionController::TestRequest>
     # @response             response       #<ActionController::TestResponse>
     # @controller           @controller    #<Controller under testing>
     # @routes               @routes        #<ActionDispatch::Routing::RouteSet>
     # @shoulda_context
     # @html_document        html_document  #<HTML::Document>
     # @assigns              assigns        @controller 内で定義されたインスタンス変数
  
   # すべての context に共通の初期化処理
   # これは個々の should の直前に、毎回呼ばれることになる
   setup {
     clear_all
   }
 
   def clear_all
     ActionMailer::Base.deliveries.clear
     DatabaseRewinder.clean_all
     session.clear
     cookies.clear
   end
  
   # 以下すべてを ja, en 両方のロケールで試す
   [:ja,:en].each do |locale|
     context "with locale #{locale}," do
       setup {
         request.env['HTTP_ACCEPT_LANGUAGE'] = locale.to_s
       }
       
       # ここからが本当のテスト内容
       
       context "without login," do
 
         context "GET #new" do
           setup { 
             get :new 
           }
           should respond_with :success
           should render_template :new
           should_not set_the_flash
         end
 
         context 'GET #home' do
           setup { 
             get :home 
           }
           should redirect_to_path :account_login
           should set_the_flash # login first
           should 'set session_intended_path to requested path' do
             # セッションキーは ApplicationController で 
             #   @session_intended_path="hogehoge"
             # として与えられているので、assigns を使って読み取る
             assert_equal @request.path, 
                          session[assigns(:session_intended_path)]
           end
         end
         
         context "GET #new" do
           setup { 
             get :new 
           }
           should respond_with :success
           should render_template :new
           should_not set_the_flash
         end
 
         context 'invalid POST #create' do
           setup { 
             post :create, account: @@invalid_account_params
             post :create, account: Params::invalid_account
           }
           should "not create account" do
             assert_equal 0, Account.count
           end
           should set_the_flash  # already login
           should render_template :new
         end
         
         context 'valid POST #create' do
           setup {
             post :create, account: @@valid_account_params
             post :create, account: Params::valid_account
           }
           should "create account" do
             assert_equal 1, Account.count
           end
           should set_the_flash  # account created
           should redirect_to_path :account_login
           should "send email to registered address" do
             assert_not ActionMailer::Base.deliveries.empty?
             email_sent = ActionMailer::Base.deliveries.last
             assert_equal @@valid_account_params[:email], email_sent.to
             assert_equal Params::valid_account[:email], email_sent.to
           end
         end
         
       end
 
       context "with auto-login," do
 
         setup {  
           @@account = Account.create!(
             @@valid_account_params.merge(
             Params::valid_account(
               password: Account.generate_new_password,
               auto_login: Account.generate_new_password
             )
           )
           
           cookies[assigns(:cookie_auto_login)] =
               [@@account.auto_login, @@account.email].join(':')
         }
         
         context 'GET #home' do
           setup { 
             get :home 
           }
           should respond_with :success
           should 'update autologin password at successful auto login' do
             assert_not_equal @@account.auto_login, assigns[:account].auto_login
             assert_equal assigns[:account].auto_login, 
                          cookies[assigns[:cookie_auto_login]].split(':',2)[0]
           end
         end
         
         context 'GET #new' do
           setup { 
             get :new
           }
           should set_the_flash  # already login
           should redirect_to_path :account
         end
         
         context 'GET #edit' do
           setup { 
             get :edit
           }
           should respond_with :success
           should render_template :edit
         end
         
         context 'POST #update' do
           setup {
             post :update, account: {name: "Updated Name"}
           }
           should 'update name' do
             account = Account.find_by_email(@@account.email)
             assert_equal "Updated Name", account.name
           end
           should set_the_flash  # successfully updated
           should redirect_to_path :account
         end
         
         context 'DELETE #destroy' do
           setup {
             delete :destroy
           }
           should "delete account" do
             assert_equal 0, Account.count
           end
           should set_the_flash  # successfully deleted
           should redirect_to_path :default
         end
         
         context 'with valid registration,' do
           setup {
             @@account.registration =
               Registration.create!(@@valid_registration_params)
               Registration.create! Params::valid_registration
           }
 
           context 'DELETE #destroy' do
             setup {
               delete :destroy
             }
             should "not delete account" do
               assert_equal 1, Account.count
             end
             should set_the_flash  # can not delete
             should redirect_to_path :account
           end
         end
 
       end
     end
   end
   
 end

* モデル生成用パラメータ [#c095e6b7]

test/models/_test_params.rb
 LANG:ruby(linenumber)
 # coding: utf-8
 
 module TestParams
 
   module Params
     #
     # name で指定される名前のクラス関数を作成する
     #   Params::param_name              = ハッシュを返す
     #   Params::param_name(key: value)  = ハッシュに hash[:key]=value してから返す
     #
     def self.param(param_name, hash)
       class_eval <<-EOS
         def self.#{param_name}(options={})
           #{hash.inspect}.merge(options)
         end
       EOS
     end
 
     param :valid_account, {
       name: "Foo Baa", 
       email: "account@example.com",
       affiliation: "My institute",
       address: "My address",
       telephone: "+00-000-0000"
     }
 
     param :invalid_account, { 
       name: ''
     }
     
     param :valid_registration, {
       gender: 1,
       membership: 1,
       student: false,
       receipt_address: 'My address',
       payaccount: 'My Account'
     }
 
     param :invalid_registration, {
       gender: 1,
       membership: 1,
       student: false,
       receipt_address: nil,
       payaccount: nil
     }
 
   end
 
 end

* 独自拡張を読み込む [#xdb96442]

test/test_helper.rb
 LANG:ruby
 ...
 
 # I18n.translate の翻訳の抜けを検出する
 require 'test_i18n_translation_strictly'
 
 # should 用のマクロを読み込む
 Dir[File.expand_path('../shoulda_macros', __FILE__) << '/*.rb'].each do |file|
   require file
 end
 
 ...
 

** I18n.translate の翻訳の抜けを検出する [#ub2cc8f0]

config/initializers/test_i18n_translation_strictly.rb
 LANG:ruby(linenumber)
 if ENV['RAILS_ENV'] == "test"
 # coding: utf-8
 
 # 翻訳が見付からないときに例外を投げる
 #   I18n.exception_handler
 #   http://guides.rubyonrails.org/i18n.html
 # これだけではモデル名やカラム名の翻訳は無くても
 # これだけだとモデル名やカラム名の翻訳が無くても
 # エラーにならないので、
 #   ActiveRecord::model_name.human
 #       https://github.com/rails/rails/blob/master/activemodel/lib/active_model/translation.rb#L43
 #   ActiveRecord::human_attribute_name
 #       https://github.com/rails/rails/blob/master/activemodel/lib/active_model/naming.rb#L175
 # にも手を入れる。locale == :en では、モデル名やカラム名を
 # そのまま使えることも多いので、それ以外の時に翻訳が未設定
 # であれば例外を投げる
 #
 # 後者2つについては関数のコードをほぼ丸々コピーしているので、
 # 元のコードが変更されたらそれに合わせて変更しなければならない
 
 module I18n
   class JustRaiseExceptionHandler < ExceptionHandler
     def call(exception, locale, key, options)
       if exception.is_a?(MissingTranslation) and
           key.to_s != 'i18n.plural.rule'
         raise exception.to_exception
       else
         super
       end
     end
   end
 end
 I18n.exception_handler = I18n::JustRaiseExceptionHandler.new
 
 module ActiveModel
   class Name
     def human(options={})
       return @human unless @klass.respond_to?(:lookup_ancestors) &&
                            @klass.respond_to?(:i18n_scope)
 
       defaults = @klass.lookup_ancestors.map do |klass|
         klass.model_name.i18n_key
       end
 
       defaults << options[:default] if options[:default]
 #      defaults << @human
       defaults << @human if I18n.locale == :en
       
       options = { scope: [@klass.i18n_scope, :models], count: 1, default: defaults }.merge!(options.except(:default))
       I18n.translate(defaults.shift, options)
     end
   end
 end
 
 module ActiveModel
   module Translation
     def human_attribute_name(attribute, options = {})
       options = { count: 1 }.merge!(options)
       parts = attribute.to_s.split(".")
       attribute = parts.pop
       namespace = parts.join("/") unless parts.empty?
       attributes_scope = "#{self.i18n_scope}.attributes"
 
       if namespace
         defaults = lookup_ancestors.map do |klass|
           :"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.#{attribute}"
         end
         defaults << :"#{attributes_scope}.#{namespace}.#{attribute}"
       else
         defaults = lookup_ancestors.map do |klass|
           :"#{attributes_scope}.#{klass.model_name.i18n_key}.#{attribute}"
         end
       end
 
       defaults << :"attributes.#{attribute}"
       defaults << options.delete(:default) if options[:default]
 #      defaults << attribute.humanize
       defaults << attribute.humanize if I18n.locale == :en
 
       options[:default] = defaults
       I18n.translate(defaults.shift, options)
     end
   end
 end
 
 end

** should redirect_to_path [#r6fcffc4]

test/shoulda_macros/action_controller_macros.rb
 LANG:ruby(linenumber)
 class ActionController::TestCase
 
   # shoulda の redirect_to では、
   #   should redirect_to account_path
   #   should redirect_to account_path(action: :edit)
   #   should redirect_to url_for(controller: :account, action: :edit)
   # などとは書けないので、かわりに
   #   should redirect_to("account_path"){ account_path }
   #   should redirect_to("account_path(action: :edit)"){ account_path(action: :edit) }
   #   should redirect_to("controller: :account, action: :edit"){ 
   #               @controller.url_for(controller: :account, action: :edit) }
   # などと書かなければならない。より簡単に
   #   should redirect_to_path :account
   #   should redirect_to_path :account, action: :edit
   #   should redirect_to_path controller: :account, action: :edit
   # などと書けるようにするのがこの関数
   def self.redirect_to_path(*arg)
     if arg.first.is_a? Symbol
       redirect_to_path_arg = arg.dup
       redirect_to_path_symbol = :"#{redirect_to_path_arg.shift}_path"
       if redirect_to_path_arg.empty?
         redirect_to(redirect_to_path_symbol){
           __send__(redirect_to_path_symbol)
         }
       else
         redirect_to_path_arg_s =redirect_to_path_arg.inspect.gsub(/\A\[|\]\z/,'')
         redirect_to_path_arg_s.gsub!(/\A\{|\}\z/,'') if redirect_to_path_arg.count == 1
         redirect_to("#{redirect_to_path_symbol}(#{redirect_to_path_arg_s})", &Proc.new {
           __send__(redirect_to_path_symbol, *redirect_to_path_arg)
         })
       end
     else
       redirect_to_path_arg = arg.dup
       redirect_to_path_arg_s =redirect_to_path_arg.inspect.gsub(/\A\[|\]\z/,'')
       redirect_to_path_arg_s.gsub!(/\A\{|\}\z/,'') if redirect_to_path_arg.count == 1
       redirect_to(redirect_to_path_arg_s){
         @controller.url_for *redirect_to_path_arg
       }
     end
   end
 
 end

*** context_for_all_locales [#r1d0c88f]

というのも作っておくと良さそう

* コメント [#yfa709ad]

#article_kcaptcha


Counter: 8412 (from 2010/06/03), today: 4, yesterday: 0