Capybaraのカスタムdriverを書くときのイロイロ
おもな登場人物。
- ユーザプログラム(多くの場合、RSpecのスクリプト)
- Capybara
- ドライバ
そうはいっても、ユーザプログラムからCapybara::Session.newなどを直接書くことはほぼなくて、
この辺のヘルパーが手取り足取りやってくれている。たぶんCapybaraドライバのライフサイクルはこんなかんじ
実際に https://github.com/testplanisphere/hotel-example-capybara-ja を動作させて Capybara::Selenium::Driver と Capybara::Cuprite::Driver をメソッドトレースすると上記のシーケンスでだいたいあってそう。
ポイントとしては、明確なnew/dispose, session_start/endが呼ばれるわけではなく、初回参照時にブラウザを起動し reset!でブラウザを閉じるようなトリッキーな実装をしないといけないということ。
Capybaraでカスタムドライバを実装する際には、DriverとNodeを実装する必要がある。
ざっくりいうと、Driverはブラウザの操作を実装するもので、Nodeは具体的なDOM要素に対するユーザ操作を実装するもの。
基底クラスは
まとまったドキュメントがなく、ソースだけみても使い方はまったくわからないので、既存のドライバをコピペプログラミングするしかない。
Seleniumドライバ
- https://github.com/teamcapybara/capybara/blob/master/lib/capybara/selenium/driver.rb
- https://github.com/teamcapybara/capybara/blob/master/lib/capybara/selenium/node.rb
Cuprite
- https://github.com/rubycdp/cuprite/blob/master/lib/capybara/cuprite/driver.rb
- https://github.com/rubycdp/cuprite/blob/master/lib/capybara/cuprite/node.rb
apparition
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)
一通り実装を終えて、SeleniumドライバやCupriteドライバと同等に使えるというのをどうやって示すか?
→RSpecを作ればいい。
Capybaraの単体試験は(ちょっと変則的だが)以下の場所にあるらしい。
これらのテストケースは、それぞれのドライバで共通で使われ、以下のように Capybara::SpecHelper を利用してテストを実行しているらしい。
- https://github.com/teamcapybara/capybara/blob/master/spec/rack_test_spec.rb
- https://github.com/teamcapybara/capybara/blob/master/spec/selenium_spec_chrome.rb
- https://github.com/teamcapybara/capybara/blob/master/spec/selenium_spec_chrome_remote.rb
- https://github.com/teamcapybara/capybara/blob/master/spec/selenium_spec_edge.rb
- https://github.com/teamcapybara/capybara/blob/master/spec/selenium_spec_firefox.rb
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'])
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
# 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
すれば、
にあるRSpecが順に実行される。
ちなみに、Driverが #needs_server?
で常にtrueを返すようにしておかないと、 visit
などでフルパスのURLが渡されず、微妙にハマるので注意。
Capybaraの共通RSpecを部分実行するには
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,
このあたりを条件にフィルタすればよさそう。
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
エラーについて
invalid_element_errors
っていうのをドライバごとに定義してやる必要がある。
たとえばPlaywright::Errorのうち特定の例外(要素が見つからない、セレクタの定義がおかしい、など)を指定すればよいのかな。
Seleniumは↓のような定義。
wait
の扱いについて。
clickなどに指定可能な
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の値を取得することができる。
accept_modal, dismiss_modalはどういう挙動をしないといけないのか。
- accept_alertやdismiss_alertをして、一切モーダルがでなかったときは?
- accept_alertやdismiss_alertをして、2個以上モーダルが出たときは?
- accept_alertやdismiss_alertをして、条件に合わないモーダルが出たときは?
- ネストされたaccept_alertやdismiss_alertで、内側のブロックで解決されたときは?
などの場合に、
- スルーすべきかModalNotFound例外を投げるべきか
- 条件に合わないモーダルはスルーすべきかdismissすべきか
あたりが仕様がよくわからないので、既存のドライバの挙動に合わせる必要がある。
実験リポジトリ
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されている。
switch_to_frameの影響を受けるもの、受けないもの
つねにページの情報を返す
- current_url
- title
フォーカスしているフレームの情報を返す
- html
- find_css, find_xpath
- などなど
なぜか、current_urlとtitleだけは、フレームの情報ではなくページの情報を返す必要がある。
(ブラウザの見た目が、iframeのURL変わってもアドレスバーやタイトルが変わらないからか?)