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_by
を RSpec の 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#use
は ActionDispatch::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#driver
で register_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
を作って、テスト毎に切り替える