Closed10

Capybaraのカスタムdriverを書くときのイロイロ

Yusuke IwakiYusuke Iwaki

おもな登場人物。

  • ユーザプログラム(多くの場合、RSpecのスクリプト)
  • Capybara
  • ドライバ

そうはいっても、ユーザプログラムからCapybara::Session.newなどを直接書くことはほぼなくて、

https://github.com/teamcapybara/capybara/blob/master/lib/capybara/rspec.rb

この辺のヘルパーが手取り足取りやってくれている。たぶんCapybaraドライバのライフサイクルはこんなかんじ

Yusuke IwakiYusuke Iwaki

実際に https://github.com/testplanisphere/hotel-example-capybara-ja を動作させて Capybara::Selenium::Driver と Capybara::Cuprite::Driver をメソッドトレースすると上記のシーケンスでだいたいあってそう。

ポイントとしては、明確なnew/dispose, session_start/endが呼ばれるわけではなく、初回参照時にブラウザを起動し reset!でブラウザを閉じるようなトリッキーな実装をしないといけないということ。

Yusuke IwakiYusuke Iwaki

Capybaraでカスタムドライバを実装する際には、DriverとNodeを実装する必要がある。
ざっくりいうと、Driverはブラウザの操作を実装するもので、Nodeは具体的なDOM要素に対するユーザ操作を実装するもの。

基底クラスは

まとまったドキュメントがなく、ソースだけみても使い方はまったくわからないので、既存のドライバをコピペプログラミングするしかない

Seleniumドライバ

Cuprite

apparition

Yusuke IwakiYusuke Iwaki

Capybaraからドライバに対して、どんなメソッドがどんな引数で呼ばれるかは、

    def trace(klass)
      klass.public_instance_methods(false).each do |method_sym|
        orig = klass.instance_method(method_sym)
        klass.define_method(method_sym) do |*args, **kwargs, &block|
          puts "START: #{klass.name}##{method_sym} #{args.map(&:to_s)} #{kwargs.map {|k, v| "#{k.to_s} => #{v.to_s}"}}"
          orig.bind(self).call(*args, **kwargs, &block)
        end
      end
    end
    trace(Capybara::Selenium::Driver)
    trace(Capybara::Selenium::Node)
    trace(Capybara::Selenium::Find)
    trace(Capybara::DSL)
def trace(klass)
  klass.public_instance_methods(false).each do |method_sym|
    orig = klass.instance_method(method_sym)
    klass.define_method(method_sym) do |*args, &block|
      puts "START: #{klass.name}##{method_sym}, #{args}"
      orig.bind(self).call(*args, &block)
    end
  end
end
trace(Capybara::Cuprite::Driver)
trace(Capybara::Cuprite::Node)

こんな感じでトレースログを仕掛けてスクリプトを実行すればわかる。

たとえば、 fill_in('q', with: 'hoge') などで q を探すときに、Driverのfind_xpathが以下のような引数で呼ばれる。Capybaraが内部でものっすごい頑張っているらしい。

find_xpath("./descendant::*[self::input | self::textarea][not(((((((./@type = 'submit') or (./@type = 'image')) or (./@type = 'radio')) or (./@type = 'checkbox')) or (./@type = 'hidden')) or (./@type = 'file')))][((((./@id = 'q') or (./@name = 'q')) or (./@placeholder = 'q')) or (./@id = //label[contains(normalize-space(string(.)), 'q')]/@for))] | .//label[contains(normalize-space(string(.)), 'q')]//./descendant::*[self::input | self::textarea][not(((((((./@type = 'submit') or (./@type = 'image')) or (./@type = 'radio')) or (./@type = 'checkbox')) or (./@type = 'hidden')) or (./@type = 'file')))]", uses_visibility: true)

Yusuke IwakiYusuke Iwaki

一通り実装を終えて、SeleniumドライバやCupriteドライバと同等に使えるというのをどうやって示すか?
→RSpecを作ればいい。

Capybaraの単体試験は(ちょっと変則的だが)以下の場所にあるらしい。
https://github.com/teamcapybara/capybara/tree/master/lib/capybara/spec/session

これらのテストケースは、それぞれのドライバで共通で使われ、以下のように Capybara::SpecHelper を利用してテストを実行しているらしい。

