ActiveSupport_TestCase + shoulda によるテスト

(1453d) 更新

公開メモ

ActiveSupport::TestCase + shoulda による Ruby on Rails のテスト

  • ActiveSupport::TestCase は Rails 標準のテストフレームワーク
  • shoulda は読みやすいテストケースを書くための拡張

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

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

環境

現在のこちらの環境は以下の通りです。

  • rails 4.0.4
  • minitest 4.7.5
  • shoulda 3.5.0

いろいろ検討中

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

便利な gem

  • 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 の基本

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

コントローラのテスト

途中ロケールの処理が入っていますが、そのあたりの構成は ソフトウェア/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

モデル生成用パラメータ

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

独自拡張を読み込む

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 の翻訳の抜けを検出する

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 の拡張

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

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

エラーの生じた should を特定しやすくする

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

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

ごたごたは飛ばして解決策へ飛ぶ場合は こちら

試行錯誤

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

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 に溜められて、後から順に実行され、そこでエラーが出ると。

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

解決

むしろ、この :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

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

mt AS AS dep AD AC sd jr で実装される assert_xxxx
名前引数テスト内容refuse実装
 asserttest, msg = niltestmt
 assert_acceptsmatcher, target, options = {}matcher.matches?(target)sd
 assert_blankobj, msg=nilobj.blank?AS
 assert_containscollection, x, extra_msg = ""x can be regexpsd
 assert_deprecatedmatch = nil, &blockAS
 assert_differenceexpression, diff = 1, msg = nil, &blockblock 前後で変化AS
 assert_does_not_containcollection, x, extra_msg = ""x can be regexpsd
 assert_dom_equalexpected, actual, message = ""HTML::Document.new(?).rootを比較AD
 assert_dom_not_equalexpected, actual, message = ""HTML::Document.new(?).rootを比較AD
 assert_emptyobj, msg = nilobj.empty?mt
 assert_equalexp, act, msg = nilexp == actmt
 assert_generatesexpected_path, options, defaults={}, extras = {}, message=nilAD
 assert_in_deltaexp, act, delta = 0.001, msg = nil(act-exp).abs <= deltamt
 assert_in_epsilona, b, epsilon = 0.001, msg = nil上の別名mt
 assert_includescollection, obj, msg = nilcollection.include?(obj)mt
 assert_instance_ofcls, obj, msg = nilobj.instance_of?(cls)mt
 assert_kind_ofcls, obj, msg = nilobj.kind_of?(cls)mt
 assert_matchmatcher, obj, msg = nilmatcher =~ objmt
 assert_nilobj, msg = nilobj.nil?mt
 assert_no_differenceexpression, msg = nil, &blockblock 前後で保存AS
 assert_no_matchmatcher, obj, msg = nilmatcher !~ objmt
 assert_no_tag*optshtml_document.find(*opts)AD
 assert_notobj, msg = nil!objAS
 assert_not_deprecated&blockAS
 assert_not_emptyobj, msg = nil!obj.empty?mt
 assert_not_equalexp, act, msg = nilexp != actmt
 assert_not_in_deltaexp, act, delta = 0.001, msg = nil(act-exp).abs > deltamt
 assert_not_in_epsilona, b, epsilon = 0.001, msg = nil上の別名mt
 assert_not_includescollection, obj, msg = nil!collection.include?(obj)mt
 assert_not_instance_ofcls, obj, msg = nil!obj.instance_of?(cls)mt
 assert_not_kind_ofcls, obj, msg = nil!obj.kind_of?(cls)mt
 assert_not_nilobj, msg = nil!obj.nil?mt
 assert_not_operatoro1, op, o2 = UNDEFINED, msg = nil!o1._send_(op, o2)mt
 assert_not_predicateo1, op, msg = nil!o1._send_(op)mt
 assert_not_respond_toobj, meth, msg = nil!obj.respond_to?(meth)mt
 assert_not_sameexp, act, msg = nil!exp.equal?(act)mt
 assert_nothing_raised
 assert_operatoro1, op, o2 = UNDEFINED, msg = nilo1._send_(op, o2)mt
 assert_outputstdout = nil, stderr = nilmt
 assert_predicateo1, op, msg = nilo1._send_(op)mt
 assert_raise
 assert_raises*expmt
 assert_recognizesexpected_options, path, extras={}, msg=nilAD
 assert_redirected_tooptions = {}, message=nilAD
 assert_rejectsmatcher, target, options = {}!matcher.matches?(target)sd
 assert_respond_toobj, meth, msg = nilobj.respond_to?(meth)mt
 assert_responsetype, message = nilAD
 assert_routingpath, options, defaults={}, extras={}, message=nilAD
 assert_sameexp, act, msg = nilexp.equal?(act)mt
 assert_same_elementsa1, a2, msg = nilcompare array asif it is setsd
 assert_select*args, &blockAD
 assert_select_email&blockAD
 assert_select_encodedelement = nil, &blockAD
 assert_select_jquery*args, &blockjr
 assert_send[recv, msg, *args], m = nilrecv._send_(msg, *args)mt
 assert_silentassert_output "", ""mt
 assert_tag*optshtml_document.find(*opts)AD
 assert_templateoptions = {}, message = nilAC
 assert_throwssym, msg = nilmt

AD: with_routing
AD: css_select

shouda の matcher

一覧

ActiveModel Matchers

  • 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

ActiveRecord Matchers

  • 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

ActionController Matchers

  • 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

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

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

ActionModel の

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 の作り方

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 で自動実行

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

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

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)>_

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

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

たしかに自動実行はされるけど

例えば 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 キーを押せばよいので、そういう用途にも楽ちんに対応できますね。

表示を見やすくする

minitest-reporters を使うと結果をカラー表示にできるようです。

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()

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 の方が良いんでしょうか

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

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

役立ちそうな記事

コメント





Counter: 3246 (from 2010/06/03), today: 1, yesterday: 2