miauのブログ

はてなダイアリー「miauの避難所」をはてなブログに移行しました

spec_server の話 (2)

とあるパッケージの推奨環境が rspec-rails 1.2.9 以上だったので、rspec-rails の 1.2.9 をインストールしました。すると、今まで動いていたテストが、

>rake spec
(druby://127.0.0.1:8989) ***/spec/spec_helper.rb:25: undefined method `use_transactional_fixtures=' for #<Spec::Runner::Config
uration:0x4a71710> (NoMethodError)

とかいうエラーで失敗するように。

現象については rspec-rails 1.2.9 Released - Ruby Forum でも報告されていて、どうも --drb オプションを使ってテストを実行した場合のみ発生するとのこと。

この件についていろいろ調査してみました。

解決方法

例によって長くなるので先に原因&解決方法を書いておきます。

原因は rspec-rails 1.2.9 ではなく、それが要求する rspec 1.2.9 のようで。

このチェンジセット以降で問題が発生しているようなので、ひとまずこのチェンジセットで加えられた処理を消してしまえば動作するようになります。具体的には、 (GEM PATH)/rspec-1.3.0/lib/spec/runner/option_parser.rb を以下のように変更します。

diff --git a/lib/spec/runner/option_parser.rb b/lib/spec/runner/option_parser.rb
index 87c9522..cc85235 100644
--- a/lib/spec/runner/option_parser.rb
+++ b/lib/spec/runner/option_parser.rb
@@ -163,12 +163,6 @@ module Spec
             options_file = @argv.delete_at(index)
           end
         end
-        
-        if options_file.nil? &&
-           File.exist?('spec/spec.opts') &&
-           !@argv.any?{|a| a =~ /^\-/ }
-             options_file = 'spec/spec.opts'
-        end
 
         if options_file
           send(action, options_file)

lib 配下を変更したくないということであれば、メソッド全体を差し替えてもいいでしょう。spec/spec_helper_drb.rb みたいな名称で、

# rspec 1.2.9 以降で
#   undefined method `use_transactional_fixtures=' for #<Spec::Runner::Configuration>
# エラーが出る件の対策。
# 
# rspec-1.3.0/lib/spec/runner/option_parser.rb にある以下の処理を
# 削除したメソッドに差し替える。
# 
#        if options_file.nil? &&
#           File.exist?('spec/spec.opts') &&
#           !@argv.any?{|a| a =~ /^\-/ }
#             options_file = 'spec/spec.opts'
#        end

require 'spec/runner/option_parser.rb'

module Spec
  module Runner
    class OptionParser < ::OptionParser
      def parse_file_options(option_name, action)
        # Remove the file option and the argument before handling the file
        options_file = nil
        options_list = OPTIONS[option_name][0..1]
        options_list[1].gsub!(" PATH", "")
        options_list.each do |option|
          if index = @argv.index(option)
            @argv.delete_at(index)
            options_file = @argv.delete_at(index)
          end
        end

        if options_file
          send(action, options_file)
          return true
        else
          return false
        end
      end
    end
  end
end

こんな感じのファイルを用意しておいて、spec_server で spec_helper.rb を呼び出す前に読んでやれば、ひとまず OK です。

Index: script/spec_server
===================================================================
--- script/spec_server	(revision 1480)
+++ script/spec_server	(revision 1481)
@@ -96,6 +96,7 @@
             require_dependency(name) if File.exists?("#{RAILS_ROOT}/app/controllers/#{name}")
           end
         end
+        require "#{RAILS_ROOT}/spec/spec_helper_drb.rb"
         load "#{RAILS_ROOT}/spec/spec_helper.rb"
 
         if in_memory_database?

エラーの原因

一応だらだら書いてみますが、読み飛ばし推奨です。

エラーが起きてる場所は ***/spec/spec_helper.rb の、

Spec::Runner.configure do |config|
  # If you're not using ActiveRecord you should remove these
  # lines, delete config/database.yml and disable :active_record
  # in your config/boot.rb
  config.use_transactional_fixtures = true
  config.use_instantiated_fixtures  = false
  config.fixture_path = RAILS_ROOT + '/spec/fixtures/'

この部分で。Spec::Runner::Configuration の use_transactional_fixtures= は (GEM PATH)/rspec-rails-1.3.2/lib/spec/rails/extensions/spec/runner/configuration.rb で rspec のものの拡張が行われていて、

require 'spec/runner/configuration'
require 'test_help'

if defined?(ActiveRecord::Base)
  module Spec
    module Runner
      class Configuration
        :
        def use_transactional_fixtures=(value)
          ActiveSupport::TestCase.use_transactional_fixtures = value
        end

こんな定義になっていると。ActiveRecord::Base が読み込まれる前にこちらが読み込まれると定義がうまくいかなそうですね。

さらに処理を追ったりいじったりしていると、ActiveSupport::TestCase に use_transactional_fixtures= が無いと言われることもあって。これは (GEM PATH)/activerecord-2.3.4/lib/active_record/fixtures.rb とかで定義されていて、

module ActiveRecord
  module TestFixtures
    def self.included(base)
      base.class_eval do
        setup :setup_fixtures
        teardown :teardown_fixtures

        superclass_delegating_accessor :fixture_path
        superclass_delegating_accessor :fixture_table_names
        superclass_delegating_accessor :fixture_class_names
        superclass_delegating_accessor :use_transactional_fixtures
        superclass_delegating_accessor :use_instantiated_fixtures   # true, false, or :no_instances
        superclass_delegating_accessor :pre_loaded_fixtures

まあこんな感じと。これを読み込むために (GEM PATH)/rails-2.3.4/lib/test_help.rb で、

if defined?(ActiveRecord)
  require 'active_record/test_case'
  require 'active_record/fixtures'

  class ActiveSupport::TestCase
    include ActiveRecord::TestFixtures
    self.fixture_path = "#{RAILS_ROOT}/test/fixtures/"
    self.use_instantiated_fixtures  = false
    self.use_transactional_fixtures = true
  end

こんなことやってるみたいです。

事前に必要な処理を呼び出して対策しようとする→失敗

だったら最初のファイル(***/spec/spec_helper.rb)で該当処理の前に、

require 'active_record'
require 'active_record/fixtures'
class ActiveSupport::TestCase
  include ActiveRecord::TestFixtures
end
require 'spec/rails/extensions/spec/example'
require 'spec/rails/extensions/spec/runner/configuration'

こんな感じでいろいろ必要な処理を呼び出してやればうまくいかないかなー?と期待したんだけど、これはうまくいかず。

これは require の順序が入れ子になっていて、必要ファイルの require が終わる前に require 後の処理が走っていたりするから。

たとえば、a.rb として

puts "#{__FILE__}:#{__LINE__}"
require 'b'
puts "#{__FILE__}:#{__LINE__}"

こんなファイルを用意しておくと、この 3 行目は b.rb の読み込み後に動作することが期待されるわけですが。b.rb のほうで

puts "#{__FILE__}:#{__LINE__}"
require 'a'
puts "#{__FILE__}:#{__LINE__}"

こんな風に a.rb を呼び出してたとすると、

>ruby a.rb
a.rb:1
./b.rb:1
./a.rb:1
./a.rb:3
./b.rb:3
a.rb:3

のように、b.rb の呼び出しが終わる前に a.rb の 3 行目が走っちゃったりします。

後で調べてわかったけど、自分で事前処理を入れる前から rspec やら rspec-rails の中でこんな感じで入れ子の require が行われていて、適切な事前処理が行われず例外が発生→その後の require がすっ飛ばされて事前処理が呼び出されていない部分がさらに増える、みたいな状態になってました。(この辺を追うために、site_ruby/1.8/rubygems/custom_require.rb に例外ハンドリングの処理を追加したりしてました。)

Ruby やなんかだと「ちょっとクラスの挙動を変えたい」といったときに、気軽に

  • 元モジュールの require
  • 気に食わない部分の書き換え

みたいなことをやってしまいがちですが、適切な require 順を崩さないようにするルールが必要な気がしますね。「デッドロックを発生させないようにテーブルの読み込み順を決めておく」みたいな対策と同じで。

問題が発生したリビジョンを調べてみる

ちょっとどの部分に手を入れていいかわからないので、別のアプローチをとってみます。

rspec-rails 1.2.9 をアンインストールしても冒頭のエラーは発生していたので、rspec の 1.2.9 だか 1.3.0 のほうに問題がありそうなところまでは目星がついていて。(GEM PATH)/rspec-1.3.0 をいったん削除して、

>cd /d D:\InstantRails\ruby\lib\ruby\gems\1.8\gems\
>git clone git://github.com/dchelimsky/rspec.git rspec-1.3.0

とかやって git の最新版に差し替えておきます。

で、git bisect を使って問題が発生したリビジョンを調べます。git start 時点でリビジョンを指定したり、git run を使ってテストするやり方もあるみたいですが、ここではもっと単純に・・・

>git bisect start

として bisect start しておいて。まず現行のリビジョンはテストに失敗するので、bad であることをマーク。

>git bisect bad

次に rspec 1.2.8 までは正常に動作していたようなので、こちらは good であることをマーク。

>git bisect good 1.2.8
Bisecting: 89 revisions left to test after this (roughly 7 steps)
[970c2f04ea2b554b12782c80d5bd96a924ec6a18] include Spec::Matchers in each Matcher

すると勝手に一番古い bad と 一番新しい good の真ん中のリビジョンを取得するので、この状態でテストして、good か bad かをマークしていけば OK。

>git bisect bad
Bisecting: 44 revisions left to test after this (roughly 6 steps)
[318fa1f230d6ee7902efb79817ccc58225f5d685] update formatted html to 1.8.7 patch 174

>git bisect good
Bisecting: 22 revisions left to test after this (roughly 5 steps)
[3d29633753a535fbbf3ef3b4b46ca963c21838ec] Fixed problem with colorized Output when writing to a file

>git bisect bad
Bisecting: 10 revisions left to test after this (roughly 4 steps)
[a2a3b0f120bc8da9dcb054c121ffc62304134a50] Push autoloading of spec/spec.opts to option_parser

>git bisect bad
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[1762539dacedeb73a8a42440aa95c492d50a52ae] back to trying localhost:0 first, then :0 (was failing on RCR)

>git bisect good
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[8eea6abbfe3bd8744b24051856d6b5ac972bc327] update History and Upgrade info

>git bisect good
Bisecting: 0 revisions left to test after this (roughly 1 steps)
[77190d35ba797f606f82ae93e73ce9d60c0cd4f9] exist matcher now takes an arg (works for filesystem)

>git bisect good
a2a3b0f120bc8da9dcb054c121ffc62304134a50 is the first bad commit	
commit a2a3b0f120bc8da9dcb054c121ffc62304134a50
Author: David Chelimsky <dchelimsky@gmail.com>
Date:   Tue Sep 8 08:03:59 2009 -0500

    Push autoloading of spec/spec.opts to option_parser

    - also added fakefs as developer dependency

:100644 100644 2d0b1c6ccb909b9e2a82e7b7371de3f4a2e62b35 cae85aec62da60b9ed1acc8d42413717d35b7165 M      Rakefile
:040000 040000 5fada7f9bff1fb8b76c9b6f96b7b0a6e2b2adc3a 80cf229e8d610800f8e4695ba35cb50f3ada6e2a M      bin
:040000 040000 1e75a179eace799df3ef7a06cacb29ec38b14753 94656c9d3f2e624d618cf127e0eb9c51441c0d2c M      lib
:040000 040000 00420a4c70c5d7f0b4111c5670daa2e6534de268 ce7465ae91388a5ab32b6886d96012e5c43e9279 M      spec

という感じで問題のリビジョンがわかりました。

>git bisect reset
Checking out files: 100% (201/201), done.
Previous HEAD position was 77190d3... exist matcher now takes an arg (works for filesystem)
Switched to branch 'master'

で元のリビジョンに戻しておきましょう。

問題の処理は・・・?

このチェンジセットを見ると、

        if options_file.nil? &&
           File.exist?('spec/spec.opts') &&
           !@argv.any?{|a| a =~ /^\-/ }
             options_file = 'spec/spec.opts'
        end

のような処理が追加されて、spec/spec.opts の内容が読み込まれるようになっているみたいですが。ここで余計なオプションが追加されて必要なモジュールの読み込みが追加される→require 順が変になってしまう、とかそんな感じだと思います。(ここはちゃんと調べてません。)

そうでなくても spec.opts に --drb を書いておくと、spec_server からさらに spec_server を呼び出して〜という感じで無限ループになってしまいます。そんな感じでとにかく嬉しくない処理なので、冒頭に書いたようにこの処理を削って対応している状態です。

ちなみにこの調査だけでまる二日間くらい使ってたりします・・・。まあその過程で dRuby の理解が深まったりしたので、これはこれでいいんですけど。