scramble cadenza

技術ネタのガラクタ置き場

Headless Chrome でスマホ用画面のテストと、PC画面用テストを行う

イントロ

Headless Chrome で system test を書いていたら、猛烈にハマったので記録を残しておく。

ハマりすぎて Capybara のコードを殆ど読んでしまったので、 読んだ内容を抜粋してまとめてみました。 若干冗長なのはお許し下さい。

環境

  • rails (5.1.4)
  • capybara (2.17.0)
  • site_prism (2.9)

やりたいこと

  • Rails 5.1.4 で system test を使い、spec を書きたい
    • 折角なので Headless Chrome を使いたい
  • 諸事情で PC 用とスマホ用で画面が違うので、それぞれテストしたい
    • PC/スマホ判定は、 UserAgent で行う

イメージとしては、以下のような感じでテストを書いている

RSpec.describe 'Inquiries', type: :system do
  describe 'GET /inquiries' do
    context 'PC' do
      include_context 'view pc browser'

      it 'has expected elements' do
        # PC ブラウザでの要素確認
      end
    end

    context 'smartphone' do
      include_context 'view smartphone'

      it 'has expected elements' do
        # スマホでの要素確認
      end
    end
  end
end
shared_context 'view smartphone' do
  before do
    # UserAgent を見て PC/スマホ用の画面切り替えを行っている
    caps = Selenium::WebDriver::Remote::Capabilities.chrome(
      'chromeOptions' => {
        'args' => %w(--headless --disable-gpu --user-agent=iPhone)
      }
    )
    driven_by :selenium, screen_size: [400, 800], options: { desired_capabilities: caps }
  end
end

shared_context 'view pc browser' do
  before do
    caps = Selenium::WebDriver::Remote::Capabilities.chrome(
      'chromeOptions' => {
        'args' => %w(--headless --disable-gpu)
      }
    )
    driven_by :selenium, screen_size: [1400, 2000], options: { desired_capabilities: caps }
  end
end

期待していた結果と、実際の結果

  • expected
    • context 'PC' の中では PC 用 view、context 'smartphone' の中では スマホ用の view をテストする
  • actual
    • include_context を一度実行した結果を、最後まで保持してしまう
    • 例えば、最初に view pc browser を呼ぶと、下の context 'smartphone' の中でも、PC 用の view でテストが実行されてしまう

原因を端的にいうと

  • Capybara はテスト始まった時に作成した session を、Capybara クラスにキャッシュしていて、それをテストが終わるまで使い続ける
    • Capybara は session 作成時にのみ、headless_chrome の初期化を行う
    • これは、この時点でのみ、--user-agent とか、--window-size 等の option を渡せることを意味する
  • driven_byRSpec の before で呼び出しても、それは ruby で評価されないので、反映されない
    • 何故なら評価されるのは、初回だけだから

コードを読む

長いので、結論だけ見たければ、一番の対処法へジャンプして下さい。

まず、headless_chrome に option を渡している部分が以下。 @processed_options[:args] でその option が渡ってくる。

class Capybara::Selenium::Driver < Capybara::Driver::Base

  DEFAULT_OPTIONS = {
    :browser => :firefox,
    clear_local_storage: false,
    clear_session_storage: false
  }
  SPECIAL_OPTIONS = [:browser, :clear_local_storage, :clear_session_storage]

  attr_reader :app, :options

  def browser
    unless @browser
      if firefox?
        options[:desired_capabilities] ||= {}
        options[:desired_capabilities].merge!({ unexpectedAlertBehaviour: "ignore" })
      end

      # この @processed_option に headless_chrome に渡す option が入っている
      @processed_options = options.reject { |key,_val| SPECIAL_OPTIONS.include?(key) }
      @browser = Selenium::WebDriver.for(options[:browser], @processed_options)

      @w3c = ((defined?(Selenium::WebDriver::Remote::W3CCapabilities) && @browser.capabilities.is_a?(Selenium::WebDriver::Remote::W3CCapabilities)) ||
              (defined?(Selenium::WebDriver::Remote::W3C::Capabilities) && @browser.capabilities.is_a?(Selenium::WebDriver::Remote::W3C::Capabilities)))
      main = Process.pid
      at_exit do
        # Store the exit status of the test run since it goes away after calling the at_exit proc...
        @exit_status = $!.status if $!.is_a?(SystemExit)
        quit if Process.pid == main
        exit @exit_status if @exit_status # Force exit with stored status
      end
    end
    @browser
  end

  def initialize(app, options={})
    load_selenium
    @session = nil
    @app = app
    @browser = nil
    @exit_status = nil
    @frame_handles = {}
    @options = DEFAULT_OPTIONS.merge(options)
  end