require 'require 'capybara/spec/spec_helper' をするには、Sinatraとlaunchyという2つのGemが必要(ないとrequireした時点でエラーになる)

https://github.com/teamcapybara/capybara/blob/master/capybara.gemspec にあるように、2つのGemを追加する。

s.add_development_dependency('launchy', ['>= 2.0.4'])
s.add_development_dependency('sinatra', ['>= 1.4.0'])
spec/spec_helper.rb
Capybara.register_driver(:playwright) do |app|
  Capybara::Playwright::Driver.new(app, browser_type: :chromium, headless: false)
end

Capybara.default_driver = :playwright
Capybara.save_path = 'tmp/capybara'
Capybara.server = :webrick
spec/playwright_driver_spec.rb
# frozen_string_literal: true

require 'spec_helper'
require 'capybara/spec/spec_helper'

module TestSessions
  Playwright = Capybara::Session.new(:playwright, TestApp)
end

skipped_tests = %i[

]
Capybara::SpecHelper.run_specs TestSessions::Playwright, 'Playwright', capybara_skip: skipped_tests do |example|

end

bundle exec rspec spec/playwright_driver_spec.rb すれば、
https://github.com/teamcapybara/capybara/blob/master/lib/capybara/spec/session/node_spec.rb
にあるRSpecが順に実行される。

ちなみに、Driverが #needs_server? で常にtrueを返すようにしておかないと、 visit などでフルパスのURLが渡されず、微妙にハマるので注意。

Yusuke IwakiYusuke Iwaki

Capybaraの共通RSpecを部分実行するには

spec/playwright_driver_spec.rb
skipped_tests = %i[

]
Capybara::SpecHelper.run_specs TestSessions::Playwright, 'Playwright', capybara_skip: skipped_tests do |example|
  case example.metadata[:full_description]
  when /on \/html selector/
    pending 'CSS selector with "/html" is not supported'
  end
end

ここのところで、必要なもの以外skipさせたらよさそう。

metadataは以下のような内容が入っている。

