実装を眺めながら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とかいうのを経由して呼び出してるっぽい

いったんここまで