LoginSignup
35
33

More than 5 years have passed since last update.

RSpecコードリーディング(第1部:RSpec)

Last updated at Posted at 2014-02-16

RSpecがどうやってテストコードをロードするのかわからなかったので、ソースコードを読んでみました。

特に、以下の点がよくわからなかったので見ていきます。

  • specファイルはどうやって読み込まれるの?
  • 普通にRSpecを使うときにはspecファイルにrequire 'spec_helper'って書くけど、なんでspec/なしでいいの?
  • Turnipを使った時はrequire 'spec_helper'してないのに読み込まれてて怖い><

RSpecのバージョンは2.14.7です。この記事中の全てのソースコードは以下のWebサイトからの引用ですが、一部説明のために日本語でコメントを追加してあります。

第1部:RSpec

まずはTurnipは気にせずにRSpecのコードを追っていきます。

1. rspecコマンド

まずはrspecコマンド。エラー処理以外はrequire 'rspec/autorun'してるだけです。

rspec-core-2.14.7/exe/rspec
#!/usr/bin/env ruby

begin
  require 'rspec/autorun'  # 2へ
rescue LoadError
  $stderr.puts <<-EOS
#{'*'*50}
  Could not find 'rspec/autorun'

  This may happen if you're using rubygems as your package manager, but it is not
  being required through some mechanism before executing the rspec command.

  You may need to do one of the following in your shell:

    # for bash/zsh
    export RUBYOPT=rubygems

    # for csh, etc.
    set RUBYOPT=rubygems

  For background, please see http://gist.github.com/54177.
#{'*'*50}
  EOS
  exit(1)
end

2. rspec/autorun.rb

このファイルは2行だけです。

rspec-core-2.14.7/lib/rspec/autorun.rb
require 'rspec/core'  # 3へ
RSpec::Core::Runner.autorun  # 4へ

3. rspec/core.rb

長いですが、必要なファイルをrequireした後、RSpecモジュールを定義しています。

rspec-core-2.14.7/lib/rspec/core.rb
require_rspec = if defined?(require_relative)
  lambda do |path|
    require_relative path
  end
else
  lambda do |path|
    require "rspec/#{path}"
  end
end

require 'set'
require 'time'
require 'rbconfig'
require_rspec['core/filter_manager']
require_rspec['core/dsl']
require_rspec['core/extensions/kernel']
require_rspec['core/extensions/instance_eval_with_args']
require_rspec['core/extensions/module_eval_with_args']
require_rspec['core/extensions/ordered']
require_rspec['core/deprecation']
require_rspec['core/backward_compatibility']
require_rspec['core/reporter']

require_rspec['core/metadata_hash_builder']
require_rspec['core/hooks']
require_rspec['core/memoized_helpers']
require_rspec['core/metadata']
require_rspec['core/pending']
require_rspec['core/formatters']

require_rspec['core/world']
require_rspec['core/configuration']
require_rspec['core/project_initializer']
require_rspec['core/option_parser']
require_rspec['core/configuration_options']
require_rspec['core/command_line']
require_rspec['core/runner']
require_rspec['core/example']
require_rspec['core/shared_example_group/collection']
require_rspec['core/shared_example_group']
require_rspec['core/example_group']
require_rspec['core/version']

module RSpec
  autoload :SharedContext, 'rspec/core/shared_context'

  # @private
  def self.wants_to_quit
  # Used internally to determine what to do when a SIGINT is received
    world.wants_to_quit
  end

  # @private
  # Used internally to determine what to do when a SIGINT is received
  def self.wants_to_quit=(maybe)
    world.wants_to_quit=(maybe)
  end

  # @private
  # Internal container for global non-configuration data
  def self.world
    @world ||= RSpec::Core::World.new
  end

  # @private
  # Used internally to set the global object
  def self.world=(new_world)
    @world = new_world
  end

  # @private
  # Used internally to ensure examples get reloaded between multiple runs in
  # the same process.
  def self.reset
    @world = nil
    @configuration = nil
  end

  # Returns the global [Configuration](RSpec/Core/Configuration) object. While you
  # _can_ use this method to access the configuration, the more common
  # convention is to use [RSpec.configure](RSpec#configure-class_method).
  #
  # @example
  #     RSpec.configuration.drb_port = 1234
  # @see RSpec.configure
  # @see Core::Configuration
  def self.configuration
    if block_given?
      RSpec.warn_deprecation <<-WARNING

