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

更新


公開メモ

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

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

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

いろいろ検討中

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

便利な gem

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

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 set_the_flash  # already login
          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'

# should 用のマクロを読み込む
Dir[File.expand_path('../shoulda_macros', __FILE__) << '/*.rb'].each do |file|
  require file
end

...

I18n.translate の翻訳の抜けを検出する

config/initializers/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

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

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

コメント





Counter: 8375 (from 2010/06/03), today: 1, yesterday: 0