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

更新


公開メモ

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

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

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

いろいろ検討中

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

コントローラのテスト

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

test/controllers/account_controller_test.rb

LANG:ruby(linenumber)
#coding: utf-8
require 'test_helper'

class AccountControllerTest < ActionController::TestCase
  tests AccountController   # この例のように標準的な命名をしている限り、
                            # コントローラ名の指定はしなくてもOK

  # すべての context に共通の初期化処理
  setup {

    clear_all

    # ActionController::TestCase にて、以下のクラス変数が使われている。
    #
    #   @@required_fixture_classes: false
    #   @@already_loaded_fixtures: {}
    #   @@test_suites: {ActiveSupport::TestCase=>true, ...}
    #   @@current: #<AccountControllerTest:0xb192174>
    #
    # クラス変数を使う場合は、これらおよび未来の TestCase のクラス変数と
    # 名前が被らないよう注意する必要あり

    # setup とテストケース should の中とで情報をやりとりするのに
    # 普通にインスタンス変数 @xxx を使うか、反則技でクラス変数 @@xxx を
    # 使うのが良いか迷い中
    #
    # インスタンス変数を使うのが普通だが、
    #   - クラススコープからインスタンス変数にアクセスできなくて
    #     残念に感じることがたまにある
    #   - 比較的多数のインスタンス変数がフレームワークによって
    #     使われているため、命名の衝突が怖い
    #
    # クラス変数を使うのはかなり異端
    #   - 本来の目的からずれまくってるので完全に反則技
    #   - 初期化し忘れると直前のテストケースの値が残ってしまうのも問題
    #   - その代わり、どこからでもアクセスできるのがかなり便利?

    @@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 内で定義されたインスタンス変数
  }

  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 'invalid POST #create' do
          setup { 
            post :create, account: @@invalid_account_params
          }
          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
          }
          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
          end
        end
        
      end

      context "with auto-login," do

        setup {  
          @@account = Account.create!(
            @@valid_account_params.merge(
              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
        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)
          }

          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/test_helper.rb

LANG:ruby
...

require 'test_i18n_translation_strictly'
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)
if ENV['RAILS_ENV'] == "test"

# 翻訳が見付からないときに例外を投げる
#   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

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

コメント





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