*****************************************************************
DEPRECATION WARNING

* RSpec.configuration with a block is deprecated and has no effect.
* please use RSpec.configure with a block instead.

Called from #{caller(0)[1]}
*****************************************************************

WARNING
    end
    @configuration ||= RSpec::Core::Configuration.new
  end

  # @private
  # Used internally to set the global object
  def self.configuration=(new_configuration)
    @configuration = new_configuration
  end

  # Yields the global configuration to a block.
  # @yield [Configuration] global configuration
  #
  # @example
  #     RSpec.configure do |config|
  #       config.add_formatter 'documentation'
  #     end
  # @see Core::Configuration
  def self.configure
    yield configuration if block_given?
  end

  # @private
  # Used internally to clear remaining groups when fail_fast is set
  def self.clear_remaining_example_groups
    world.example_groups.clear
  end

  # @private
  def self.windows_os?
    RbConfig::CONFIG['host_os'] =~ /cygwin|mswin|mingw|bccwin|wince|emx/
  end

  module Core
    # @private
    # This avoids issues with reporting time caused by examples that
    # change the value/meaning of Time.now without properly restoring
    # it.
    class Time
      class << self
        define_method(:now,&::Time.method(:now))
      end
    end
  end

  MODULES_TO_AUTOLOAD = {
    :Matchers     => "rspec/expectations",
    :Expectations => "rspec/expectations",
    :Mocks        => "rspec/mocks"
  }

  def self.const_missing(name)
    # Load rspec-expectations when RSpec::Matchers is referenced. This allows
    # people to define custom matchers (using `RSpec::Matchers.define`) before
    # rspec-core has loaded rspec-expectations (since it delays the loading of
    # it to allow users to configure a different assertion/expectation
    # framework). `autoload` can't be used since it works with ruby's built-in
    # require (e.g. for files that are available relative to a load path dir),
    # but not with rubygems' extended require.
    #
    # As of rspec 2.14.1, we no longer require `rspec/mocks` and
    # `rspec/expectations` when `rspec` is required, so we want
    # to make them available as an autoload. For more info, see:
    require MODULES_TO_AUTOLOAD.fetch(name) { return super }
    ::RSpec.const_get(name)
  end
end

require_rspec['core/backward_compatibility']

4. rspec/core/runner.rb

2で呼ばれたRSpec::Core::Runner.autorunは、6行目で定義されています。Kernel.#at_exit を使って、インタプリタ終了時にRSpec::Core::Runner.runを実行するという仕組みになっています。

こうすることで誰にも邪魔させないみたいなことが書いてあるけどよくわかってません。

rspec-core-2.14.7/lib/rspec/runner.rb
module RSpec
  module Core
    class Runner

      # Register an at_exit hook that runs the suite.
      def self.autorun  # 6行目
        return if autorun_disabled? || installed_at_exit? || running_in_drb?
        at_exit do
          # Don't bother running any specs and just let the program terminate
          # if we got here due to an unrescued exception (anything other than
          # SystemExit, which is raised when somebody calls Kernel#exit).
          next unless $!.nil? || $!.kind_of?(SystemExit)

          # We got here because either the end of the program was reached or
          # somebody called Kernel#exit.  Run the specs and then override any
          # existing exit status with RSpec's exit status if any specs failed.
          status = run(ARGV, $stderr, $stdout).to_i
          exit status if status != 0
        end
        @installed_at_exit = true
      end
      AT_EXIT_HOOK_BACKTRACE_LINE = "#{__FILE__}:#{__LINE__ - 2}:in `autorun'"

      def self.disable_autorun!
        @autorun_disabled = true
      end

      def self.autorun_disabled?
        @autorun_disabled ||= false
      end

      def self.installed_at_exit?
        @installed_at_exit ||= false
      end

      def self.running_in_drb?
        defined?(DRb) &&
        (DRb.current_server rescue false) &&
         DRb.current_server.uri =~ /druby\:\/\/127.0.0.1\:/
      end

      def self.trap_interrupt
        trap('INT') do
          exit!(1) if RSpec.wants_to_quit
          RSpec.wants_to_quit = true
          STDERR.puts "\nExiting... Interrupt again to exit immediately."
        end
      end

