ActiveSupport_TestCase + shoulda によるテスト
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 の行が含まれていない???
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 | 実装 |
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†
一覧†
ActiveModel Matchers†
| ActiveRecord Matchers†
| ActionController Matchers†
|
始めからコード側で宣言的に書かれている、例えば 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 の作り方†
shoulda の matcher は matches?, description, failure_message などを実装した Matcher オブジェクトを作成するファクトリメソッドとして実装されます。
それが分かっていれば上記 redirect_to_path のように、既存の matcher にパラメータを与えて作成して返すことは簡単にできます。
Matcher クラスを1から自分で作る場合のやりかたは、set_session のコードを見れば一通り分かりそうです。
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 の記事の方がたくさん目にとまります。
長いものには巻かれておいた方が良いのかもしれません。