end

@processed_options の中身は以下のようになっている

[7] pry(#<Capybara::Selenium::Driver>)> @processed_options
=> {:desired_capabilities=>
  #<Selenium::WebDriver::Remote::Capabilities:0x007f9db391b998
   @capabilities=
    {:browser_name=>"chrome",
     :version=>"",
     :platform=>:any,
     :javascript_enabled=>true,
     :css_selectors_enabled=>true,
     :takes_screenshot=>false,
     :native_events=>false,
     :rotatable=>false,
     :firefox_profile=>nil,
     :proxy=>nil,
     "chromeOptions"=>{"args"=>["--headless", "--disable-gpu"]}}>}

options[:desired_capabilities]Selenium::WebDriver::Remote::Capabilities インスタンスで、 options[:desired_capabilities]['chromeOptions']['args'] に headless_chrome に渡す option が文字列で定義されている。

これは ヘッドレス Chrome ことはじめ  |  Web  |  Google Developers に書かれている option で、ここをいじれば色々なことができる。

テスト実行されると、回りに回って Capybara::Selenium::Driver#browser が呼ばれるのだが、一旦話は戻って一番最初の shared_context の中に戻る。 この shared_context 内で before を定義しているので、example の実行前に driven_by が呼ばれる。

driven_by の定義は以下。 SystemTesting::Driver を new している。

    # System Test configuration options
    #
    # The default settings are Selenium, using Chrome, with a screen size
    # of 1400x1400.
    #
    # Examples:
    #
    #   driven_by :poltergeist
    #
    #   driven_by :selenium, using: :firefox
    #
    #   driven_by :selenium, screen_size: [800, 800]
    def self.driven_by(driver, using: :chrome, screen_size: [1400, 1400], options: {})
      self.driver = SystemTesting::Driver.new(driver, using: using, screen_size: screen_size, options: options)
    end

SystemTesting::Driver は以下のようになっている new された後、途中経過は色々あるが、ActionDispatch::SystemTesting::Driver#use が呼ばれる。

module ActionDispatch
  module SystemTesting
    class Driver # :nodoc:
      def initialize(name, **options)
        @name = name
        @browser = options[:using]
        @screen_size = options[:screen_size]
        @options = options[:options]
      end

      def use
        register if registerable?

        setup
      end

      private
        def registerable?
          [:selenium, :poltergeist, :webkit].include?(@name)
        end

        def register
          Capybara.register_driver @name do |app|
            case @name
            when :selenium then register_selenium(app)
            when :poltergeist then register_poltergeist(app)
            when :webkit then register_webkit(app)
            end
          end
        end

        def register_selenium(app)
          Capybara::Selenium::Driver.new(app, { browser: @browser }.merge(@options)).tap do |driver|
            driver.browser.manage.window.size = Selenium::WebDriver::Dimension.new(*@screen_size)
          end
        end

        def register_poltergeist(app)
          Capybara::Poltergeist::Driver.new(app, @options.merge(window_size: @screen_size))
        end

        def register_webkit(app)
          Capybara::Webkit::Driver.new(app, Capybara::Webkit::Configuration.to_hash.merge(@options)).tap do |driver|
            driver.resize_window(*@screen_size)
          end
        end

        def setup
          Capybara.current_driver = @name
        end
    end
  end
end

ActionDispatch::SystemTesting::Driver#useActionDispatch::SystemTesting::Driver#register を呼ぶが、ここで奇妙なことがわかる。

        def register
          puts "register!!!" #=> before で毎回呼ばれる
          Capybara.register_driver @name do |app|
            puts @name #=> before で毎回呼ばれない???
            case @name
            when :selenium then register_selenium(app)
            when :poltergeist then register_poltergeist(app)
            when :webkit then register_webkit(app)
            end
          end
        end

register が毎回 before で呼ばれているにも関わらず、この block が毎回呼ばれない。

これさえ呼ばれれば、--user-agent 付きで Capybara::Selenium::Driver が初期化されるので、example 毎にブラウザを切り替えることができると思うのだが。

で、何故呼ばれないかというと、register_driver された評価済みのブロックがキャッシュされているから。

順に追っていくと、まずは Capybara.register_driver の実装。

module Capybara
  class << self
    extend Forwardable
    ##
    #
    # Register a new driver for Capybara.
    #
    #     Capybara.register_driver :rack_test do |app|
    #       Capybara::RackTest::Driver.new(app)
    #     end
    #
    # @param [Symbol] name                    The name of the new driver
    # @yield [app]                            This block takes a rack app and returns a Capybara driver
    # @yieldparam [<Rack>] app                The rack application that this driver runs against. May be nil.
    # @yieldreturn [Capybara::Driver::Base]   A Capybara driver instance
    #
    def register_driver(name, &block)
      drivers[name] = block
    end

    def drivers
      @drivers ||= {}
    end
  end
end

ただ単に drivers という Hash に詰め込んでいるだけ。 で、この block がいつ評価されるかというと、以下の場所。

module Capybara
  class Session
    include Capybara::SessionMatchers

    attr_reader :mode, :app, :server
    attr_accessor :synchronized

    def initialize(mode, app=nil)
      raise TypeError, "The second parameter to Session::new should be a rack app if passed." if app && !app.respond_to?(:call)
      @@instance_created = true
      @mode = mode
      @app = app
      if block_given?
        raise "A configuration block is only accepted when Capybara.threadsafe == true" unless Capybara.threadsafe
        yield config if block_given?
      end
      if config.run_server and @app and driver.needs_server?
        @server = Capybara::Server.new(@app, config.server_port, config.server_host, config.server_errors).boot
      else
        @server = nil
      end
      @touched = false
    end

    def driver
      @driver ||= begin
        unless Capybara.drivers.has_key?(mode)
          other_drivers = Capybara.drivers.keys.map { |key| key.inspect }
          raise Capybara::DriverNotFoundError, "no driver called #{mode.inspect} was found, available drivers: #{other_drivers.join(', ')}"
        end
        driver = Capybara.drivers[mode].call(app) #=> ここで評価している
        driver.session = self if driver.respond_to?(:session=)
        driver
      end
    end
  end
end

ここで Capybara::Session#driverregister_driver で格納されたブロックが評価されて、Capybara::Session インスタンスにキャッシュされる。

で、この session インスタンスCapybara.current_session で初期化され、RSpec のテスト実行中、ずっと使い回される。

その理由は以下。

module Capybara
  class << self
    extend Forwardable

    ##
    #
    # The current Capybara::Session based on what is set as Capybara.app and Capybara.current_driver
    #
    # @return [Capybara::Session]     The currently used session
    #
    def current_session
      session_pool["#{current_driver}:#{session_name}:#{app.object_id}"] ||= Capybara::Session.new(current_driver, app)
    end

    def session_pool
      @session_pool ||= {}
    end
  end
end

以下はテスト中で Capybara.current_session が使われている部分

module Capybara
  module DSL
    def page
      Capybara.current_session
    end

    # Session::DSL_METHODS には visit とか within とか、毎度お馴染みのメソッドが配列になっている
    # これらのメソッド全て、`Capybara.current_session` がレシーバーで呼び出されている
    Session::DSL_METHODS.each do |method|
      define_method method do |*args, &block|
        page.send method, *args, &block
      end
    end
  end
end

今は natritmeyer/site_prism を使っているけど、そこでもやっぱり Capybara.current_session が使われている

module SitePrism
  class Page
    def page
      @page || Capybara.current_session
    end
  end
end

ということで、before で何度 driven_by を宣言しようが、一度作成した driver はキャッシュされ、使い続けるような実装になっている

試したこと

キャッシュされている場所はわかったので after で強引にインスタンス変数を消し去ってみる

shared_context 'view pc browser' do
  before do
    caps = Selenium::WebDriver::Remote::Capabilities.chrome(
      'chromeOptions' => {
        'args' => %w(--headless --disable-gpu)
      }
    )
    driven_by :selenium, screen_size: [1400, 2000], options: { desired_capabilities: caps }
  end

  after do
    # ↓ この辺を試した
    # Capybara.instance_variable_set(:@session_pool, nil)
    # Capybara.current_session.reset!
    # Capybara.current_session.driver.quit
    # Capybara.current_session.instance_variable_set(:@driver, nil)
  end
end

しかし、ブラウザが真っ白になったりして、通るはずのテストで落ちるようになってしまって断念。 流石にアクセサやメソッドが用意されてない中で、こんな無茶苦茶な事やったら駄目みたいだ。

結局どうなったか

この強引なアプローチが駄目だったので、渋々複数の session を管理して、example 実行時に切り替えるようにした。

# around で session_name の一時的変更を行うようにした
shared_context 'view smartphone' do
  around do |example|
    caps = Selenium::WebDriver::Remote::Capabilities.chrome(
      'chromeOptions' => {
        'args' => %w(--headless --disable-gpu --user-agent=iPhone)
      }
    )
    driven_by :selenium, screen_size: [400, 800], options: { desired_capabilities: caps }
    old_session_name = Capybara.session_name
    Capybara.session_name = :smartphone_browser
    example.run
    Capybara.session_name = old_session_name
  end
end

shared_context 'view pc browser' do
  around do |example|
    caps = Selenium::WebDriver::Remote::Capabilities.chrome(
      'chromeOptions' => {
        'args' => %w(--headless --disable-gpu)
      }
    )
    driven_by :selenium, screen_size: [1400, 2000], options: { desired_capabilities: caps }
    old_session_name = Capybara.session_name
    Capybara.session_name = :pc_browser
    example.run
    Capybara.session_name = old_session_name
  end
end
# ここは一切変えてない
RSpec.describe 'Inquiries', type: :system do
  describe 'GET /inquiries' do
    context 'PC' do
      include_context 'view pc browser'

      it 'has expected elements' do
        # PC ブラウザで要素確認できている
      end
    end

    context 'smartphone' do
      include_context 'view smartphone'

      it 'has expected elements' do
        # スマホで要素確認できている
      end
    end
  end
end

Capybara.current_session の実装が、

module Capybara
  class << self
    extend Forwardable

    ##
    #
    # The current Capybara::Session based on what is set as Capybara.app and Capybara.current_driver
    #
    # @return [Capybara::Session]     The currently used session
    #
    def current_session
      session_pool["#{current_driver}:#{session_name}:#{app.object_id}"] ||= Capybara::Session.new(current_driver, app)
    end

    def session_pool
      @session_pool ||= {}
    end
  end
end

となっていたので、session_name さえ変えてあげれば、新たに Capybara::Session が作成される、という事になる。

こんなやり方で良いのだろうか...?

まとめ

  • Capybara::Session は、テスト実行中ずっとキャッシュされる
  • 別の Session、或いは driver を使いたければ(別の driver とは、option の変更も含む)、別の名前で Capybara::Session を作って、テスト毎に切り替える