同じファイルの下半分です。上で呼ばれていたRSpec::Core::Runner.runは66行目にあります。

オプションをパースして、80行目でCommandLine.new(options).run(err, out)を呼んでいます。

rspec-core-2.14.7/lib/rspec/runner.rb
      # Run a suite of RSpec examples.
      #
      # This is used internally by RSpec to run a suite, but is available
      # for use by any other automation tool.
      #
      # If you want to run this multiple times in the same process, and you
      # want files like spec_helper.rb to be reloaded, be sure to load `load`
      # instead of `require`.
      #
      # #### Parameters
      # * +args+ - an array of command-line-supported arguments
      # * +err+ - error stream (Default: $stderr)
      # * +out+ - output stream (Default: $stdout)
      #
      # #### Returns
      # * +Fixnum+ - exit status code (0/1)
      def self.run(args, err=$stderr, out=$stdout)  # 66行目
        trap_interrupt
        options = ConfigurationOptions.new(args)
        options.parse_options

        if options.options[:drb]
          require 'rspec/core/drb_command_line'
          begin
            DRbCommandLine.new(options).run(err, out)
          rescue DRb::DRbConnError
            err.puts "No DRb server is running. Running in local process instead ..."
            CommandLine.new(options).run(err, out)
          end
        else
          CommandLine.new(options).run(err, out)  # 80行目(5へ)
        end
      ensure
        RSpec.reset
      end
    end
  end
end

5. rspec/command_line.rb

4で呼ばれたCommandLine#runは18行目に定義されています。@configuration.load_spec_filesというSpecをロードしてそうなメソッドがあります。

@configurationは10行目で代入されています。configurationは4行目にあるinitializeの第2引数ですが、4の下半分に戻ればわかるように第2引数は渡されていません。このためデフォルト値のRSpec::configurationが使われます。

rspec-core-2.14.7/lib/rspec/command_line.rb
module RSpec
  module Core
    class CommandLine
      def initialize(options, configuration=RSpec::configuration, world=RSpec::world)  # 4行目
        if Array === options
          options = ConfigurationOptions.new(options)
          options.parse_options
        end
        @options       = options
        @configuration = configuration  # 10行目
        @world         = world
      end

      # Configures and runs a suite
      #
      # @param [IO] err
      # @param [IO] out
      def run(err, out)  # 18行目
        @configuration.error_stream = err
        @configuration.output_stream ||= out
        @options.configure(@configuration)  # 8へ
        @configuration.load_spec_files  # 6, 7へ
        @world.announce_filters

        @configuration.reporter.report(@world.example_count, @configuration.randomize? ? @configuration.seed : nil) do |reporter|
          begin
            @configuration.run_hook(:before, :suite)
            @world.example_groups.ordered.map {|g| g.run(reporter)}.all? ? 0 : @configuration.failure_exit_code
          ensure
            @configuration.run_hook(:after, :suite)
          end
        end
      end
    end
  end
end

6. rspec/core.rb(再掲)

RSpec::configurationは、2で読んだrspec/core.rbの87行目で定義されているので、ここに抜粋して再掲します。

102行目でRSpec::Core::Configuration.newされており、5の@configuration.load_spec_filesRSpec::Core::Configuration#load_spec_filesであることがわかります。