{:block=>#<Proc:0x00007f820fde6b18 /Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/session/node_spec.rb:12>, :description_args=>["should act like a session object"], :description=>"should act like a session object", :full_description=>"Capybara::Session Playwright node should act like a session object", :described_class=>Capybara::Session, :file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/session/node_spec.rb", :line_number=>12, :location=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/session/node_spec.rb:12", :absolute_file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/session/node_spec.rb", :rerun_file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb", :scoped_id=>"1:1:1", :capybara_skip=>[], :execution_result=>#<RSpec::Core::Example::ExecutionResult:0x00007f820fde6780 @started_at=2021-05-01 15:47:26.413807 +0900>, :example_group=>{:block=>#<Proc:0x00007f82105f4378 /Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb:87>, :description_args=>["node"], :description=>"node", :full_description=>"Capybara::Session Playwright node", :described_class=>Capybara::Session, :file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb", :line_number=>87, :location=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb:87", :absolute_file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb", :rerun_file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb", :scoped_id=>"1:1", :capybara_skip=>[], :parent_example_group=>{:block=>#<Proc:0x00007f820fc02978 /Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb:61>, :description_args=>[Capybara::Session, "Playwright"], :description=>"Capybara::Session Playwright", :full_description=>"Capybara::Session Playwright", :described_class=>Capybara::Session, :file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb", :line_number=>61, :location=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb:61", :absolute_file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb", :rerun_file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/spec_helper.rb", :scoped_id=>"1", :capybara_skip=>[]}}, :shared_group_inclusion_backtrace=>[], :last_run_status=>"passed"}

file_path=>"/Users/yusuke-iwaki/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/capybara-3.35.3/lib/capybara/spec/session/node_spec.rb",
:line_number=>12,

このあたりを条件にフィルタすればよさそう。

spec/playwright_driver_spec.rb
Capybara::SpecHelper.run_specs TestSessions::Playwright, 'Playwright', capybara_skip: skipped_tests do |example|
  if example.metadata[:file_path].end_with?('/session/node_spec.rb') && example.metadata[:line_number] <= 100
    next
  end
  skip
end
$ bundle exec rspec spec/playwright_driver_spec.rb 

Capybara::Session Playwright
  node
    should act like a session object
    should scope CSS selectors
    #query_scope
      should have a reference to the element the query was evaluated on if there is one
    #text
      should extract node texts
      should return document text on /html selector (PENDING: CSS selector with "/html" is not supported)
    #[]
      should extract node attributes (FAILED - 1)
      should extract boolean node attributes (FAILED - 2)
    #style
call evaluate_script(args=["(function(){\n  return (function(){\n  var s = window.getComputedStyle(this);\n  var result = {};\n  for (var i = arguments.length; i--; ) {\n    var property_name = arguments[i];\n    result[property_name] = s.getPropertyValue(property_name);\n  }\n  return result;\n}).apply(this, arguments)\n}).apply(arguments[0], Array.prototype.slice.call(arguments,1));", #<Capybara::Playwright::Node tag="p">, "display"], kwargs={})
      should return the computed style value (FAILED - 3)
call evaluate_script(args=["(function(){\n  return (function(){\n  var s = window.getComputedStyle(this);\n  var result = {};\n  for (var i = arguments.length; i--; ) {\n    var property_name = arguments[i];\n    result[property_name] = s.getPropertyValue(property_name);\n  }\n  return result;\n}).apply(this, arguments)\n}).apply(arguments[0], Array.prototype.slice.call(arguments,1));", #<Capybara::Playwright::Node tag="p">, "display", "line-height"], kwargs={})
      should return multiple style values (FAILED - 4)
    #value
      should allow retrieval of the value (FAILED - 5)
      should not swallow extra newlines in textarea (FAILED - 6)
      should not swallow leading newlines for set content in textarea (FAILED - 7)
      return any HTML content in textarea (FAILED - 8)
      defaults to 'on' for checkbox (FAILED - 9)
      defaults to 'on' for radio buttons (FAILED - 10)
    #set
      should allow assignment of field value (FAILED - 11)
      should fill the field even if the caret was not at the end (PENDING: No reason given)
      should not change if the text field is readonly (PENDING: No reason given)
      should not change if the textarea is readonly (PENDING: No reason given)
      should use global default options (PENDING: No reason given)
      with a contenteditable element
        should allow me to change the contents (PENDING: No reason given)
        should allow me to set the contents (PENDING: No reason given)
        should allow me to change the contents of a child element (PENDING: No reason given)
    #tag_name
      should extract node tag name (PENDING: No reason given)
    #disabled?
      should extract disabled node (PENDING: No reason given)
      should see disabled options as disabled (PENDING: No reason given)
      should see enabled options in disabled select as disabled (PENDING: No reason given)

(中略)

Finished in 58.86 seconds (files took 1.37 seconds to load)
1479 examples, 11 failures, 1464 pending
Yusuke IwakiYusuke Iwaki

clickなどに指定可能な wait の扱いについて。

https://rubydoc.info/github/teamcapybara/capybara/Capybara/Node/Element#click-instance_method

Capybaraではclickメソッドなどに wait というパラメータを指定することができる。
これは、リファレンスにもあるように、waitで指定した秒数まで(たとえば要素が他のDOM要素に隠れていてクリックできない場合などに)リトライするというもの。

      def perform_click_action(keys, wait: nil, **options)
        raise ArgumentError, 'You must specify both x: and y: for a click offset' if nil ^ options[:x] ^ options[:y]

        options[:offset] ||= :center if session_options.w3c_click_offset
        synchronize(wait) { yield keys, options }
        self
      end

このsynchronize っていうメソッドの中でリトライの実装があるのだが、問題は wait のパラメータは Node#click には渡されてこないということだ。

Playwrightのように、自前でauto-waitingの実装を持っている場合には、Capybaraに頼るのではなく、waitで指定された値を ElementHandle#click のtimeoutパラメータに渡してあげないといけない。

↑のようなperform_click_actions側でwaitパラメータが食われてしまうので、モンキーパッチを当てる必要がある。(もっといい方法があれば知りたい)

    module ElementClickOptionPatch
      def perform_click_action(keys, **options)
        # Expose `wait` value to the block given to perform_click_action.
        if options[:wait].is_a?(Numeric)
          options[:_playwright_wait] = options[:wait]
        end
        super
      end
    end
    Capybara::Node::Element.prepend(ElementClickOptionPatch)

こんな感じで、waitに入れた値をそのまま_playwright_waitっていうパラメータにコピーしておくと、 Node#click でwaitの値を取得することができる。

Yusuke IwakiYusuke Iwaki

accept_modal, dismiss_modalはどういう挙動をしないといけないのか。

  • accept_alertやdismiss_alertをして、一切モーダルがでなかったときは?
  • accept_alertやdismiss_alertをして、2個以上モーダルが出たときは?
  • accept_alertやdismiss_alertをして、条件に合わないモーダルが出たときは?
  • ネストされたaccept_alertやdismiss_alertで、内側のブロックで解決されたときは?

などの場合に、

  • スルーすべきかModalNotFound例外を投げるべきか
  • 条件に合わないモーダルはスルーすべきかdismissすべきか

あたりが仕様がよくわからないので、既存のドライバの挙動に合わせる必要がある。

実験リポジトリ
https://github.com/YusukeIwaki/capybara-playground/blob/main/spec/feature/example_spec.rb

accept_alertなどをせず、alertを野放しにした場合

class TestApp < Sinatra::Base
  get('/') { '<h1>It works!</h1>' }

  get('/alert') {
    <<~HTML
    <html>
    <head>
    <script type="text/javascript">
    window.addEventListener('DOMContentLoaded', () => {
      document.querySelector('button').addEventListener('click', (evt) => {
        alert('Hoge!');
        document.getElementById('result').innerText = "clicked";
      }, false);
    }, false);
    </script>
    </head>
    <body>
      <button>Click me</button>
      <span id="result"></span>
    </body>
    </html>
    HTML
  }
end
visit '/alert'
find('button').click # ここでモーダルが出現
expect(page).to have_content('clicked')
Failures:

  1) example accept modal by default
     Failure/Error: expect(page).to have_content('clicked')
     
     Selenium::WebDriver::Error::UnexpectedAlertOpenError:
       unexpected alert open: {Alert text : Hoge!}
         (Session info: chrome=90.0.4430.93)
     # 0   chromedriver                        0x0000000109851cc9 chromedriver + 2718921
     # 1   chromedriver                        0x0000000109f0e143 chromedriver + 9781571
     # 2   chromedriver                        0x00000001095e3218 chromedriver + 168472
     # 3   chromedriver                        0x0000000109645993 chromedriver + 571795
     # 4   chromedriver                        0x00000001096343d3 chromedriver + 500691
     # 5   chromedriver                        0x000000010960ad9f chromedriver + 331167
     # 6   chromedriver                        0x000000010960c03d chromedriver + 335933
     # 7   chromedriver                        0x000000010981c5f9 chromedriver + 2500089
     # 8   chromedriver                        0x000000010982a8a5 chromedriver + 2558117
     # 9   chromedriver                        0x000000010982b80f chromedriver + 2562063
     # 10  chromedriver                        0x0000000109800741 chromedriver + 2385729
     # 11  chromedriver                        0x000000010982bd1f chromedriver + 2563359
     # 12  chromedriver                        0x0000000109811c3c chromedriver + 2456636
     # 13  chromedriver                        0x0000000109845398 chromedriver + 2667416
     # 14  chromedriver                        0x000000010984555a chromedriver + 2667866
     # 15  chromedriver                        0x0000000109856d18 chromedriver + 2739480
     # 16  libsystem_pthread.dylib             0x00007fff204ca954 _pthread_start + 224
     # 17  libsystem_pthread.dylib             0x00007fff204c64a7 thread_start + 15
     # ./spec/feature/example_spec.rb:12:in `block (2 levels) in <top (required)>'

expect(page).to have_content(...) がなければ例外は起きないが、野放しになっているダイアログがあるとSeleniumが例外で落ちるらしい。

条件に合わないacceptがある場合

    visit '/alert'
    accept_alert 'xxx' do
      find('button').click # ここでモーダルが出現
    end
    expect(page).to have_content('clicked')
Failures:

  1) example accept modal by default
     Failure/Error:
       accept_alert 'xxx' do
         find('button').click
       end
     
     Capybara::ModalNotFound:
       Unable to find modal dialog with xxx - found 'Hoge!' instead.
     # ./spec/feature/example_spec.rb:11:in `block (2 levels) in <top (required)>'

モーダルが出た次の瞬間に Capybara::ModalNotFoundが送出される。

accept_modalの中で複数回モーダルが出た場合

    visit '/alert'
    accept_alert do
      find('button').click
      find('button').click
      find('button').click
    end
    expect(page).to have_content('clicked')
Failures:

  1) example accept modal by default
     Failure/Error: find('button').click
     
     Selenium::WebDriver::Error::UnexpectedAlertOpenError:
       unexpected alert open: {Alert text : Hoge!}
         (Session info: chrome=90.0.4430.93)
     # 0   chromedriver                        0x0000000100cb9cc9 chromedriver + 2718921
     # 1   chromedriver                        0x0000000101376143 chromedriver + 9781571
     # 2   chromedriver                        0x0000000100a4b218 chromedriver + 168472
     # 3   chromedriver                        0x0000000100aad993 chromedriver + 571795
     # 4   chromedriver                        0x0000000100a9c3d3 chromedriver + 500691
     # 5   chromedriver                        0x0000000100a72d9f chromedriver + 331167
     # 6   chromedriver                        0x0000000100a7403d chromedriver + 335933
     # 7   chromedriver                        0x0000000100c845f9 chromedriver + 2500089
     # 8   chromedriver                        0x0000000100c928a5 chromedriver + 2558117
     # 9   chromedriver                        0x0000000100c9380f chromedriver + 2562063
     # 10  chromedriver                        0x0000000100c68741 chromedriver + 2385729
     # 11  chromedriver                        0x0000000100c93d1f chromedriver + 2563359
     # 12  chromedriver                        0x0000000100c79c3c chromedriver + 2456636
     # 13  chromedriver                        0x0000000100cad398 chromedriver + 2667416
     # 14  chromedriver                        0x0000000100cad55a chromedriver + 2667866
     # 15  chromedriver                        0x0000000100cbed18 chromedriver + 2739480
     # 16  libsystem_pthread.dylib             0x00007fff204ca954 _pthread_start + 224
     # 17  libsystem_pthread.dylib             0x00007fff204c64a7 thread_start + 15
     # ./spec/feature/example_spec.rb:13:in `block (3 levels) in <top (required)>'
     # ./spec/feature/example_spec.rb:11:in `block (2 levels) in <top (required)>'

野放しになっているダイアログと同じ扱いなのか、2つ目のモーダルが出た瞬間に Seleniumが例外で落ちる

accept_modalの中でモーダルが出る操作がなかった場合

    visit '/alert'
    accept_alert do
      # find('button').click
    end
    expect(page).to have_content('clicked')
Failures:

  1) example accept modal by default
     Failure/Error:
       accept_alert do
         # find('button').click
       end
     
     Capybara::ModalNotFound:
       Unable to find modal dialog
     # ./spec/feature/example_spec.rb:11:in `block (2 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # Selenium::WebDriver::Error::TimeoutError:
     #   timed out after 2 seconds (no such alert
     #     (Session info: chrome=90.0.4430.93))
     #   ./spec/feature/example_spec.rb:11:in `block (2 levels) in <top (required)>'

デフォルトタイムアウト(2秒)が経過してもモーダルが現れない場合には、Capybara::ModalNotFoundが送出される。

ネストされた場合

「1つのaccept_modalブロックは1つのダイアログだけ処理する」という基本思想は変わらない。
内部のaccept_modalブロック処理し終わったダイアログは、外側のaccept_modal的には何もなかったのと同じ扱い。

  it 'passes with nested handler on 2 dialog shown' do
    visit '/confirm_twice'
    accept_confirm do
      accept_confirm do
        find('button').click # 2回confirmダイアログが出る
      end
    end
    expect(page).to have_content('clicked')
  end

「Seleniumが例外で落ちる」というのが気になったので、apparitionがどうかを見てみた。

Unexpected confirm modal - accepting.You should be using accept_confirm or dismiss_confirm みたいな警告はprintされるが、例外は出ず勝手にacceptされている。

Yusuke IwakiYusuke Iwaki

switch_to_frameの影響を受けるもの、受けないもの

つねにページの情報を返す

  • current_url
  • title

フォーカスしているフレームの情報を返す

  • html
  • find_css, find_xpath
  • などなど

なぜか、current_urlとtitleだけは、フレームの情報ではなくページの情報を返す必要がある。
(ブラウザの見た目が、iframeのURL変わってもアドレスバーやタイトルが変わらないからか?)

このスクラップは2021/06/04にクローズされました