実装を眺めながらRSpec, Capybaraあたりをざっくり理解する #2 Capybara
つぎは Capybara
README.md にあるとおり、RSpecの場合は spec_helper.rb に require 'capybara/rspec'
を読み込めってことなのでこれをまず見ていく
require 'rspec/core' require 'capybara/dsl' require 'capybara/rspec/matchers' require 'capybara/rspec/features' require 'capybara/rspec/matcher_proxies' RSpec.configure do |config| config.include Capybara::DSL, type: :feature config.include Capybara::RSpecMatchers, type: :feature config.include Capybara::DSL, type: :system config.include Capybara::RSpecMatchers, type: :system config.include Capybara::RSpecMatchers, type: :view # The before and after blocks must run instantaneously, because Capybara # might not actually be used in all examples where it's included. config.after do if self.class.include?(Capybara::DSL) Capybara.reset_sessions! Capybara.use_default_driver end end config.before do |example| if self.class.include?(Capybara::DSL) Capybara.current_driver = Capybara.javascript_driver if example.metadata[:js] Capybara.current_driver = example.metadata[:driver] if example.metadata[:driver] end end end
前回 before とか after 周りをちゃんと追ってはいないけれどテストケース実行前に Capybara のドライバの設定をしているっぽい。 :js があれば Capybara.javascript_driver を利用するし、:driver で指定されているものがあればそちらで上書きするらしい
after では セッションのリセットとドライバの設定をデフォルトにもどすっぽい
RSpec.configure は rspec の Configuration のインスタンスをブロックにわたす Configuration.include は以下
def include(mod, *filters) define_mixed_in_module(mod, filters, @include_modules, :include) do |group| safe_include(mod, group) end end def define_mixed_in_module(mod, filters, mod_list, config_method, &block) unless Module === mod raise TypeError, "`RSpec.configuration.#{config_method}` expects a module but got: #{mod.inspect}" end meta = Metadata.build_hash_from(filters, :warn_about_example_group_filtering) mod_list.append(mod, meta) on_existing_matching_groups(meta, &block) end def safe_include(mod, host) host.__send__(:include, mod) unless host < mod end
フィルタに合致するExampleGroupにCapybara::DSLとかをincludeさせてる?
Capybara::DSL は下記
require 'capybara' module Capybara module DSL def self.included(base) warn 'including Capybara::DSL in the global scope is not recommended!' if base == Object super end def self.extended(base) warn 'extending the main object with Capybara::DSL is not recommended!' if base == TOPLEVEL_BINDING.eval('self') super end ## # # Shortcut to working in a different session. # def using_session(name_or_session, &block) Capybara.using_session(name_or_session, &block) end # Shortcut to using a different wait time. # def using_wait_time(seconds, &block) page.using_wait_time(seconds, &block) end ## # # Shortcut to accessing the current session. # # class MyClass # include Capybara::DSL # # def has_header? # page.has_css?('h1') # end # end # # @return [Capybara::Session] The current session object # def page Capybara.current_session end Session::DSL_METHODS.each do |method| class_eval <<~METHOD, __FILE__, __LINE__ + 1 def #{method}(...) page.method("#{method}").call(...) end METHOD end end extend(Capybara::DSL) end
Session::DSL_METHODS で列挙されているメソッドを page.method(メソッド名).call(...) の形で定義してやっている page は Capybara.current_session らしい DSL_METHODS は visit とか click とか find とか
このpageってのはなに?
def current_session specified_session || session_pool["#{current_driver}:#{session_name}:#{app.object_id}"] end def specified_session if threadsafe Thread.current.thread_variable_get :capybara_specified_session else @specified_session ||= nil end end def specified_session=(session) if threadsafe Thread.current.thread_variable_set :capybara_specified_session, session else @specified_session = session end end def using_session(name_or_session, &block) previous_session = current_session previous_session_info = { specified_session: specified_session, session_name: session_name, current_driver: current_driver, app: app } self.specified_session = self.session_name = nil if name_or_session.is_a? Capybara::Session self.specified_session = name_or_session else self.session_name = name_or_session end if block.arity.zero? yield else yield current_session, previous_session end ensure self.session_name, self.specified_session = previous_session_info.values_at(:session_name, :specified_session) self.current_driver, self.app = previous_session_info.values_at(:current_driver, :app) if threadsafe end
specified_session ってのは specファイルで Capybara.using_session('hoge session') do ... end みたいに明示されたときに保持されるっぽいから普段は
session_pool["#{current_driver}:#{session_name}:#{app.object_id}"]
が使われるっぽい session_pool には何が保持されている??
def session_pool @session_pool ||= Hash.new do |hash, name| hash[name] = Capybara::Session.new(current_driver, app) end end
らしい
def initialize(mode, app = nil) if app && !app.respond_to?(:call) raise TypeError, 'The second parameter to Session::new should be a rack app if passed.' end @@instance_created = true # rubocop:disable Style/ClassVars @mode = mode @app = app if block_given? raise 'A configuration block is only accepted when Capybara.threadsafe == true' unless Capybara.threadsafe yield config end @server = if config.run_server && @app && driver.needs_server? server_options = { port: config.server_port, host: config.server_host, reportable_errors: config.server_errors } server_options[:extra_middleware] = [Capybara::Server::AnimationDisabler] if config.disable_animation Capybara::Server.new(@app, **server_options).boot end @touched = false end
セッションの起動時にサーバが必要だったら起動している?
def initialize(app, *deprecated_options, port: Capybara.server_port, host: Capybara.server_host, reportable_errors: Capybara.server_errors, extra_middleware: []) unless deprecated_options.empty? warn 'Positional arguments, other than the application, to Server#new are deprecated, please use keyword arguments' end @app = app @extra_middleware = extra_middleware @server_thread = nil # suppress warnings @host = deprecated_options[1] || host @reportable_errors = deprecated_options[2] || reportable_errors @port = deprecated_options[0] || port @port ||= Capybara::Server.ports[port_key] @port ||= find_available_port(host) @checker = Checker.new(@host, @port) end def boot unless responsive? Capybara::Server.ports[port_key] = port @server_thread = Thread.new do Capybara.server.call(middleware, port, host) end timer = Capybara::Helpers.timer(expire_in: 60) until responsive? raise 'Rack application timed out during boot' if timer.expired? @server_thread.join(0.1) end end self end # lib/capybara/config.rb ## # # Return the proc that Capybara will call to run the Rack application. # The block returned receives a rack app, port, and host/ip and should run a Rack handler # By default, Capybara will try to use puma. # attr_reader :server ## # # Set the server to use. # # Capybara.server = :webrick # Capybara.server = :puma, { Silent: true } # # @overload server=(name) # @param [Symbol] name Name of the server type to use # @overload server=([name, options]) # @param [Symbol] name Name of the server type to use # @param [Hash] options Options to pass to the server block # @see register_server # def server=(name) name, options = *name if name.is_a? Array @server = if name.respond_to? :call name elsif options proc { |app, port, host| Capybara.servers[name.to_sym].call(app, port, host, **options) } else Capybara.servers[name.to_sym] end end
ちょっと話は変わって visit とか click は page.method(メソッド名).call(...) って形で定義されていて、pageはSessionだったのは確認した Session内でvisitとかclickはどう定義されているかというと
def visit(visit_uri) raise_server_error! @touched = true visit_uri = ::Addressable::URI.parse(visit_uri.to_s) base_uri = ::Addressable::URI.parse(config.app_host || server_url) if base_uri && [nil, 'http', 'https'].include?(visit_uri.scheme) if visit_uri.relative? visit_uri_parts = visit_uri.to_hash.compact # Useful to people deploying to a subdirectory # and/or single page apps where only the url fragment changes visit_uri_parts[:path] = base_uri.path + visit_uri.path visit_uri = base_uri.merge(visit_uri_parts) end adjust_server_port(visit_uri) end driver.visit(visit_uri.to_s) end NODE_METHODS.each do |method| class_eval <<~METHOD, __FILE__, __LINE__ + 1 def #{method}(...) @touched = true current_scope.#{method}(...) end METHOD end def click(keys = [], **options) click_options = ClickOptions.new(keys, options) return native.click if click_options.empty? perform_with_options(click_options) do |action| target = click_options.coords? ? nil : native if click_options.delay.zero? action.click(target) else action.click_and_hold(target) action_pause(action, click_options.delay) action.release end end rescue StandardError => e if e.is_a?(::Selenium::WebDriver::Error::ElementClickInterceptedError) || e.message.include?('Other element would receive the click') scroll_to_center end raise e end
visitとかdriverで直接呼び出せるものは driver.visit を、clickとかDOMの操作(という表現が正しいか微妙だけど)っぽいのはnode.clickをscopeとかいうのを経由して呼び出してるっぽい
いったんここまで