rspec-core-2.14.7/lib/rspec/core.rb
  # Returns the global [Configuration](RSpec/Core/Configuration) object. While you
  # _can_ use this method to access the configuration, the more common
  # convention is to use [RSpec.configure](RSpec#configure-class_method).
  #
  # @example
  #     RSpec.configuration.drb_port = 1234
  # @see RSpec.configure
  # @see Core::Configuration
  def self.configuration  # 87行目
    if block_given?
      RSpec.warn_deprecation <<-WARNING

*****************************************************************
DEPRECATION WARNING

* RSpec.configuration with a block is deprecated and has no effect.
* please use RSpec.configure with a block instead.

Called from #{caller(0)[1]}
*****************************************************************

WARNING
    end
    @configuration ||= RSpec::Core::Configuration.new  # 102行目
  end

7. rspec/core/configuration.rb

このファイルは1174行もあるため、必要な箇所を抜粋します。load_spec_filesは895行目にあります。

files_to_runを1つずつロードしています。

rspec-core-2.14.7/lib/rspec/core/configuration.rb
      # @private
      def load_spec_files  # 895行目
        files_to_run.uniq.each {|f| load File.expand_path(f) }
        raise_if_rspec_1_is_loaded
      end

files_to_runは612行目で代入されています。

rspec-core-2.14.7/lib/rspec/core/configuration.rb
      # @private
      def files_or_directories_to_run=(*files)
        files = files.flatten
        files << default_path if (command == 'rspec' || Runner.running_in_drb?) && default_path && files.empty?
        self.files_to_run = get_files_to_run(files)  # 612行目
      end

get_files_to_runは1023行目で定義されています。複雑ですが、引数で渡されたpathsの中からConfiguration#patternにマッチするファイルを返しているようです。

引数pathsは上(612行目)の呼び出し側に戻るとfilesです。filesには、rspecコマンドで対象のファイルを指定した場合はそれが、指定しなかった場合はdefault_pathすなわち'spec'が追加されます。

また、Configuration#patternはデフォルトだと'**/*_spec.rb'です。

このため、単純にrspecコマンドを実行するとspec/**/*_spec.rbにマッチするテストケースが実行されます。

