ActiveSupport_TestCase + shoulda によるテスト の変更点

更新


[[公開メモ]]

#contents

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

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

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

ネットで調べると rspec + shoulda-matchers の記事が多くて、
MiniTest + shoulda-context + shoulda-matchers の情報になかなかたどり着けないので。。。

* 環境 [#ab825549]

現在のこちらの環境は以下の通りです。
-rails 4.0.4
-minitest 4.7.5
-shoulda 3.5.0

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

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

** 便利な gem [#d7228abc]

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

Gemfile
 LANG:ruby
 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   # この例のように標準的な命名をしている限り、
                             # コントローラ名の指定は省略してもよい
 
   # Params::param_name の形で Hash 形式のモデル作成用
   # パラメータを参照可能にする
   # Params::param_name(key1: value1, key2: value2) として
   # いくつかのキーをオーバーライドすることも可能
   include TestParams
 
   # 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 内で定義されたインスタンス変数
 
  
   # すべての should に先駆けて1回だけ行いたい処理がもしもあれば
   # ここに書けばいい(通常あまり必要なさそう?)
 
   # 初期化処理1
   # 初期化処理2
 
  
   # すべての 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 #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: Params::invalid_account
           }
           should "not create account" do
             assert_equal 0, Account.count
           end
           should render_template :new
         end
         
         context 'valid POST #create' do
           setup {
             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 Params::valid_account[:email], email_sent.to
           end
         end
         
       end
 
       context "with auto-login," do
 
         setup {  
           @@account = Account.create!(
             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! 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'
 
 # shoulda 用のマクロを読み込む
 Dir[File.expand_path('../shoulda_macros', __FILE__) << '/*.rb'].each do |file|
   require file
 end
 
 ...
 

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

test/test_i18n_translation_strictly.rb
 LANG:ruby(linenumber)
 # 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

** should redirect_to_path と set_the_flash.to の拡張 [#r6fcffc4]

test/shoulda_macros/action_controller_macros.rb
 LANG:ruby(linenumber)
 module Shoulda::Matchers::ActionController # :nodoc:
 
   # 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
 
   # set_session.to と同様に、
   # set_the_flash.to{ some_code } の形の呼び出しを可能にする
   # controller 側の I18n.locale を使ってメッセージを評価するには
   # set_the_flash.to{ I18n.t :message_id } の形での呼び出しにが必須になる
   class SetTheFlashMatcher
 
     alias :to_without_block :to
     def to(value=//, &block)
       @value_block = block
       to_without_block(value)
     end
 
     alias :matches_q_without_block :"matches?"
     def matches?(controller)
       if @value_block
         to_without_block( @context.instance_eval(&@value_block) )
       end
       matches_q_without_block(controller)
     end
 
     def in_context(context)
       @context = context
       self
     end
   
   end
   
 end

*** context_for_all_locales [#r1d0c88f]

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

** エラーの生じた should を特定しやすくする [#wc579ebc]

shoulda からのエラーメッセージが以下のように shoulda 
ライブラリや shoulda macro の中を指してしまうことがあります。

 LANG:console
   1) Failure:
 AccountControllerTest#test_: with locale en, without login, invalid POST #create 
 should set the flash.  
 [/var/lib/gems/2.0.0/gems/shoulda-context-1.2.1/lib/shoulda/context/context.rb:344]:
 Expected the flash to be set, but no flash was set

このようなメッセージからエラーが生じたテストの場所を特定するのはなかなか手間がかかります。

ごたごたは飛ばして解決策へ飛ぶ場合は [[こちら>#s29326e1]]

*** 試行錯誤 [#c418a6e5]

で、例外のバックトレースをいじって改善できない物か???と考えたわけです。

ActiveSupport::TestCase ~
https://github.com/rails/rails/blob/master/activesupport/lib/active_support/test_case.rb

gem 'minitest' を取り込んで ::Minitest::Test を継承している。ruby 標準の minitest ではないらしい?

これかな?~
https://github.com/seattlerb/minitest

capture_exceptions で self.failures にエラーを溜めて、~
https://github.com/seattlerb/minitest/blob/d5d43cef9a3fd4a0eea972dde125ed5ba1ddb821/lib/minitest/test.rb#L202

to_s で文字に直す?~
https://github.com/seattlerb/minitest/blob/d5d43cef9a3fd4a0eea972dde125ed5ba1ddb821/lib/minitest/test.rb#L261

場所を表わす部分は location で、self.failure というのがどこから来るのか分からないものの、
アサート例外の location から得ているように見える~
https://github.com/seattlerb/minitest/blob/d5d43cef9a3fd4a0eea972dde125ed5ba1ddb821/lib/minitest/test.rb#L224

failure は Runnable で定義されていた → 何のことはない failures.first のことだった~
https://github.com/seattlerb/minitest/blob/d5d43cef9a3fd4a0eea972dde125ed5ba1ddb821/lib/minitest.rb#L345

Minitest::Assertion 例外の location はここ~
https://github.com/seattlerb/minitest/blob/d5d43cef9a3fd4a0eea972dde125ed5ba1ddb821/lib/minitest.rb#L633

 LANG:ruby
 module Minitest
   VERSION = "5.3.4" # :nodoc:
   ...
 
   class Assertion < Exception
     ...
 
     def location
       last_before_assertion = ""
       self.backtrace.reverse_each do |s|
         break if s =~ /in .(assert|refute|flunk|pass|fail|raise|must|wont)/
         last_before_assertion = s
       end
       last_before_assertion.sub(/:in .*$/, "")
     end

この backtrace を遡っている部分の条件を変更してやれば良い・・・のかと思ったら違うみたいです???
どこかで間違った???

ああ、動かしてるバージョンが段違いでした orz~
上記 seattlerb/minitest でメンテしているのは 5.x.x 系列で、4.x.x 系列は 4.7.2 あたりが最後?

古いコードだと、普通に rescue で捕まえて runner.puke してますね~
https://github.com/seattlerb/minitest/blob/7e25922c340fa1cfdbeffaf8f37ec020cf6a4fff/lib/minitest/unit.rb#L1255

puke は location を呼んでるから、~
https://github.com/seattlerb/minitest/blob/7e25922c340fa1cfdbeffaf8f37ec020cf6a4fff/lib/minitest/unit.rb#L954

この location を書き換えればいいのかと思ったのですが・・・

 LANG:ruby
 module Minitest
   class Unit
     def location e
       e.backtract.join("\n")
     end
   end
 end

として得られたトレースはこんな感じ。

 LANG:console
 minitest-4.7.5/lib/minitest/unit.rb:195:in `assert'
 shoulda-context-1.2.1/lib/shoulda/context/assertions.rb:91:in `safe_assert_block'
 shoulda-context-1.2.1/lib/shoulda/context/assertions.rb:83:in `assert_rejects'
 shoulda-context-1.2.1/lib/shoulda/context/context.rb:358:in `block in should_not'
 shoulda-context-1.2.1/lib/shoulda/context/context.rb:413:in `instance_exec'
 shoulda-context-1.2.1/lib/shoulda/context/context.rb:413:in `block in create_test_from_should_hash'
 minitest-4.7.5/lib/minitest/unit.rb:1258:in `run'
 minitest-4.7.5/lib/minitest/unit.rb:933:in `block in _run_suite'
 minitest-4.7.5/lib/minitest/unit.rb:926:in `map'
 minitest-4.7.5/lib/minitest/unit.rb:926:in `_run_suite'
 minitest-4.7.5/lib/minitest/parallel_each.rb:71:in `block in _run_suites'
 minitest-4.7.5/lib/minitest/parallel_each.rb:71:in `map'
 minitest-4.7.5/lib/minitest/parallel_each.rb:71:in `_run_suites'
 minitest-4.7.5/lib/minitest/unit.rb:877:in `_run_anything'
 minitest-4.7.5/lib/minitest/unit.rb:1085:in `run_tests'
 minitest-4.7.5/lib/minitest/unit.rb:1072:in `block in _run'
 minitest-4.7.5/lib/minitest/unit.rb:1071:in `each'
 minitest-4.7.5/lib/minitest/unit.rb:1071:in `_run'
 minitest-4.7.5/lib/minitest/unit.rb:1059:in `run'
 minitest-4.7.5/lib/minitest/unit.rb:795:in `block in autorun'

バックトレースに TestCase の行が含まれていない???

https://github.com/thoughtbot/shoulda-context/blob/5305f310d432992e2a1e74f542c62104272edacf/lib/shoulda/context/context.rb#L356

 LANG:ruby
      def should_not(matcher)
        name = matcher.description
        blk = lambda { assert_rejects matcher, subject }
        self.shoulds << { :name => "not #{name}", :block => blk }
      end

この lambda で作った blk が shoulds に溜められて、後から順に実行され、そこでエラーが出ると。

これだと例外から行番号を得るのは難しいですね。

*** 解決 [#s29326e1]

むしろ、この :name に行番号を突っ込んじゃえばいいという話かも。

test/shoulda_macros/add_caller_information.rb
 LANG:ruby
 #coding: utf-8
 
 module Shoulda
   module Context
     class Context # :nodoc:
 
       # should ブロックの :name に行番号を書き足す
       def add_caller_line_number(should)
         # 現バージョンでは2番目が呼び出し元になるみたい
         caller(2).first =~ /:(\d+):in /
         should[:name] = should[:name].to_s + " (at line #{$1})"
       end
 
       alias :should_without_caller_information :should
       def should(name_or_matcher, options = {}, &blk)
         count_before = self.shoulds.count
         should_without_caller_information(name_or_matcher, options = {}, &blk)
         if self.shoulds.count > count_before
           add_caller_line_number(self.shoulds.last)
         else
           add_caller_line_number(self.should_eventuallys.last)
         end
       end
 
       alias :should_not_without_caller_information :should_not
       def should_not(matcher)
         should_not_without_caller_information(matcher)
         add_caller_line_number(self.shoulds.last)
       end
 
       alias :should_eventually_without_caller_information :should_eventually
       def should_eventually(name, &blk)
         should_eventually_without_caller_information(name, &blk)
         add_caller_line_number(self.should_eventuallys.last)
       end
 
     end
   end
 end

これで、
 LANG:console
   3) Failure:
 AccountControllerTest#test_: with locale ja, without login, GET #home should not 
 set the flash (at line 35).  
 [/var/lib/gems/2.0.0/gems/shoulda-context-1.2.1/lib/shoulda/context/context.rb:358]:
 Did not expect the flash to be set, but was #<ActionDispatch::Flash::FlashHash:0xad041fc ...

などというように、should や should_not に対応する行番号が (at line xxx) の形で付くようになりました。

caller() は遅いので、called_from を作った、という記事がありました。テストの規模が大きい場合には検討の価値があるかもしれません。(このライブラリ、Ruby 1.8 以外に対応していない?)~
http://magazine.rubyist.net/?0031-BackTrace#l7 ~
とはいえ1万個の should を作ってようやく1秒違うかどうかなので、気にするだけ損なのかも。

** minitest-focus の代わりに shoulda-focus [#g183283d]

Minitest::Unit によるテストを便利にするための gem として minitest-focus というものがあります。

http://docs.seattlerb.org/minitest-focus/

これは、 編集中のテストケースの中で、ある特定のテストだけを実行したいときに そのテストに focus というキーワードを付けることで、同じテストケースの中の他のテストをすべてスキップできる、という便利な gem です。

特に guard-minitest でテストを自動実行しているときには非常に便利です。

ただ、shoulda-context との組み合わせはどうもよくありません。

そこで、shoulda-context を併用する際に使える focus を以下のように作って使っています。

この記事は Quiita にも載せました。http://qiita.com/osamu_takeuchi/items/256e044a3e802a5abff0

test/shoulda_macros/shoulda-focus.rb
 LANG:ruby(linenumber)
 # shoulda-focus.rb
 #
 # tested with: shoulda (3.5.0), minitest (4.7.5)
 #
 # Marking a context or a should by 'focus'
 # allows testing only the focused tests, 
 # skipping all other unfocused ones in a TestCase.
 #
 # REVISIONS:
 #   2014-05-30 report number of skipped tests in the result
 #
 # EXAMPLE:
 #
 # class MyTest < ActionController::TestCase
 # 
 #   should "will not be tested" do assert false end
 # 
 #   context "Unfocused context" do
 #     should "will not be tested" do assert false end
 # 
 #     focus  # <<<<<< FOCUSED!
 #     should "will be tested" do assert true end
 # 
 #     should "will not be tested" do assert false end
 #   end
 # 
 #   focus  # <<<<<< FOCUSED!
 #   context "Focused context" do
 #     should "will be tested" do assert true end
 #     should "will be tested" do assert true end
 #     should "will be tested" do assert true end
 #     context "Focused sub context" do
 #       should "will be tested" do assert true end
 #     end
 #   end
 # 
 # end
 #
 
 module Shoulda
   module Context
 
     class << self
     
       attr_accessor :focused      # indicates we are in a focused context
       attr_writer   :focus_next   # indicates focus just called, we will focus the next context/should
       attr_accessor :focus_used   # indicates focus is used, then we will skip unfocused shoulds
 
       # to see if we should focus the next context
       def to_focus?
         # either we are in a focused context or 'focus' was just called
         result = @focused || @focus_next
         @focus_next = false
         result
       end
 
     end
     
     module ClassMethods
     
       # call this method to mark the next context/should to be focused
       def focus
         unless Shoulda::Context.focus_used
           puts "", 
                "  * FOCUS CALLED FOR [#{self.name}] WE WILL SKIP UNFOCUSED TESTS",
                "    " + caller(2).first.sub(/:in .*/, ''),
                ""
         end
         Shoulda::Context.focus_used = true
         Shoulda::Context.focus_next = true
       end
       
     end
 
     class Context
     
       alias :initialize_without_focus :initialize
       def initialize(name, parent, &blk)
         was_focused = Shoulda::Context.focused
         Shoulda::Context.focused = Shoulda::Context.to_focus?
 
           initialize_without_focus(name, parent, &blk)
 
         Shoulda::Context.focused = was_focused
       end
 
       alias :should_without_focus :should
       def should(name_or_matcher, options = {}, &blk)
         to_focus = Shoulda::Context.to_focus?
 
         count_before = shoulds.count
         should_without_focus(name_or_matcher, options, &blk)
         
         if shoulds.count > count_before
           shoulds.last[:focused] = to_focus
         else
           should_eventuallys.last[:focused] = to_focus
         end
       end
       
       alias :should_not_without_focus :should_not
       def should_not(matcher)
         to_focus = Shoulda::Context.to_focus?
         should_not_without_focus(matcher)
         shoulds.last[:focused] = to_focus
       end
       
       alias :create_test_from_should_hash_without_focus :create_test_from_should_hash
       def create_test_from_should_hash(should)
         if not Shoulda::Context.focus_used or should[:focused]
           create_test_from_should_hash_without_focus(should)
         else
        else
 #         to report skipped tests verbosely, use skip() instead (will be too noisy and slow)
 #          should[:block] = Proc.new(){ skip() }
           should[:block] = Proc.new(){ increase_skip_count_quietly() }
           create_test_from_should_hash_without_focus(should)
         end
       end
     end
   end
 end
 
 MiniTest::Unit::TestCase.class_eval do
   alias :run_without_quiet_skip :run
   def run(runner)
     @runner = runner
     run_without_quiet_skip(runner)
   end
   
   def increase_skip_count_quietly()
     @runner.instance_eval do
       @skips += 1
     end
   end
 end

* assert_XXX [#h14dab47]

|>|>|>|>|~[[mt>https://github.com/seattlerb/minitest/blob/7e25922c340fa1cfdbeffaf8f37ec020cf6a4fff/lib/minitest/unit.rb#L190]] [[AS>https://github.com/rails/rails/blob/4-0-stable/activesupport/lib/active_support/testing/assertions.rb]] [[AS dep>https://github.com/rails/rails/blob/4-1-stable/activesupport/lib/active_support/testing/deprecation.rb]] [[AD>https://github.com/rails/rails/tree/4-0-stable/actionpack/lib/action_dispatch/testing/assertions]] [[AC>https://github.com/rails/rails/blob/4-0-stable/actionpack/lib/action_controller/test_case.rb#L95]] [[sd>https://github.com/thoughtbot/shoulda-context/blob/master/lib/shoulda/context/assertions.rb]] [[jr>https://github.com/rails/jquery-rails/blob/master/lib/jquery/assert_select.rb]] で実装される assert_xxxx |
|名前|引数|テスト内容|refuse|実装|
| assert |test, msg = nil | test | ○ |mt|
| assert_accepts | matcher, target, options = {} | matcher.matches?(target) | | sd |
| assert_blank | obj, msg=nil | obj.blank? | | AS |
| assert_contains | collection, x, extra_msg = "" | x can be regexp | | sd |
| assert_deprecated | match = nil, &block | | | AS |
| assert_difference|expression, diff = 1, msg = nil, &block|block 前後で変化||AS|
| assert_does_not_contain | collection, x, extra_msg = "" | x can be regexp | | sd |
| assert_dom_equal | expected, actual, message = "" | HTML::Document.new(?).rootを比較 | | AD |
| assert_dom_not_equal | expected, actual, message = "" | HTML::Document.new(?).rootを比較 | | AD |
| assert_empty |obj, msg = nil | obj.empty? | ○ |mt|
| assert_equal |exp, act, msg = nil | exp == act | ○ |mt|
| assert_generates | expected_path, options, defaults={}, extras = {}, message=nil | | | AD |
| assert_in_delta |exp, act, delta = 0.001, msg = nil | (act-exp).abs <= delta | ○ |mt|
| assert_in_epsilon |a, b, epsilon = 0.001, msg = nil | 上の別名 | ○ |mt|
| assert_includes |collection, obj, msg = nil | collection.include?(obj) | ○ |mt|
| assert_instance_of |cls, obj, msg = nil | obj.instance_of?(cls) | ○ |mt|
| assert_kind_of |cls, obj, msg = nil | obj.kind_of?(cls) | ○ |mt|
| assert_match |matcher, obj, msg = nil | matcher =~ obj | ○ |mt|
| assert_nil |obj, msg = nil | obj.nil? | ○ |mt|
| assert_no_difference|expression, msg = nil, &block|block 前後で保存||AS|
| assert_no_match |matcher, obj, msg = nil | matcher !~ obj |  |mt|
| assert_no_tag | *opts | html_document.find(*opts) | | AD |
| assert_not |obj, msg = nil|!obj||AS|
| assert_not_deprecated | &block | | |AS|
| assert_not_empty |obj, msg = nil | !obj.empty? |  |mt|
| assert_not_equal |exp, act, msg = nil | exp != act |  |mt|
| assert_not_in_delta |exp, act, delta = 0.001, msg = nil | (act-exp).abs > delta |  |mt|
| assert_not_in_epsilon |a, b, epsilon = 0.001, msg = nil | 上の別名 |  |mt|
| assert_not_includes |collection, obj, msg = nil | !collection.include?(obj) |  |mt|
| assert_not_instance_of |cls, obj, msg = nil | !obj.instance_of?(cls) |  |mt|
| assert_not_kind_of |cls, obj, msg = nil | !obj.kind_of?(cls) |  |mt|
| assert_not_nil |obj, msg = nil | !obj.nil? |  |mt|
| assert_not_operator |o1, op, o2 = UNDEFINED, msg = nil | !o1._send_(op, o2) |  |mt|
| assert_not_predicate |o1, op, msg = nil | !o1._send_(op) |  |mt|
| assert_not_respond_to |obj, meth, msg = nil | !obj.respond_to?(meth) |  |mt|
| assert_not_same |exp, act, msg = nil | !exp.equal?(act) |  |mt|
| assert_nothing_raised | | | | |
| assert_operator |o1, op, o2 = UNDEFINED, msg = nil | o1._send_(op, o2) | ○ |mt|
| assert_output |stdout = nil, stderr = nil | | |mt|
| assert_predicate |o1, op, msg = nil | o1._send_(op) | ○ |mt|
| assert_raise | | | | |
| assert_raises |*exp |  | |mt|
| assert_recognizes | expected_options, path, extras={}, msg=nil | | | AD |
| assert_redirected_to | options = {}, message=nil | | | AD |
| assert_rejects | matcher, target, options = {} | !matcher.matches?(target) | | sd |
| assert_respond_to |obj, meth, msg = nil | obj.respond_to?(meth) | ○ |mt|
| assert_response | type, message = nil | | | AD |
| assert_routing | path, options, defaults={}, extras={}, message=nil | | | AD |
| assert_same |exp, act, msg = nil | exp.equal?(act) | ○ |mt|
| assert_same_elements | a1, a2, msg = nil | compare array asif it is set | | sd |
| assert_select | *args, &block | | | AD |
| assert_select_email | &block | | | AD |
| assert_select_encoded | element = nil, &block | | | AD |
| assert_select_jquery | *args, &block | | | jr |
| assert_send |[recv, msg, *args], m = nil | recv._send_(msg, *args) | |mt|
| assert_silent | |assert_output "", "" | |mt|
| assert_tag | *opts | html_document.find(*opts) | | AD |
| assert_template | options = {}, message = nil | | | AC |
| assert_throws |sym, msg = nil | | |mt|

- mt: minitest = https://github.com/seattlerb/minitest/blob/7e25922c340fa1cfdbeffaf8f37ec020cf6a4fff/lib/minitest/unit.rb#L190
- AS: ActiveSupport = https://github.com/rails/rails/blob/4-0-stable/activesupport/lib/active_support/testing/assertions.rb- AS: ActiveSupport deplication = https://github.com/rails/rails/blob/4-1-stable/activesupport/lib/active_support/testing/deprecation.rb
- AD: ActionDispatch = https://github.com/rails/rails/tree/4-0-stable/actionpack/lib/action_dispatch/testing/assertions
- AC: ActionController = https://github.com/rails/rails/blob/4-0-stable/actionpack/lib/action_controller/test_case.rb#L95
- sd: Shoulda = https://github.com/thoughtbot/shoulda-context/blob/master/lib/shoulda/context/assertions.rb
- jr: jquery-rails = https://github.com/rails/jquery-rails/blob/master/lib/jquery/assert_select.rb

AD: with_routing ~
AD: css_select ~

* shouda の matcher [#gd8d5b05]

** 一覧 [#m5e4b4ec]

#multicolumns

*** [[ActiveModel Matchers>https://github.com/thoughtbot/shoulda-matchers#activemodel-matchers]] [#n7a5004d]

-allow_mass_assignment_of
-''allow_value''
-ensure_inclusion_of
-ensure_exclusion_of
-ensure_length_of
-have_secure_password
-validate_absence_of
-validate_acceptance_of
-validate_confirmation_of
-validate_numericality_of
-validate_presence_of
-validate_uniqueness_of

#multicolumns

*** [[ActiveRecord Matchers>https://github.com/thoughtbot/shoulda-matchers#activerecord-matchers]] [#x0e4eaa5]

-accept_nested_attributes_for
-belong_to
-have_many
-have_one
-have_and_belong_to_many
-have_db_column
-have_db_index
-have_readonly_attribute
-serialize

#multicolumns

*** [[ActionController Matchers>https://github.com/thoughtbot/shoulda-matchers#actioncontroller-matchers]] [#kdd1429f]

-filter_param
-permit
-''redirect_to''
-''render_template''
-''render_with_layout''
-rescue_from
-''respond_with''
-route
-''set_session''
-''set_the_flash''
-use_after_filter
-use_after_action
-use_around_filter
-use_around_action
-use_before_filter
-use_around_action

#multicolumns(end)

始めからコード側で宣言的に書かれている、例えば has_many なんかを
わざわざテストしても、ほとんどコピペになるだけで意味は薄いと思います。

なので、上記のように matcher はたくさんある物の、頻繁に使うのは、

ActionModel の
-allow_value
-- allow_value('http://foo.com', 'http://bar.com/baz').for(:website_url)
-- allow_value('2013-01-01').for(:birthday_as_string).on(:create)
-- allow_value('open', 'closed').for(:state).with_message('State must be open or closed')

ActionController の

-redirect_to
-- redirect_to :list
-- redirect_to("description") { posts_path }
-render_template
-- render_template 'show'
-- render_template :show
-respond_with
-- respond_with 403 
-- respond_with :success
-- respond_with 500..600
-route
-- route(:get, '/posts').to(controller: 'posts', action: 'index')
-- route(:get, '/posts/1').to('posts#show', id: 1)
-set_session
-- set_session :key
-- set_session(:key).to("value")
-- set_session(:key).to{ calc_expectation }
-set_the_flash
-- set_the_flash
-- set_the_flash.to('value')
-- set_the_flash.to(/regexp/)
-- set_the_flash.to{ calc_expectation_in_string_or_regexp }  # 上のコードによる拡張
-- set_the_flash[:key]
-- set_the_flash[:key].to('value')
-- set_the_flash[:key].to(/regexp/)
-- set_the_flash[:key].now
-- set_the_flash[:key].to('value').now

くらいかもしれません。

** shoulda-matcher の作り方 [#m38d0482]

shoulda の matcher は matches?, description, failure_message などを実装した
Matcher オブジェクトを作成するファクトリメソッドとして実装されます。

それが分かっていれば上記 redirect_to_path のように、既存の matcher にパラメータを与えて作成して返すことは簡単にできます。

Matcher クラスを1から自分で作る場合のやりかたは、set_session のコードを見れば一通り分かりそうです。

https://github.com/thoughtbot/shoulda-matchers/blob/master/lib/shoulda/matchers/action_controller/set_session_matcher.rb

* guard-minitest で自動実行 [#wb648c09]

[[guard-minitest>https://github.com/guard/guard-minitest]] は、Rails 4 用の guard-test です。
guard により、ソースファイルを編集&保存するたびに関連のテストを自動実行することができます。

Rails 4 では標準のテスト環境が Test::Unit から Minitest::Unit に変更になったため、
guard-test が動かなくなり、代わりに開発されているのが guard-minitest のようです。

- http://bitplaying.tumblr.com/post/55711657471/rails-4-defaults-to-minitest-frustration-follows

 LANG:console
 $ jed Gemfile
  group :development do
    gem 'guard-minitest'
  end
 $ bundle install
 $ bundle exec guard init minitest
 $ jed Guardfile
  # Rails 4 となっている部分を有効にする
 $ bundle exec guard
  05:47:51 - INFO - Guard is using TerminalTitle to send notifications.
  05:47:51 - INFO - Guard::Minitest 2.3.0 is running, with Minitest::Unit 4.7.5!
  05:47:51 - INFO - Running: all tests
  Run options: --seed 40183
  
  # Running tests:
 
  ...
  
  05:47:56 - INFO - Guard is now watching at '(Rails root)'
  [1] guard(main)>_

別のコンソールから、

 LANG:console
 
 $ touch app/controllers/account_controller.rb
 

とすると元の窓で、

 LANG:console
 
  05:49:07 - INFO - Running: test/controllers/account_controller_test.rb
  
  Run options: --seed 43771
 
  # Running tests:
 
  ...
 
 [1] guard(main)>_

ちゃんとテストが自動実行されました。

恐らく、ファイルを編集する窓と、テスト結果を表示する窓とを開いておいて、
保存する度に横目でテスト結果を確認するような使い方になりそうです?

** たしかに自動実行はされるけど [#bf45d0f5]

例えば app/models/account.rb を編集すると test/models/account_test.rb が実行されるだけで、Account モデルを使う app/controllers/account_controller.rb のテストまで自動実行されるわけではありません。

実際のところ、account_controller_test.rb も合わせて実行するように設定することまでなら簡単にできそうですが、ちゃんと依存関係をたどってその他のコントローラや統合テストまで起動するのは恐らく簡単にはできないのだと思います。

ですから、app/models/account.rb の編集が一段落したら、例えば application_controller.rb に touch して全テストを実行する(そのように設定しておいて)といった使い方になるんだと思います。

guard で全テストを実行するには、"guard(main)>" のプロンプトで何も入力せず ENTER キーを押せばよいので、そういう用途にも楽ちんに対応できますね。

** 表示を見やすくする [#gbbca72e]

[[minitest-reporters>https://github.com/kern/minitest-reporters]] を使うと結果をカラー表示にできるようです。

- http://stackoverflow.com/questions/7959523/can-minitest-do-something-like-rspec-color-format-doc

 LANG:console
 $ jed Gemfile
  
  group :test do
    ...
  
    gem 'minitest-reporters'
  
  end
 
 $ bundle install
 $ jed test/test_helpder.rb
  ...
 
  require "minitest/reporters"
  Minitest::Reporters.use!
  ...
 $ bundle exec guard

ここには載せませんが、確かに出力がカラーになりました。

* should_eventually と skip() [#m30d873b]

shoulda-context の機能に should_eventually というのがあって、
通常 should と書くところを should_eventually と書いておく、
あるいは、should に名前だけ付けて do end を与えないことで、
ビヘイビャーを記述しつつ、実装を後から行うようにできます。

 LANG:ruby
 should_eventually "be implemented in the future" do # should_eventually
   assert false  # will not be tested for now
 end
 
 should "be implemented in the future, too"          # should without body

このときテスト画面には、

 LANG:console
   * DEFERRED: should be implemented in the future (at line 139).
   * DEFERRED: should be implemented in the future, too (at line 143).

というような表示を残すのみで、テストとしてはカウントされません。

一方、MiniTest::Unit::TestCase には skip() という関数が用意されていて、
テスト関数から skip() を呼ぶことで MiniTest::Skip という例外を投げることができて、
そのテスト関数の以降のテストをキャンセルできます。このとき、テストは skipped としてカウントされます。

 LANG:ruby
 should "be implemented in the future" do
   skip()         # cancel testing
   assert false   # this assertion will not be tested for now
 end 

当分実装しなさそうな、しばらくの間完全に忘れていて良い仕様については 
should_eventually または do end なしの should で記述しておけばよく、

逆にペンディングになっている仕様があることを目立たせたければ skip() を使えば良いのだと思います。

* RSpec の方が良いんでしょうか [#m55d50d7]

Web 上で情報を探していると、RSpec の記事の方がたくさん目にとまります。

長いものには巻かれておいた方が良いのかもしれません。

* 役立ちそうな記事 [#x64e134e]

- http://www.railstips.org/blog/archives/2009/02/21/shoulda-looked-at-it-sooner/
- http://d.hatena.ne.jp/language_and_engineering/20091023/p1
- http://www.atmarkit.co.jp/ait/articles/1112/22/news132.html
- http://guides.rubyonrails.org/testing.html
- http://maskana-soft.com/rails/pro/detail/test

* コメント [#yfa709ad]

#article_kcaptcha

Counter: 7069 (from 2010/06/03), today: 2, yesterday: 0