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'
してるだけです。
#!/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行だけです。
require 'rspec/core' # 3へ
RSpec::Core::Runner.autorun # 4へ
3. rspec/core.rb
長いですが、必要なファイルをrequireした後、RSpec
モジュールを定義しています。
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
を実行するという仕組みになっています。
こうすることで誰にも邪魔させないみたいなことが書いてあるけどよくわかってません。
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)
を呼んでいます。
# 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
が使われます。
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_files
はRSpec::Core::Configuration#load_spec_files
であることがわかります。
# 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つずつロードしています。
# @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行目で代入されています。
# @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
にマッチするテストケースが実行されます。
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
といういかにもロードパスを追加してそうなメソッドを呼んでいます。
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']
です。
# @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
に追加しています。
# 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というツリーの形ではなくノートのような平面に写したいと思うのです。