rspec-core-2.14.7/lib/rspec/core/configuration.rb
      def get_files_to_run(paths)  # 1023行目
        paths.map do |path|
          path = path.gsub(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR
          File.directory?(path) ? gather_directories(path) : extract_location(path)
        end.flatten.sort
      end

      def gather_directories(path)
        stripped = "{#{pattern.gsub(/\s*,\s*/, ',')}}"
        files    = pattern =~ /^#{Regexp.escape path}/ ? Dir[stripped] : Dir["#{path}/#{stripped}"]
        files.sort
      end

      def extract_location(path)
        if path =~ /^(.*?)((?:\:\d+)+)$/
          path, lines = $1, $2[1..-1].split(":").map{|n| n.to_i}
          filter_manager.add_location path, lines
        end
        path
      end

8. rspec/core/configuration_options.rb

ちょっと話が戻りますが、5で呼ばれていた@options.configure(@configuration)です。
これは21行目で定義されており、Configuration#setup_load_path_and_requireといういかにもロードパスを追加してそうなメソッドを呼んでいます。

rspec-core-2.14.7/lib/rspec/core/configuration_options.rb
      def configure(config)  # 21行目
        config.filter_manager = filter_manager

        config.libs = options[:libs] || []
        config.setup_load_path_and_require(options[:requires] || [])  # 9へ

        process_options_into config
        load_formatters_into config
      end

9. rspec/core/configuration.rb(再掲)

Configuration#setup_load_path_and_requireは、7で読んだrspec/core/configuration.rbの864行目に定義されています。

directoriesはデフォルトだと['lib', 'spec']です。

rspec-core-2.14.7/lib/rspec/core/configuration.rb
      # @private
      def setup_load_path_and_require(paths)  # 864行目
        directories = ['lib', default_path].select { |p| File.directory? p }
        RSpec::Core::RubyProject.add_to_load_path(*directories)  # 10へ
        paths.each {|path| require path}
        @requires += paths
      end

10. rspec/core/ruby_project.rb

add_to_load_pathは10行目で定義されています。14行目のadd_dir_to_load_pathを呼び出して$LOAD_PATHに追加しています。

rspec-core-2.14.7/lib/rspec/core/ruby_project.rb
# This is borrowed (slightly modified) from Scott Taylor's
# project_path project:
#   http://github.com/smtlaissezfaire/project_path

require 'pathname'

module RSpec
  module Core
    module RubyProject
      def add_to_load_path(*dirs)  # 10行目
        dirs.map {|dir| add_dir_to_load_path(File.join(root, dir))}
      end

      def add_dir_to_load_path(dir)  # 14行目
        $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
      end

      def root
        @project_root ||= determine_root
      end

      def determine_root
        find_first_parent_containing('spec') || '.'
      end

      def find_first_parent_containing(dir)
        ascend_until {|path| File.exists?(File.join(path, dir))}
      end

      def ascend_until
        Pathname(File.expand_path('.')).ascend do |path|
          return path if yield(path)
        end
      end

      module_function :add_to_load_path
      module_function :add_dir_to_load_path
      module_function :root
      module_function :determine_root
      module_function :find_first_parent_containing
      module_function :ascend_until
    end
  end
end

第1部まとめ

ここまで読んだことで、以下の疑問点が解消されました。

  • specファイルはどうやって読み込まれるの?

spec/**/*_spec.rbのファイルが読み込まれます。(まあドキュメント読めば書いてあるのだけど、中でどう動いているのかを知りたかったのです)

  • 普通にRSpecを使うときにはspecファイルにrequire 'spec_helper'って書くけど、なんでspec/なしでいいの?

デフォルトでlib/spec/$LOAD_PATHに追加されるからです。

長くなったので、第2部:Turnipは別の記事にします。

追記:
続きを書きました:RSpecコードリーディング(第2部:Turnip) - Qiita

本当のまとめ:コードリーディングを支援するツールが欲しい

実はRSpecのコードを読むのがメインでこの記事を書いたわけではありません。ここまでやってきたようなコードリーディングを支援するツールが欲しいのです。

ツールの必要性

コードリーディングを支援するツールとして、GNU GLOBALがあります。しかし、ファイル間の移動が簡単になったとしても、それだけでは不十分だと感じます。

なぜなら、ソースコードを追いかけている間、呼び出しのスタックを全て記憶することができないからです。読んでる内に今どこを読んでたっけ?となったり、気付かずに同じファイルを読んでたということがあります。

私の場合、ここまでやってきたように、ソースコードを抜粋してメモを取りながらでないとしっかり読めません。ちょうど学校で勉強するときに板書をノートに写して理解するように。

というわけで、コードリーディングを支援するツールが欲しいです。

欲しい機能

やっていることはキュレーションに近いので、最初はNaverまとめを試してみましたが、ソースコードを引用して表示するのは無理がありました。

なので、ツールのPOCとしてこのようにQiitaに書いてみたわけです。Qiitaはソースコードを綺麗に書けるので素晴らしいのですが、引用しているファイル名や行数は自分で書いているため、気軽にこのような記事を書けるとは言いがたいです。

GitHubと連携するなどして、以下のようにいい感じにコードリーディングしたノートを作成できるサービスがあったらすごく使いたいです。

  • 特定の行をハイライトできる
  • 関数の呼び出しを可視化できる(# 4へとか書いていた部分。矢印で繋げられるとか)
  • ファイル中のコードを必要な箇所だけ表示でき、読者は必要に応じて隠れている場所も読める
  • (途中の番号からでも)行番号を表示できる

もしこのようなツールをご存じの方がいらっしゃいましたら、教えて頂けるとありがたいです。

以下のツールは目指す方向性が似ていますが、Wikiというツリーの形ではなくノートのような平面に写したいと思うのです。

35
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
33