ActiveSupport_TestCase + shoulda によるテスト の履歴(No.3)
更新- 履歴一覧
- 差分 を表示
- 現在との差分 を表示
- ソース を表示
- ソフトウェア/rails/ActiveSupport_TestCase + shoulda によるテスト へ行く。
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: 8956 (from 2010/06/03),
today: 2,
yesterday: 2