Capybaraによるブラウザ操作自動化の知見まとめ
はじめに
ブラウザ上で行う面倒な作業を自動化しているうちにいろんなノウハウが溜まったのでまとめた。もともと Capybara はテストのためのフレームワークで、ブラウザ操作は selenium-webdriver の役割だが、Capybara から扱う方がブラウザ操作もやりやすかったので、ほぼ Capybara を扱う内容になっている。
chromedriver に関する問題
グローバルな chromedriver がある
- chromedriver-helper gem の残骸は削除する
- 他にも chromedriver で始まる外部コマンドがあるはず
- gem も含めて抹殺する
$ which chromedriver
/usr/local/var/rbenv/shims/chromedriver
$ gem uni -ax chromedriver-helper
$ rm /usr/local/var/rbenv/shims/chromedriver*
また chromedriver が出てきた
brew install chromedriver
で入れたやつも抹殺する。
$ which chromedriver
/usr/local/bin/chromedriver
$ brew uninstall chromedriver
chromedriver がない
Failure/Error: raise Error::WebDriverError, self.class.missing_text unless path
Selenium::WebDriver::Error::WebDriverError:
Unable to find chromedriver. Please download the server from
https://chromedriver.storage.googleapis.com/index.html and place it somewhere on your PATH.
More info at https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver.
- エラーメッセージ通りに「ここからダウンロードして PATH を設定」してはいけない
- webdrivers gem を使う
chromedriver はローカルのどこにある?
$ exa -al --no-user ~/.webdrivers
.rwxr-xr-x 17M 10 7 2021 chromedriver
.rw-r--r-- 13 16 5 06:23 chromedriver.version
ここにパスを通す必要はない
chromedriver があるのに動作しない
- Google Chrome と chromedriver のバージョンが一致していない
- コマンドラインですぐにバージョンを確認できるようにしておく
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version
~/.webdrivers/chromedriver --version
- Google Chrome が 70 以上であれば chromedriver とのバージョンは一致すると webdrivers の README に書いてある
- しかし Google Chrome が自動アップデートされた直後に同じバージョンの chromedriver が用意されてないことがあった
- それで一回事故って大変な目にあったので時差があるのではないかと疑っている
- ただ昔のことで記憶が怪しい。もしかしたら brew の chromedriver と chromedriver-helper と webdrivers がそれぞれ何をするものか正確に把握できておらず webdrivers を正しく使えてなかったのかもしれない。もしそうなら webdrivers にはすまん
バージョンが一致しない事故を防ぐ
方法1. Google Chrome を自動更新させない
これなら絶対にずれない。まず更新チェック頻度を 0 にする。
defaults write com.google.Keystone.Agent checkInterval 0
さらにアップデート関連のプログラムを抹殺&ダミーに置き換える。
~/Library 側
sudo rm -R ~/Library/Google/GoogleSoftwareUpdate/
sudo touch ~/Library/Google/GoogleSoftwareUpdate
sudo chmod 444 ~/Library/Google/GoogleSoftwareUpdate
sudo rm ~/Library/LaunchAgents/com.google.keystone.agent.plist
sudo rm -R ~/Library/Caches/com.google.Keystone*
sudo rm ~/Library/Preferences/com.google.Keystone.Agent.plist
/Library 側
sudo rm -R /Library/Google/GoogleSoftwareUpdate/
sudo touch /Library/Google/GoogleSoftwareUpdate
sudo chmod 444 /Library/Google/GoogleSoftwareUpdate
sudo rm /Library/LaunchAgents/com.google.keystone.agent.plist
sudo rm -R /Library/Caches/com.google.Keystone*
sudo rm /Library/Preferences/com.google.Keystone.Agent.plist
上の手順はかなり昔の作業メモからひっぱってきたものなので現在その通りにして期待通りになるかはわからない
方法2. いざ事故ったら手動で戻す
そのために日々バックアップを取っておく。
#!/bin/sh
# (cd /Applications && rm -fr .git)
# (cd ~/.webdrivers && rm -fr .git)
# exit
cd /Applications
[ ! -d .git ] && git init -q
git add -A "Google Chrome.app"
"./Google Chrome.app/Contents/MacOS/Google Chrome" --version | git commit -uno -F -
cd ~/.webdrivers
[ ! -d .git ] && git init -q
git add -A
./chromedriver --version | git commit -F -
これを cron にセットしておく。
webdrivers は Google Chrome のバージョンに合わせた chromedriver を用意してくれるはずなので ~/.webdrivers をバックアップする必要はないのかもしれない。が、まさにその部分が信用できていないので、こちらも管理下に入れてすぐ戻せるようにしておく。
それと、いざというときに /Applications/Google Chrome.app
だけを過去に戻して Google Chrome 自体が起動するのか不安ではあるが、それは運を天に任せる。とりあえず常時アップデートしていきたい場合はこっちの方がよい。
方法3. ハイブリッド方式
結局こうした。
- 自動更新を止める
- バックアップも取る
- その上で、月に1回ほど手動で Google Chrome をアップデートする
- 具体的には普通に新規でダウンロードして /Applications にコピる
- すぐに webdrivers を読み込んで同じバージョンの chromedriver が入ったか確認する
Selenium で警告が出る
WARN Selenium [DEPRECATION] [:browser_options] :options as a parameter for driver initialization is deprecated. Use :capabilities with an Array of value capabilities/options if necessary instead.
- capybara が古いのが原因
- どこで直ったのかはわからないが 3.37.1 以上だと警告は出なくなっていた
- ただ 3.37.1 にするには Ruby 2.7.0 以上を求められる
rack application が必要だと言われる。
rack-test requires a rack application, but none was given (ArgumentError)
見なかったことにして current_driver に :selenium_chrome を指定する。
Capybara.current_driver = :selenium_chrome
ヘッドレスモードに切り替えれるようにしておく。
環境変数を使う場合
Capybara.current_driver = ENV["HEADLESS"] ? :selenium_chrome_headless : :selenium_chrome
Thor のクラスオプションにも対応する場合
Capybara.current_driver = ENV["HEADLESS"] ? :selenium_chrome_headless : :selenium_chrome
class MyApp < Thor
class_option :headless, type: :boolean, desc: "ブラウザを裏で動かす"
def initialize(...)
super
if options[:headless]
Capybara.current_driver = :selenium_chrome_headless
end
end
end
require "capybara/dsl"
するとどうなる?
- 一つのセッションしか使わないとした上でいろいろ短かく書けるようになる
- Capybara.page は Capybara.current_session を呼ぶ
- なので page と current_session は同じ
- Capybara.page に対するメソッドが Capybara モジュール自体に生える
- なので Capybara.xxx は Capybara.current_session.xxx を呼ぶ
- ショートカットできるのは
Capybara::Session::DSL_METHODS
に列挙されているメソッドだけ- active_element などは current_session を経由で呼ぶこと
- このように簡単に書けるようにしてくれている反面、元々どう書かないといけなかったものなのかを把握しておかないと余計に混乱する
Capybara.xxx と書くのが面倒
グローバルにぶちまけると怒られるので、
module Playground
extend Capybara::DSL
visit "https://www.google.co.jp/"
end
または、
class App
include Capybara::DSL
def run
visit "https://www.google.co.jp/"
end
new.run
end
とする。
Capybara の名前が長いのが気になる場合は、
module C
extend Capybara::DSL
end
C.visit "https://www.google.co.jp/"
としても良いが、それなら C = Capybara
でいい気がする。
途中で止めてDOMの構造を調べる
- byebug gem を入れる
- 本当は debug gem の方がいいのかもしれない
- が、 eshell から動かないので byebug にする
- 具体的には debug gem が使っている irb が動かない
- いくら調べてもわからない
- さくさく動作させたいので default_max_wait_time を 0 にしてから debugger を起動する
Capybara.using_wait_time(0) { debugger }
- snippet 的なやつですぐに入力できるようにしておく
ローカルの HTML ファイルが読めない
- コマンドラインからは
open foo.html
で開ける - だから
Capybara.visit("foo.html")
で開いてくれても良さそうだけど開けない - invalid argument と言われるだけ
-
file://
からのフルパスにすると読める
file = Pathname("foo.html").expand_path
Capybara.visit("file://#{file}")
XML が読めない
- Capybara は HTML が来る前提になっている
- Google Chrome は良かれと XML を HTML として装飾表示する
- 結果、Capybara が混乱する
Capybara.visit("https://www.google.co.jp/sitemap.xml")
Capybara.page.text rescue $! # => #<Capybara::ElementNotFound: Unable to find xpath "/html">
Capybara.assert_text("google") rescue $! # => #<Capybara::ElementNotFound: Unable to find xpath "/html">
Capybara.page.body[...40] # => "<html xmlns=\"http://www.w3.org/1999/xhtm"
となるので faraday を使う。
require "faraday"
response = Faraday.get("https://www.google.co.jp/sitemap.xml")
node = Nokogiri::XML(response.body)
node.search("sitemap").count # => 24
待つ
assert_xxx 系メソッドと wait オプションの組み合わせは検証よりもその状態になるまで待つのに使える。
has_selector? の高速化
まず、
Capybara.has_selector?(:field, id: "foo")
とするのは該当の要素が見つからなかった場合に諦めるまで (デフォルトでは) 2秒かかる。そのため、これを繰り返し呼んでいると呆れるほど時間がかかるようになる。
したがって、もしページが完全に読み込まれていて、変化しないのがわかっているのなら wait: 0
を入れる。
Capybara.has_selector?(:field, id: "foo", wait: 0)
これで 2000 ms から約 20 ms まで短縮され、約100倍速くなる。
想定のタイトルになるまで待つ
Capybara.assert_title("foo", exact: true, wait: 5)
とした場合は完全一致でタイトルが foo になるまで最大5秒待つ。
必ず exact: true
で完全一致させる。または初期値を Capybara.exact = true
としておく。そうしないと foobar にもマッチしてしまってあとあとはまる原因になる。
部分一致なら exact: false
を明示するか、
Capybara.assert_title(/foo/)
のように正規表現で書くと未来の自分に意図が伝わる。
送信ボタンが表われるまで最大1分待つ
Capybara.assert_selector(:button, text: "送信", exact_text: true, wait: 60)
-
sleep(60)
などとウェイトを入れてはいけない -
exact_text: true
はテキストの完全一致の意味
指定のパスになるまで待つ
Capybara.visit("https://www.youtube.com/results?search_query=capybara")
Capybara.assert_current_path("/results?search_query=capybara")
Capybara.assert_current_path("/results", ignore_query: true)
assert_current_path では不十分なときがある
無駄に AJAX を多用したサイトでは読み込みが終わったあとでも、ちんたらローディングを続けていたりして、そのサイトに来てはいるが、コンテンツがまだ用意しきれていない場合がある。そういうときに assert_current_path
で待つのは意味がない。かわりにパスではなくコンテンツに含まれるもので判定する。
ダメな例: URLが正しいだけでログインフォームがあるとは限らない
Capybara.assert_current_path("/login", wait: 60)
良い例: 実際のコンテンツを確認し、ログインボタンがあるなら全部読み込まれているとする
Capybara.assert_selector(:button, text: "ログイン", exact_text: true, wait: 60)
ありえないほど重いサイトにリロードを重ねて開くまで待つ例
Capybara.visit("...")
success = false
100.times do |i|
puts [i.next, Time.current, Capybara.current_url].join(" ")
if Capybara.has_selector?(:button, text: "送信", exact_text: true, wait: 60)
success = true
break
end
say "リロード#{i.next}回目"
Capybara.refresh
end
if !success
say "失敗"
end
-
<button>送信</button>
があれば開いたと見なす - 60秒経っても反応が無ければ死んでいるので再びリクエスト(リロード)する
- こんなことしないといけないサイトがあるのかと思うかもしれないが実際にある
待っているとPCのファンが唸りを上げてしまう原因と対策
次のように書くと内部では 0.01 のウェイトが入っているため処理回数は1秒間で約100回、全体で約6000回になる。
Capybara.has_selector?(..., wait: 60)
こう置き換えると1秒に1回なので全体で60回になり、処理量を100分の1に抑えられる。
60.times do
break if Capybara.has_selector?(..., wait: 0)
sleep(1)
end
タイムアウトしたときの判定も含めるとこうなる。
interval = 2 # n秒に1回確認
expire = 60 # n秒経っても成功しなければ諦める
success = false
(expire / interval).times do |i|
puts [i.next, Time.now, Capybara.current_url].join(" ")
if Capybara.has_selector?(..., wait: 0)
success = true
break
end
sleep(interval)
end
if success
# 成功
else
# 失敗
end
待ち秒数の指定は wait オプションだけに留める
待ち秒数を変更する方法はいくつかあるが──
指定したメソッドだけ
Capybara.assert_title("Google", wait: 60)
局所的(忘れてよい)
Capybara.using_wait_time(60) do
Capybara.default_max_wait_time # => 60
end
全体(忘れてよい)
Capybara.default_max_wait_time = 60
- マッチしないからといってデフォルト値を増やすべからず
- 逆に失敗の判定をしていた部分で時間がかかるようになってしまう
- デフォルト 2 のままで良い。触ってはいけない
- 重いときはとことん重いので指定のメソッドだけで wait オプションを使う
指定の要素が1件になるまで待つ
インクリメンタル検索で結果の数が変動する場合に有用
Capybara.assert_selector(".foo", count: 1, wait: 60)
サイトが 200 を返すまで待つ
require "faraday"
url = "https://www.google.co.jp/"
loop do
response = Faraday.get(url)
p [Time.now.strftime("%F %T"), url, response.reason_phrase]
if response.success?
break
end
sleep(2)
end
Capybara だと戻値がわからないので節操なく faraday を使う。
WEBにアクセスせずにセレクタの検証をする(重要)
node = Capybara.string("<a></a>")
node.has_selector?("a") # => true
- とりあえず動けばいいではなく妥協せず適切なセレクタを追及する
- そのための検証や練習にとても役立つ
- ただし click イベントや CSS 表記の
:checked
には反応しないので注意
CSS のセレクタを XPath に変換する(有用)
Nokogiri::CSS.xpath_for("*") # => ["//*"]
Nokogiri::CSS.xpath_for("a") # => ["//a"]
Nokogiri::CSS.xpath_for("a, b") # => ["//a", "//b"]
Nokogiri::CSS.xpath_for("a b") # => ["//a//b"]
Nokogiri::CSS.xpath_for("a > b") # => ["//a/b"]
Nokogiri::CSS.xpath_for("a[x=y]") # => ["//a[@x='y']"]
Nokogiri::CSS.xpath_for("a[x*=y]") # => ["//a[contains(@x,'y')]"]
Nokogiri::CSS.xpath_for(".foo") # => ["//*[contains(concat(' ',normalize-space(@class),' '),' foo ')]"]
Nokogiri::CSS.xpath_for("#foo") # => ["//*[@id='foo']"]
Nokogiri::CSS.xpath_for("*[foo]") # => ["//*[@foo]"]
Nokogiri::CSS.xpath_for("p:first") # => ["//p[position()=1]"]
CSS では確かこう書くはずだけど XPath だとなんだっけ? な場合に役立つ。とはいえ、XPath は悪手なので実際に書くのはやめた方がよい。
CSS のセレクタは内部で XPath に変換される (重要)
Capybara.default_selector # => :css
node = Capybara.string("<a></a>")
node.find("a") # => #<Capybara::Node::Simple tag="a" path="/html/body/a">
上だけ見てもわからないが、内部では a
を //a
に変換してから探している。
xpath = Nokogiri::CSS.xpath_for("a") # => ["//a"]
node = Nokogiri.XML("<a></a>")
node.at(xpath.first) # => #<Nokogiri::XML::Element:0xa8c name="a">
default_selector が :css なのにそのまま XPath も書けるのはなぜか? (超重要)
a
でなく //a
でマッチしている。なぜか?
Capybara.default_selector # => :css
node = Capybara.string("<a>xxx</a>")
node.has_selector?("//a") # => true
当初、融通を効かせてくれているのだと思っていた。Nokogiri には LOOKS_LIKE_XPATH
という定数があり、これにマッチすると XPath と見なしているのかと。
Nokogiri::XML::Searchable::LOOKS_LIKE_XPATH # => /^(\.\/|\/|\.\.|\.$)/
node = Nokogiri.XML("<a></a>")
node.at("a") # => #<Nokogiri::XML::Element:0x9ec name="a">
node.at("//a") # => #<Nokogiri::XML::Element:0x9ec name="a">
だが、それに惑わされてはいけない。ここで最初の仕組みを思い出す。
CSS のセレクタは内部で XPath に変換される
だからこうなる。
xpath = Nokogiri::CSS.xpath_for("//a") # => ["////a"]
node = Nokogiri.XML("<a></a>")
node.at(xpath.first) # => #<Nokogiri::XML::Element:0xaa0 name="a" children=[#<Nokogiri::XML::Text:0xa8c "xxx">]>
見ると、
- CSS として
//a
が入力された (そんな CSS の表記はない) -
////a
に変換された (そんな XPath の表記もない) - しかし、引けた (なぜ?)
たまたまでも動くなら CSS に XPath を書けるってことでいいんじゃない? と思うかもしれないけど絶対だめ。なぜなら、── (続く)
default_selector が :css のときに XPath を書いてはいけない理由 (超重要)
- 動かないセレクタもあるから
- たとえば、部分一致のセレクタは動かない
Capybara.default_selector # => :css
node = Capybara.string("<a>xxx</a>")
node.has_selector?("//a[text()='xxx']") # => true
node.has_selector?("//a[contains(text(), 'x')]") rescue $! # => #<Nokogiri::CSS::SyntaxError: unexpected 'text(' after 'contains('>
何度も繰り返すが、
CSS のセレクタは内部で XPath に変換される
ので試すと今度は失敗する。
Nokogiri::CSS.xpath_for("//a[contains(text(), 'x')]") rescue $! # => #<Nokogiri::CSS::SyntaxError: unexpected 'text(' after 'contains('>
- この問題がやっかいなのはだいたいの XPath セレクタは通るところ
- 完全一致のセレクタも通る
- だから部分一致のセレクタだけが通らない理由がわからない
- 常識的に考えて、原因は部分一致の書き方にあると考えてしまう
- そして、ますます深みにはまっていった
- 実際4年ぐらい混乱していた
- XPath 恐怖症になっていた
- 思うに
Nokogiri::CSS.xpath_for
が元凶だった- CSS として XPath が入力されたらエラーとすべき
-
//
や./
で始まる間違ったセレクタをエラーとすべき
結論
- XPath を書くときは 必ず XPath を明示 する
こんなんは偶然動いているだけ。本来エラーとなるコードが素通りしてしまっているだけ。置き換える。
-
assert_selector?("//...")
→assert_selector?(:xpath, "//...")
-
assert_selector?("//...")
→assert_xpath?("//...")
-
has_selector?("//...")
→has_xpath?("//...")
-
find("//...")
→find(:xpath, "//...")
ラベルに正確にマッチさせる
Capybara.visit("https://httpbin.org/forms/post")
Capybara.fill_in("Customer name:", with: "foo", exact: true)
- exact は 正確 の意味
- 毎回
exact: true
と書く状況ならCapybara.exact = true
としておく - ただ
fill_in
も exact オプションも滅多に使わない -
exact_text: true
とごっちゃになって混乱するのでこっちは忘れていい
exact = true
にすると assert_text も完全一致モードになる?
グローバルで - ならない
- assert_text には専用の
Capybara.exact_text
がある - が、これをグローバルで設定することは普通ない
- メソッドに指定するときは
assert_text "xxx", exact: true
と書く-
exact_text: true
ではない点に注意
-
改行やスペースの影響で assert_text の判定が揺らぐ
Capybara.assert_text("foo bar", normalize_ws: true)
-
normalize_ws: true
すると対象をgsub(/[[:space:]]+/, ' ').strip
する - なので
foo \n bar
にもfoo bar
がマッチするようになる
assert_text でマッチしない
- 想定する画面になっていないのがほとんどの原因
- デバッガで止めて以下を確認
Capybara.title
Capybara.current_url
Capybara.page.text
Capybara.page.text.include?("xxx")
画面を覆う巨大な div が邪魔で押せない
<div class=".spinner"></div>
が画面を覆って目的の button をクリックできない場合、次のようなエラーになる
*** Selenium::WebDriver::Error::ElementClickInterceptedError Exception:
element click intercepted: Element <button>...</button> is not clickable at point (1503, 193).
Other element would receive the click: <div class="spinner"></div>
対策1. 消えるまで待つ
Capybara.assert_no_selector?(".spinner", wait: 10)
- 10秒経過してもスピナーがでっぱなしならエラーとする
対策2. 要素を抹殺する
サイト側の不具合で永遠に残ってしまう場合は強引に消してしまう
Capybara.execute_script("document.querySelector('.spinner').remove()")
対策3. 拡縮をやめてみる
これで押せない要素が押せるようになったことが実際にある
Capybara.execute_script("document.body.style.zoom = 1.0")
サイト訪問時のスピナーが出る順序に気をつける
次のようになっているように見えて、
- 最初にローディングが始まってスピナーが数秒間表示される
- 入力フォームに入力できるようになる
実際はこうなっている場合がある
- 入力フォームが一瞬表示される
- 最初にローディングが始まってスピナーが数秒間表示される
- 入力フォームに入力できるようになる
前者だと思って次のように書くと、いきなり set("foo")
が呼ばれ、スピナーが邪魔して押せない。
Capybara.assert_no_selector?(".spinner", wait: 10)
Capybara.find("input").set("foo")
確実に選択できるセレクタを得る
- Developer Tool で該当タグを
Copy Selector
でコピる - あとのことは知らない、今動けばいい──場合に有用
Copy Selector
でコピったのに選択できない
- iframe が使われているのが原因
- within_frame(selector) で iframe 内に入る
<body>
<iframe>
<form></form>
</iframe>
</body>
Capybara.has_selector?("form") # => false
Capybara.within_frame("iframe") do
Capybara.assert_selector("form") # => true
end
- selector は "iframe" が初期値なので上の場合は selector を省略してもよい
マウスを重ねると見えるタグを選択する
-
visible: :all
をつける
node = Capybara.string("<a style='display:none'></a>")
node.has_selector?("a") # => false
node.has_selector?("a", visible: :all) # => true
visible オプションは他に何が指定できる?
-
visible: :hidden
にすると見えないタグだけが選択できる - だから普通に選択できたものは逆に選択できなくなる
node = Capybara.string("<a></a>")
node.has_selector?("a", visible: :hidden) # => false
- 特殊なケースでのテストに有用かもしれない
-
Capybara.ignore_hidden_elements
に指定すると初期値になる- 名前が全然違うのがよくない
-
default_visible
などにすべき - そもそも3択で値に
visible
があるのにキーがvisible
なのがイケてない
- 設定値に真偽値も使えるが、わかりにくいので忘れよう
- hidden も基本忘れていい
シンボル | 見えるタグ | 見えないタグ | ||
---|---|---|---|---|
visible | ○ | 初期値 | ← true | |
all | ○ | ○ | ← false | |
hidden | ○ |
セレクタのリファクタリング
<button><span>送信する</span></button>
タグの構造を含めて完全一致でマッチする
has_xpath?("//button/span[text()='送信する']")
テキストは部分一致で良い
has_xpath?("//button/span[contains(text(), '送信')]")
span が別のタグになるかもしれない
has_xpath?("//button/*[contains(text(), '送信')]")
span が無くなるかもしれない
has_xpath?("//button[contains(., '送信')]")
contains と書きたくない
has_xpath?("//button", text: "送信")
//
を書きたくない
has_css?("button", text: "送信")
disabled
属性がある場合はマッチさせたくない
has_selector?(:button, text: "送信")
button タグとは限らない
has_selector?(:link_or_button, text: "送信")
それをクリックしたい
find(:link_or_button, text: "送信").click
短かく書きたい
click_on("送信")
不安なので完全一致にしたい
click_on("送信する", exact_text: true)
文言だけを頼りにそれを直下に持つタグを探す
find(:xpath, "//*[text()='送信する']") # => #<Capybara::Node::Simple tag="span" path="/html/body/button/span">
find(:xpath, "//*[contains(text(), '送信')]") # => #<Capybara::Node::Simple tag="span" path="/html/body/button/span">
-
text()
は自分の直下のテキストなのでマッチすれば自分自身のタグが返る - 昨今のフロントエンドは何が click できるのか想像つかない
タグを指定しないなら組み込みセレクタの :element
を使う方法もあるけど「直下」が指定できないため html
タグからマッチしてしまう。
node = Capybara.string(<<~EOT)
<html>
<body>
<button>送信</button>
</body>
</html>
EOT
node.all(:element, text: "送信").count # => 3
node.all(:element, "button", text: "送信").count # => 1
button タグを指定すれば絞れるが、文言だけを頼りに探す目的からは外れる。
応用. role が alertdialog の要素が内包する OK (完全一致) のテキストを直下に持つ要素をクリックする例
Capybara.find(:element, role: "alertdialog").find(:xpath, "//*[text()='OK']").click
*
や .
を使ったときのはまりどころ
セレクタに トップレベルの HTML タグにマッチしている
find(:xpath, "*", text: "送信") # => #<Capybara::Node::Simple tag="html" path="/html">
html, body, button, span のすべてのタグがマッチしている
find(:xpath, "//*", text: "送信") rescue $! # => #<Capybara::Ambiguous: Ambiguous match, found 4 elements matching visible xpath "//*" with text "送信">
find(:element, text: "送信") rescue $! # => #<Capybara::Ambiguous: Ambiguous match, found 4 elements matching visible element nil with text "送信">
find(:xpath, "//*[.='送信する']") rescue $! # => #<Capybara::Ambiguous: Ambiguous match, found 4 elements matching visible xpath "//*[.='送信する']">
find(:xpath, "//*[contains(., '送信')]") rescue $! # => #<Capybara::Ambiguous: Ambiguous match, found 4 elements matching visible xpath "//*[contains(., '送信')]">
「送信」を選択するつもりが「送信確認」も選択してしまう対策
node = Capybara.string("<button>送信確認</button><button>送信</button>")
node.find("button", text: "送信") rescue $! # => #<Capybara::Ambiguous: Ambiguous match, found 2 elements matching visible css "button" with text "送信">
-
exact_text: true
をつけると完全一致になる-
exact
なのかexact_text
なのかややこしい
-
-
text
オプションを正規表現にする方法もある
node.find("button", text: "送信", exact_text: true).path # => "/html/body/button[2]"
node.find("button", text: /\A送信\z/).path # => "/html/body/button[2]"
- xpath であれば contains を避ける
node.find(:xpath, "//*[contains(text(), '送信')]") rescue $! # => #<Capybara::Ambiguous: Ambiguous match, found 2 elements matching visible xpath "//*[contains(text(), '送信')]">
node.find(:xpath, "//*[text()='送信']").path # => "/html/body/button[2]"
最小限のHTMLを用意して問題を特定する(重要)
実際のサイトの深部に行くのが難しい場合はローカルに再現したHTMLで検証する。
require "bundler/inline"
gemfile(false) do
source "https://rubygems.org"
gem "webdrivers"
gem "capybara", require: "capybara/dsl"
gem "byebug"
end
Capybara.current_driver = :selenium_chrome
require "tempfile"
SOURCE = <<~EOT
<a href="https://www.google.co.jp/">検索</a>
EOT
path = Tempfile.open(["", ".html"]) { |e| e.tap { |e| e << SOURCE } }.path
Capybara.visit("file://#{path}")
Capybara.click_on("検索")
gets
- 他者のサイトに無駄なアクセスを重ねて迷惑をかけないようにする意味もある
- 探索中の HTML を保存するには
Capybara.save_page("foo.html")
とする - 単にセレクタの確認であれば
Capybara.string
を使う - こちらはクリックイベントなど、ブラウザの挙動を含めて確認したいときに用いる
- CSS表記の
:checked
もこっちの方法でのみ反応する
連続する要素の指定番目の要素を拾うのに all を使ってはいけない
node = Capybara.string(<<~EOT)
<div>A</div>
<div>B</div>
<div>C</div>
EOT
ダメな例
node.all("div")[1].text # => "B"
正しい例
node.find("div:nth-child(2)").text # => "B"
指定のテキストを含む要素の親から見た──となってくると XPath でないと難しい
外側の div が大量にあって目印は h2 内の日付だけと想定する
node = Capybara.string(<<~EOT)
<div>
<h2>2000-01-01</h2>
<ul>
<li>A</li>
<li>B</li>
</ul>
</div>
EOT
このとき CSS では該当の h2 までは引けるがそこから兄弟の ul に移動するのが難しい
node.find("h2", text: "2000-01-01", exact_text: true).path # => "/html/body/div/h2"
この場合、いったん親に戻れる XPath で書くのもありだが、
node.find(:xpath, "//h2[text()='2000-01-01']/../ul").path # => "/html/body/div/ul"
node.find(:xpath, "//h2[text()='2000-01-01']/parent::*/ul").path # => "/html/body/div/ul"
node.find(:xpath, "//h2[text()='2000-01-01']/following-sibling::ul").path # => "/html/body/div/ul"
-
..
とparent::*
は同じ -
following-sibling::ul
は h2 のあとでに出てくる兄弟の ul
慣れない XPath ですべて書くのではなく上に上がるところだけ XPath で書くのがおすすめ。
node = node.find("h2", text: "2000-01-01", exact_text: true) # => #<Capybara::Node::Simple tag="h2" path="/html/body/div/h2">
node = node.find(:xpath, "..") # => #<Capybara::Node::Simple tag="div" path="/html/body/div">
node.all("li").collect(&:text) # => ["A", "B"]
指定のクラスが含まれないセレクタ
!クラス名
で「含まれない」を指定できる
node = Capybara.string(<<~EOT)
<div class="a b c" />
<div class="a b" />
EOT
いまいちな例
node.all("div.a.b").count # => 2
node.all("div.a.b:not(.c)").count # => 1
わかりやすい例
node.all("div", class: %w(a b)).count # => 2
node.all("div", class: %w(a b !c)).count # => 1
組み込みセレクタと disabled 問題
なるべく組み込みセレクタを使った方が良い理由
セレクタに "button"
を使うより :button
を使った方が良い理由は disabled
属性を考慮してくれるから。
たとえば、次のタグは disabled なので押せない
<button disabled>送信</button>
だけど find + CSS セレクタを使うと取得できてしまう。その上(見かけ上は)押せてしまう。
Capybara.find("button", text: "送信").click # => #<Capybara::Node::Element tag="button" path="/HTML/BODY[1]/DIV[1]/BUTTON[1]">
もちろんサイト側では何も起きないので disabled 属性があることに気付かなければかなりはまることになる。というかはまった。で、ここでなんとかするとすれば disabled の逆を意味する enabled を指定しないといけないけど毎回指定するのは面倒だ。
Capybara.find("button:enabled", text: "送信") rescue $! # => #<Capybara::ElementNotFound: Unable to find css "button:enabled">
これを自動的にやってくれるのが組み込みの :button
セレクタになる。
Capybara.find(:button, text: "送信") rescue $! # => #<Capybara::ElementNotFound: Unable to find visible button nil with text "送信" that is not disabled>
find_button
も内部で :button
セレクタを使っているのでやっていることは同じ
Capybara.find_button("送信") rescue $! # => #<Capybara::ElementNotFound: Unable to find visible button "送信" that is not disabled>
だから組み込みセレクタを使った方が良い。
まとめ
-
"button"
と:button
はかなり異なる -
find("button")
はfind(:css, "button")
のことで"button"
の部分は単なる CSS のセレクタ文字列 -
find(:button)
はボタン類に絞り disabled な要素を(デフォルトでは)除外する
組み込みセレクタがすべて disabled 考慮しているとは限らない
- :element セレクタは disabled を考慮していない
- なので
has_selector?("div")
をhas_selector?(:element, "div")
にする利点はあまりない
node = Capybara.string("<div disabled />")
node.has_selector?(:element, "div") # => true
node.has_selector?("div") # => true
disabled を考慮するセレクタ一覧
$ capybara-3.37.1/lib/capybara/selector/definition $ rg -wl disabled
field.rb
option.rb
button.rb
datalist_input.rb
datalist_option.rb
link_or_button.rb
fieldset.rb
checkbox.rb
fillable_field.rb
select.rb
file_field.rb
radio_button.rb
組み込みセレクタを使いつつ disabled なタグも選択したい
disabled: true
をつける
node = Capybara.string("<button disabled>foo</button>")
node.has_selector?(:button) # => false
node.has_selector?(:button, disabled: true) # => true
組み込みセレクタだとマッチしすぎるけど必ずフィルタする方法がある
node = Capybara.string(<<~EOT)
<input type="submit" value='a' />
<input type="reset" value='b' />
<input type="image" value='c' />
<input type="button" value='d' />
<button>e</button>
EOT
すべてにマッチしてしまう
node.all(:button).collect { |e| e.value || e.text }.join # => "abcde"
特定のタグだけ拾う方法がある
node.find(:button, type: "submit").value # => "a"
node.find(:button, type: "reset").value # => "b"
node.find(:button, type: "image").value # => "c"
node.find(:button, type: "button").value # => "d"
node.find(:button, text: "e").text # => "e"
ここで重要なのが type
オプションは :button
だから使えるということ。find("button")
では使えない。
CSSセレクターでタグを書きたくない
span をボタンにしている気持ち悪いタグを選択するとき span と書くのに抵抗がある。そういうときは :element
にしておく。
node = Capybara.string(%(<span class="button">送信</span>))
node.find(:element, class: "button") # => #<Capybara::Node::Simple tag="span" path="/html/body/span">
placeholder にマッチさせる
node = Capybara.string(<<~EOT)
<input placeholder="検索" />
EOT
ダメな例
node.has_selector?("input[placeholder='検索']") # => true
- disabled な input にもマッチしてしまう
- placeholder を正規表現でマッチさせたいときに困る
融通が効く例
node.has_selector?(:field, placeholder: "検索") # => true
- disabled な input を除外している
- 正規表現も使える
-
type
オプションで種類を絞れる
見たこともないタグの見たこともない属性にマッチさせる
-
:element
セレクタを使うとタグも属性も自由に指定できる - が、例外として text オプションは属性ではなく内包するテキストとマッチする
- タグや属性名は元が大文字であっても大文字で指定はできないのでそこも注意
- 小文字にするとマッチする
node = Capybara.string(<<~EOT)
<foo color='white'>A</foo>
<foo color='black'>B</foo>
<bar color='black'>C</bar>
<Baz/>
EOT
node.find(:element, "foo", color: "black").text # => "B"
node.find(:element, "foo", text: "B").text # => "B"
node.has_selector?(:element, "Baz") # => false
node.has_selector?(:element, "baz") # => true
:element セレクタで属性を指定するときは必ずシンボルにしないといけない
そうしないとはまる。というかはまった。
node = Capybara.string(%(<div foo="bar"></div>))
node.has_selector?(:element, "foo" => "bar") # => false
node.has_selector?(:element, :foo => "bar") # => true
テキスト関連入力要素には :field ではなく :fillable_field を使う
-
:field
はすべての入力要素が対象になる -
:fillable_field
はテキスト関連に絞られる - なので
find(:field, type: "text")
はfind(:fillable_field)
に置き換えよう - 他はそんなメリットはないけど「テキスト入力」をコード上で明示できる
-
input[type="text"]
とtextarea
だけが対象ではない -
input[type="color"]
なども含まれる - radio や checkbox は含まれない
node = Capybara.string(<<~EOT)
<input type="text" value="(text)" />
<textarea>(textarea)</textarea>
<input type="checkbox" />
<input type="radio" />
EOT
node.all(:field).collect(&:value) # => ["(text)", "(textarea)", "on", "on"]
node.all(:fillable_field).collect(&:value) # => ["(text)", "(textarea)"]
チェックボックスには専用の :checkbox セレクタを使う
node = Capybara.string(<<~EOT)
<input type="checkbox" checked />
EOT
node.find("input[type=checkbox]").checked? # => true
node.find(:field, type: "checkbox").checked? # => true
node.find(:checkbox).checked? # => true
チェックボックスのラベルをマッチ条件にする
node = Capybara.string(<<~EOT)
<label>
<input type="checkbox" />
有効
</label>
EOT
node.has_selector?(:checkbox, text: "有効", exact_text: true) # => false
node.has_selector?(:checkbox, "有効", exact: true) # => true
- 「有効」のテキストはひと目 input タグに内包するテキストのように見える
- なので text オプションを使うがこれではマッチしない
- 実際は兄弟関係にある
- これは HTML の設計ミスとしか言いようがない
- テキストとチェックボックスを連動させるには label を活用しないといけない
- しかしその分かりにくさから知らない人も多くテキストとチェックボックスが連動していないフォームが少なくない
- :checkbox セレクタがうまくやってくれていて単に第二引数に指定するとマッチする
- 外側のタグが label でなない場合はマッチしない
組み込みセレクタの引数一覧
select タグは value と表示名が異なるため選択方法が難しい
- CSSセレクタでも
:field
でもなく、なるべく:select
を使う - value を得る場合は option タグのことは考えなくてよい
- 表示名を得るには :checked で option タグを find する必要がある
- selected な要素がないとき
:checked
はいちばん上の option を返す - ただし option が一つもないとき
find(":checked")
は失敗する
source = <<~EOT
<select>
<option value="1">A</option>
<option value="2" selected>B</option>
</select>
EOT
path = Tempfile.open(["", ".html"]) { |e| e.tap { |e| e << source } }.path
Capybara.visit("file://#{path}")
Capybara.find(:select).value # => "2"
Capybara.find(:select).find(":checked").text # => "B"
新しいタブで開く
click(:meta)
とすると command を押しながらクリックしたことになる
新しいタブで開いたときの window を取得する
YouTube のトップで最初に出てくる動画のサムネを別タブで開いてその Window を取得する例
Capybara.visit("https://www.youtube.com/")
window = Capybara.window_opened_by do
Capybara.first(:id, "thumbnail").click(:meta)
end
たまたま動くいまいちな例
Capybara.visit("https://www.youtube.com/")
Capybara.first(:id, "thumbnail").click(:meta)
window = Capybara.windows.last
-
window_opened_by
はブロック内で新しく開いたウィンドウを返す - 新しく開かれなかったり、2つ以上開いた場合はエラーとしてくれる
- 一方、後者の方法では
windows.last
が新しく開いたタブである保証がない
target="_blank"
なリンクをクリックして開かれた新しいタブに移動する
次の方法でもたまたま動くが新しく開かれたタブが windows.last
である保証はない
Capybara.find("#foo").click
Capybara.switch_to_window(Capybara.windows.last)
window_opened_by
のなかでクリックするのが正しい
window = Capybara.window_opened_by do
Capybara.find("#foo").click
end
Capybara.switch_to_window(window)
開いた直後、そのタブに移動しないケースはないので次のようなメソッドを定義してシンプルに使う
def switch_to_window_by(&block)
switch_to_window(window_opened_by(&block))
end
タブ操作
新しいタブを開いて移動して何かして閉じて戻る
Capybara.within_window(Capybara.open_new_window) do
# Capybara.visit("https://www.youtube.com/")
# 何かする
Capybara.current_window.close
end
タブを閉じる
今いるタブを閉じる
if Capybara.current_window != Capybara.windows.first
Capybara.current_window.close
end
他のタブをすべて閉じる
(Capybara.windows.drop(1) - [Capybara.current_window]).each(&:close)
右側のタブを閉じる
Capybara.instance_eval do
windows.drop(windows.find_index(current_window).next).each(&:close)
end
左側のタブを閉じる
Capybara.instance_eval do
windows[1...windows.find_index(current_window)].each(&:close)
end
すべてのタブを閉じる
Capybara.reset!
- いちばん左端のタブ
windows.first
は close できない - close しようとすると
Not allowed to close the primary window
JavaScript を実行して結果を取得する
p Capybara.evaluate_script("window.navigator.userAgent")
# >> "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"
ファイルアップロードする
`convert logo: file.png`
Capybara.find("input").attach_file("file.png")
ドラッグアンドドロップでファイルアップロードする
`convert logo: file.png`
Capybara.visit("https://css-tricks.com/examples/DragAndDropFileUploading/")
Capybara.page.drop_file("form", "file.png")
dropybara gem を使わせてもらう。特定の範囲に D&D でファイルアップロードするタイプの UI は非常に扱いにくいが dropybara のおかげでなんとかなる。
テキストフィールドに set したつもりが追記されてしまう対策
Capybara.find(".foo").set("xxx", clear: :backspace)
マウスを乗せないとドロップダウンメニューが開かない対策
Capybara.find(".foo").hover
command + a
で、すべてを選択
Capybara.find("html").send_keys([:command, "a"])
ページの最後まで移動する
Capybara.find("html").send_keys(:end)
クリップボードにコピーしてくる
module Clipboard
extend self
def write(text)
IO.popen("pbcopy", "w") { |io| io.write(text) }
end
def read
IO.popen("pbpaste", &:read)
end
end
Clipboard.write("")
Capybara.visit("https://www.ruby-lang.org/ja/")
Capybara.find("html").send_keys([:command, "a"])
Capybara.find("html").send_keys([:command, "c"])
puts Clipboard.read.lines.take(2)
# > Ruby
# > A PROGRAMMER'S BEST FRIEND
JavaScript を使ってクリップボードに入れる
Clipboard.write("")
Capybara.visit("https://www.google.co.jp/")
Capybara.execute_script("await navigator.clipboard.writeText('foo')")
Clipboard.read # => "foo"
Capybara.first("form input").send_keys([:command, "v"])
YouTubeで画面サイズを調整してスクショを取る
Capybara.visit("https://www.youtube.com/watch?v=Ftm2uv7-Ybw")
Capybara.current_window.resize_to(640, 480)
sleep(1)
Capybara.save_screenshot("youtube.png")
- YouTubeは画面のリサイズに応じてレイアウトが切り替わる
- が、かなりもっさりゆったりと切り替わる
- なので、このあたりは深追いせず見当つけて sleep を入れる (前言撤回)
RSpec 実行時にスクショを集める
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "webdrivers"
gem "capybara", require: "capybara/dsl"
gem "rspec", require: "rspec/autorun"
end
Capybara.current_driver = :selenium_chrome_headless
RSpec.configure do |config|
config.before(:example) do |ex|
@full_description = ex.full_description
end
config.include Module.new {
def image_save
name = @full_description.gsub(/\s+/, "_")
Capybara.save_screenshot("#{name}.png")
end
}
end
RSpec.describe "Browsing" do
it "Google" do
Capybara.visit("https://www.google.co.jp/")
image_save
end
it "YouTube" do
Capybara.visit("https://www.youtube.com/")
image_save
end
end
$ exa -al --no-user Browsing*.png
.rw-r--r-- 130k 18 5 12:24 Browsing_Google.png
.rw-r--r-- 621k 18 5 12:24 Browsing_YouTube.png
- 単に保存したファイルの名前からはどこのスクショだったか判断できない
- かといって、いちいちファイル名を指定したくない
- ので full_description を元にファイル名を決める
- スクショが欲しいところに image_save を置く
自動化処理中に状況を知らせる
これを定義し、
def say(message)
system "say '#{message}'"
end
知らせたいところに置く
say "ログイン完了"
say を定義したはずがしゃべらない原因は Thor の say メソッドと干渉し負けていると思われる
自動化の敵 reCAPTCHA は人間に頼む
これを定義し、
def pause(message = "一時停止")
say message
puts message
$stdin.gets.strip
Capybara.switch_to_window(Capybara.current_window)
nil
end
人間の助けが必要なところに挟む
pause "reCAPTCHA を倒せ"
-
$stdin.gets
しているのは Thor から使う場合を考慮している -
gets
だとファイルと見なした引数を読み込もうとしてしまう - ENTER のあとターミナルからブラウザに切り替える
- pause をユーザー入力して使うと面倒なことになるのであえて nil を返す
pause メソッドを賢くする
def pause(message = "一時停止", bind = binding)
say message
puts message
s = $stdin.gets.strip
if s == "d"
bind.eval("Capybara.using_wait_time(0) { debugger }")
end
Capybara.switch_to_window(Capybara.current_window)
nil
end
- pause しているとき
d ENTER
で debugger を起動する - 呼び出し元のローカル変数が見えるように呼び出し元のスコープで実行する
visit メソッドを使いやすくする
GET パラメータとしてハッシュを渡せるようにする
require "active_support/core_ext/object/to_query"
require "active_support/core_ext/object/blank"
Capybara::Session.prepend Module.new {
def visit(uri, params = {})
super [uri, params.to_query.presence].compact.join("?")
end
}
Capybara.visit("https://www.google.co.jp/search", q: "foo")
使い捨てサンプル
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "webdrivers"
gem "capybara", require: "capybara/dsl"
end
Capybara.current_driver = :selenium_chrome
Capybara.visit "https://www.google.co.jp/"
Capybara.find(:field).set("capybara")
Capybara.find(:element, value: "Google 検索").click
gets
document.body.style.zoom を弄ってはいけない (ひどい罠)
command + -
を5回押して50%にしたときと同じ見た目にするには、
Capybara.execute_script("document.body.style.zoom = 0.5")
とすれば良いことに気づいて多用していたのだけど、これをすると本来普通に選択できるはずの要素が選択できなくなる。しかも document.body.style.zoom
に起因するとは思えないエラーがでるため、めちゃくちゃはまる。
*** Selenium::WebDriver::Error::ElementClickInterceptedError Exception:
element click intercepted: Element <button>...</button> is not clickable at point (1503, 193).
Other element would receive the click: <div class="xxx"></div>
上のエラーが出る場合は document.body.style.zoom
を弄ってないか確認する。
form の onSubmit フックが邪魔して submit できない
- onSubmit フックを無効化する
Capybara.execute_script("document.querySelector('form').onsubmit = undefined")
Capybara.click_on("送信")
送信ボタンが謎の要素に邪魔されて submit できない
- form を直接 submit する
- 邪魔な要素を
remove()
する手もある
Capybara.execute_script("document.querySelector('form').submit()")
縦に長いフォームでいちばん下までスクロールしたら画面外に出た一番上のテキストフィールドが選択できなくなった
- まさか画面外に出ると選択できない? と、勘違いしてしまうような何かが邪魔しているはず
- 普通は画面外でも選択できる
- で、
Unable to find visible css "#xxx
が出たらとりあえずvisible: :all
をつけると選択できるようになる
Capybara.find("#xxx", visible: :all)
- が、それも根本的な解決方法ではない
- これも
document.body.style.zoom
が元凶だったことがある
モーダル・ダイアログ類は背面へのアクセスを奪っているときがある
モーダル・ダイアログ類の外側をクリックしたときに閉じるような仕組みになっているものがある。この場合、背面にクリック可能な要素が全面に敷いてある。だいたい黒の半透明になっていることが多いが、100%透明だと気付かない。で、これが敷いてあると背面の要素がクリックできないばかりか find すらできない。なので、見えているのに find できないときがあったらモーダル・ダイアログ類が出ていないか確認すること。出ていればモーダル・ダイアログ類をいったん閉じれば find できるようになる。
セレクタマッチを阻害している要素を地道に探す
コードを変更して再実行の繰り返しでは心が消耗するのでヘッドあり Chrome で起動し、デバッガー内で次のようなコードを実行する
(0..).each do |i|
p [i, has_selector?(".target")]
sleep(0.5)
end
そして手動でブラウザ内の要素を操作し、どの要素が影響して has_selector? の結果が変化したかを調べる
assert を仕掛けまくる
HTML以外のロジックで、こうなっているべきなところにはどんどん assert を仕掛けておく
def assert(value)
if !value
say "予期せぬ事態です"
raise "must not happen"
end
end
assert windows.count == 5
confirm ダイアログの OK を押す方法
Capybara.accept_confirm
- 正攻法だがいまいち
- 原因がわからないがこの方法は不安定、というかほとんどエラーになる
-
accept_confirm
の直前でデバッガで止め、手動でaccept_confirm
を実行すると確実にUnable to find modal dialog (Capybara::ModalNotFound)
のエラーになる - シンプルなHTMLで confirm が出る処理と accept_confirm を連続で実行したときだけ一応動いた
accept_confirm でエラーになるときの対処法
-
Unable to find modal dialog
が出るので accept_confirm は諦める - 代わりに confirm 自体が true を返すように変更する
Capybara.execute_script("window.confirm = () => true")
頻繁に出る alert を無視したい
accept_alert で OK を押したことにできるらしいが、こちらも confirm と同様の現象が起きるので処理を無効化するのがてっとり早い
Capybara.execute_script("window.alert = () => true")
閉じたウィンドウにいる状態で open_new_window すると例外がでる対策
Capybara.switch_to_window(Capybara.open_new_window)
Capybara.current_window.close
Capybara.open_new_window rescue $! # => #<Selenium::WebDriver::Error::NoSuchWindowError: no such window: target window already closed
その場合は、生きているウィンドウにいったん戻れば問題ない。
Capybara.switch_to_window(Capybara.open_new_window)
Capybara.current_window.close
Capybara.switch_to_window(Capybara.windows.first) # ←
Capybara.open_new_window # => #<Window @handle="CDwindow-EA4763FC0D41D15C065A53F092C21BFA">
すぐ戻るなら within_window + open_new_window
を使う
Capybara.within_window(Capybara.open_new_window) do
# ...
Capybara.current_window.close
end
いまアクティブになっている要素を取得する
Capybara.current_session.active_element # => #<Capybara::Node::Element tag="body" path="/HTML/BODY[1]">
-
Capybara.active_element
では呼べないので注意
どこかを選択するのではなく今いる場所で入力
Capybara.current_session.active_element.send_keys("foo")
インクリメンタル検索の定番コード
- 1件になるまで待つ
- 見つかるまで5秒間は待つ
- 連続で検索するときなどはとくに検索文字列自体を has_selector? で探すの重要
Capybara.find("input[type=search").set("alice", clear: :backspace)
if Capybara.has_selector?(".row", text: "alice", count: 1, wait: 5)
Capybara.find(".row").text
else
# 見つからなかった
end
Capybara をサポートする便利メソッドは Capybara モジュールではなく DSL モジュールに追加する
Capybara は DSL を extend しているので DSL に定義すれば Capybara.xxx として実行できる。
複数の URL を一気に開いて最初のタブにフォーカスするメソッドを追加する例
module Capybara::DSL
def visit_all(urls)
windows = urls.collect do |e|
switch_to_window(open_new_window)
visit(e)
current_window
end
if window = windows.first
switch_to_window(window)
end
windows
end
end
ウィンドウ最大化は最初のvisitのタイミングで1回だけ実行する
ウィンドウ最大化自体はこうする。
Capybara.current_window.maximize
当初、ウィンドウ最大化は visit する毎に実行しないといけないのかと勘違いしていたが、Capybara.current_driver
の設定と合わせて最初に一度実行しておくだけでもよい。ただし Capybara を使わないときも Capybara.current_window.maximize
のタイミングでブラウザが開いてしまうのが気になるので、最初に visit したタイミングで最大化させる。
Capybara::Session.prepend Module.new {
def visit(...)
current_window_maximize_once
super
end
def current_window_maximize_once
@current_window_maximize_once ||= yield_self do
current_window.maximize
true
end
end
}
並列ログイン
タブ毎にログインユーザーを分けることはできない
Capybara.switch_to_window(Capybara.open_new_window)
Capybara.visit("https://www.google.co.jp/")
# ここでユーザーAとしてログイン
Capybara.switch_to_window(Capybara.open_new_window)
Capybara.visit("https://www.google.co.jp/")
# ここでユーザーBとしてログイン
- タブ毎に異なるユーザーでログインしてもセッションは別々にならない
- Aでログインした方に戻ってリロード
Capybara.refresh
するとBに切り替わってしまう
ログインユーザーを分ける方法
session_a = Capybara::Session.new(Capybara.current_session.mode)
session_a.switch_to_window(session_a.open_new_window)
session_a.visit("https://www.google.co.jp/")
# ここでユーザーAとしてログイン
session_b = Capybara::Session.new(Capybara.current_session.mode)
session_b.switch_to_window(session_b.open_new_window)
session_b.visit("https://www.google.co.jp/")
# ここでユーザーBとしてログイン
- 別々の Session インスタンスを作る
- タブではなく本当のウィンドウで分けられる
-
Session.new
の引数には:selenium_chrome
を指定してもよいが同じ値が取れるCapybara.current_session.mode
を指定している
role="button" ならボタンと見なす
node = Capybara.string(%(<button>OK</button>))
node.has_selector?(:button) # => true
node = Capybara.string(%(<a role="button">OK</a>))
node.has_selector?(:button) # => false
node.has_selector?(:button, enable_aria_role: true) # => true
- :button セレクタで button タグにマッチするのはわかる
- しかし a タグになってしまうと見た目は button なのにマッチしなくなってしまう
- そういうときに
enable_aria_role: true
をつける
aria-label にマッチさせる
node = Capybara.string(%(<button aria-label="送信">xxx</button>))
node.has_selector?(:button, "送") # => false
node.has_selector?(:button, "送", enable_aria_label: true) # => true
あまり使うことはないけど enable_aria_label: true
なら aria-label の値に部分一致でマッチする
localStorage の扱い
Capybara.visit("https://www.google.co.jp/")
Capybara.evaluate_script(%(localStorage.getItem('foo'))) # => nil
Capybara.execute_script(%(localStorage.setItem('foo', 1)))
Capybara.evaluate_script(%(localStorage.getItem('foo'))) # => "1"
Capybara.visit("https://www.youtube.com/")
Capybara.evaluate_script(%(localStorage.getItem('foo'))) # => nil
Capybara.visit("https://www.google.co.jp/")
Capybara.evaluate_script(%(localStorage.getItem('foo'))) # => "1"
-
evaluate_script
やexecute_script
から普通に扱える - ただし Capybara を再起動したら localStorage は消える
- なので localStorage というより sessionStorage に近い
読み易さの改善
ひと目、何をやっているのかさっぱりわからないので次のようにラップして使う
module LocalStorage
def self.[]=(key, value)
Capybara.execute_script(%(localStorage.setItem("#{key}", "#{value}")))
end
def self.[](key)
Capybara.evaluate_script(%(localStorage.getItem("#{key}")))
end
end
Capybara.visit("https://www.google.co.jp/")
LocalStorage[:foo] # => nil
LocalStorage[:foo] = 1
LocalStorage[:foo] # => "1"
Capybara.visit("https://www.youtube.com/")
LocalStorage[:foo] # => nil
Capybara.visit("https://www.google.co.jp/")
LocalStorage[:foo] # => "1"
似た要素のタグから1つだけを抽出する
次のようなHTMLがあったとする
<button color="blue"></button>
<button color="blue" disabled></button>
<button color="red"></button>
<button color="blue">保存</button>
<a color="blue"></a>
そこで一行目の <button color="blue"></button>
だけにマッチさせたいと考えたときこれがなかなか難しい
- 順番は変わるので
first("button")
などのやっつけはダメ - タグは button とする
- テキスト要素は空
- color は blue であること
- color 属性は HTML の予約語ではない
- disabled されていないこと
これらを考慮するとこうなる
:button で書く場合
Capybara.find(:button, text: "", exact_text: true) { |e| e["color"] == "blue" } # => #<Capybara::Node::Simple tag="button" path="/html/body/button[1]">
- 予約語ではない color は引数に書けないのでブロックで書かないといけない
- 一方で
disabled: false
はデフォルトで効いている
:element で書く場合
Capybara.find(:element, "button", text: "", exact_text: true, color: "blue", disabled: false) # => #<Capybara::Node::Simple tag="button" path="/html/body/button[1]">
- 予約語ではない color も引数に書ける
- 一方で
disabled: false
はデフォルトで効いていないので明示しないといけない
ダウンロードディレクトリを変更する
Capybara.current_session.driver.browser.download_path = Pathname("~/tmp").expand_path.to_s
- selenium の管轄
- めちゃくちゃ不親切な設計になっている
-
セッターしかない
- 現在の値を確認しようとしてすげーはまる
- 存在しないディレクトリ渡してもエラーはでない
- 不正な値を設定してもエラーはでない
- 存在するディレクトリ
~/tmp
を渡しても動かない-
~
が含まれているため動かない - エラーはでない
-
- Pathname のインスタンスのまま渡しても問題はなかったけど to_s しといた方が良さそう
- デフォルトは
Pathname("~/Downloads").expand_path.to_s
相当
テキストは部分一致より完全一致の方に寄せていく
部分一致が初期値になっているのがよくない。次のように書いてしまうと「送信」は部分一致なので、そのときは動くかもしれないけどのちのち動かなくなる。
click_on("送信")
また部分一致なせいでどこの「送信」を指しているのか自分自身がわからなくなりメンテナンスが難しくなる。なので常に exact_text: true
をつける方に寄せていく。
click_on("メールを送信する", exact_text: true)
すべてに exact_text: true
をつけたならグローバルで有効にする
Capybara.exact_text = true
できれば最初からグローバルで有効にしておきたい
Capybara は head にアクセスできない
Capybara.visit("https://www.google.co.jp/")
Capybara.has_selector?("html") # => true
Capybara.has_selector?("body") # => true
Capybara.has_selector?("head") # => false
- html や body タグにはアクセスできる
- しかし head タグ以下にアクセスできない
- なので meta タグの内容も取れない
- なぜこんな不便な仕様なのかはわからない
Nokogiri::HTML(Capybara.html).at("head").path # => "/html/head"
とりあえず Nokogiri に html を丸ごと渡せば head にアクセスできる
自由にスタイルを書き換える
css = <<~EOT.gsub(/\R/, "")
body {
background-color: blue;
}
EOT
Capybara.execute_script(<<~EOT)
const style_el = document.createElement("style")
const text_node = document.createTextNode("#{css}")
style_el.appendChild(text_node)
document.body.appendChild(style_el)
EOT
特定の画像のサイズを調整する
Capybara.execute_script("document.querySelector('#app > img').style.height = '64px'")
ドラッグアンドドロップ
Capybara.visit("https://sortablejs.github.io/Vue.Draggable/#/simple")
a = Capybara.find(".list-group-item:nth-child(1)")
b = Capybara.find(".list-group-item:nth-child(2)")
[a.text, b.text] # => ["John", "Joao"]
a.drag_to(b)
a = Capybara.find(".list-group-item:nth-child(1)")
b = Capybara.find(".list-group-item:nth-child(2)")
[a.text, b.text] # => ["Joao", "John"]
BASIC認証にまつわる災いを回避する
Capybara.visit("https://id:password@www.example.com")
Capybara.visit("https://www.example.com")
いったんBASIC認証で入ったあとでBASIC認証を外したURLで入り直す。
まず、URL にBASIC認証を埋め込んだ状態でアクセスすると、その URL にはアクセスできるが、その URL から(おそらく異なるドメインにある)リソースを読み込んだときや、リダイレクトしたときBASIC認証にひっかかってしまうことがある。その場合いったんBASIC認証つきで入ったあとBASIC認証を埋め込んでいないURLでアクセスし直すとその災いを回避できる。
しょうもないことではまらないようにするには?
- セレクターに用いるクラスやテキストを手入力するべからず
- デベロッパーツールから本物の値をコピペするべし
「このサイトを離れますか?」の確認ダイアログへの対処方法
今いるタブは放置で新しいタブを作る方法がもっとも安全に回避できる
Capybara.visit(url)
↓
window = Capybara.current_window
Capybara.switch_to_window(Capybara.open_new_window)
Capybara.visit(url)
window.close
- ダイアログが出るタイミングはさまざま
- confirm メソッドを空に置き換える方法は効かない
Tailwind CSS 乱用サイト対策
Tailwind CSS を乱用したサイトはマークアップの概念が崩壊しているため class でひっかけるのは難しい。そういうときは src, alt, style の値から何か意味のあるキーワードを探す。もし src="/images/xxx_cart_xxx.png"
を見つけたら次のようにして確認する。
Capybara.has_selector?("[src*='cart']")
スライダーを最小・最大まで移動させる
Capybara.find(:field, type: "range").send_keys(:home)
Capybara.find(:field, type: "range").send_keys(:end)
スライダーを直感的に動かすのは難しいため、併設されているボタンを連打すれば最小・最大まで持っていけると考えてしまいがち。たしかにそれでも動かせるが、恐しく時間がかかる上、動かし終わったかどうかの判定処理も必要になってくる。単に home, end を送るので良い。
ラジオボタンが有効になっているか調べる
Capybara.find(:radio_button, id: "foo").checked?
Capybara.has_selector?(:radio_button, id: "foo", checked: true)
within を使いたくないとき
あえてブロックにしたくないときもある。そういうときは find を繋ぐ。
Capybara.within("div[role=dialog]") do
Capybara.find(:button, text: "編集", exact_text: true).click
end
↓
Capybara.find("div[role=dialog]").find(:button, text: "編集", exact_text: true).click
フォーム入力値の判定には with を使う
node = Capybara.string(%(
<input class="a" value="">
<input class="b" value="x">
<input class="c" value="xxxxx">
))
node.find(:fillable_field, with: "")[:class] # => "a"
node.find(:fillable_field, with: "x")[:class] # => "b"
node.find(:fillable_field, with: /xx/)[:class] # => "c"
node.assert_selector(:fillable_field, class: "a", with: "") # => true
node.assert_selector(:fillable_field, class: "b", with: /.+/) # => true
- 条件に
with == value
相当が追加される - 正規表現なら
value.match?(with)
相当になる - value と比較するなら with ではなく value にしてほしかった
- 値があるかどうかも with のみで書ける
node.assert_selector(".b[value='x']") # => true
- 上の場合、静的なHTMLが最初から
value="x"
となっていたときだけ true になる - 後から value が x に変わったときは正しく判定されない
node.assert_selector(".b") { |e| e.value == "x" } # => true
苦肉の策としてブロックを使う方法もあるが、それをするなら最初から fillable_field で with を使って書いた方がよい
フォーム入力値に value 属性がない場合はブロックを使う
value 属性が欠けていると非常に面倒で with オプションが通用しなくなる
node = Capybara.string("<input>")
node.find(:fillable_field, with: nil) rescue $! # => #<Capybara::ElementNotFound: Unable to find visible field nil that is not disabled with value "">
value がないなら nil と比較してくれてよさそうに思うが実際はエラーになる。この場合はブロックで value.nil?
を判定しないといけない。
node = Capybara.string(%(<input>))
node.find(:fillable_field) { |e| e.value.nil? } # => #<Capybara::Node::Simple tag="input" path="/html/body/input">
したがって value 属性がないものが含まれる場合の値の有無判定は次のようになる[1]
node = Capybara.string(%(
<input>
<input value="x">
))
node.assert_selector(:fillable_field) { |e| e.value.blank? } # => true
node.assert_selector(:fillable_field) { |e| e.value.present? } # => true
CSSセレクターと class の混同に注意する
node = Capybara.string(<<~EOT)
<div class="a">
<div class="b"></div>
</div>
EOT
node.find(class: ".a .b") rescue $! # => #<Capybara::ElementNotFound: Unable to find css nil with classes [.a .b]>
node.find(".a .b") # => #<Capybara::Node::Simple tag="div" path="/html/body/div/div">
node.find(class: "b") # => #<Capybara::Node::Simple tag="div" path="/html/body/div/div">
- CSS セレクター と class は異なる
- class はマッチする要素との比較
class == 要素のclass値
を行う
特にカスタムセレクターとの組み合わせで間違えやすい
node = Capybara.string(<<~EOT)
<div class="a"><input></div>
EOT
node.has_selector?(:fillable_field, class: ".a input") # => false
node.find(".a").has_selector?(:fillable_field) # => true
- fillable_field を使った場合 CSS セレクターを書く場所がないため、自然と class に書いてしまってはまりやすい
- CSSセレクターを使うならその外側で書く
- 上の例では Capybara.string が返すインスタンスに within がないため find で繋げたが、実際は
within(".a") { has_selector?(:fillable_field) }
のように within スコープを使う
CSSアニメーションに注意する
CSSアニメーションはサイト利用者にとって邪魔でしかない。きびきび動けばそれが最高の状態であるにもかかわらずCSSアニメーションを多用するサイトは少なくない。これは自動操作する上でも邪魔で不可解な挙動を誘発しやすい。
たとえば、ボタンを押すとメニューが飛び出てくる UI があったとき、ボタンを押した直後にメニュー要素をクリックしても、指定要素とは異なる要素をクリックしてしまうことがある。これは飛び出てくるアニメーションの最中のため、動いている的に矢を放つような状態になってしまい、目標の要素を正しくクリックできない。
この場合、どうにかしてアニメーションが終わるのを検知できればいいのだが、検知できる要素がなかったりして難しいのでやむをえず sleep(1)
を挟んで対処する。
モーダルの背景要素をクリックする
モーダル内に閉じるボタンがなく、閉じるには背景をクリックしなければならないダメな UI になっている場合、背景要素を正確に選択できればいいのだが、Tailwind CSS 乱用の弊害で選択が難しい場合がある。そのようなときは単に body をクリックすればよい。というか最初から body をクリックすればよい。
ただし、次のように書くと、
Capybara.find("body").click
*** Selenium::WebDriver::Error::ElementNotInteractableError Exception: element not interactable: element has zero size
「body のサイズは 0」などと意味不明なエラーが出て失敗する。
この場合は、クリックする座標を明示すると通る。
Capybara.find("body").click(x: 0, y: 0)
fillable_field では css のセレクタが使えない点に注意する
node = Capybara.string(%(
<div class="form1"><input value="x" /></div>
<div class="form2"><input value="x" /></div>
))
となっているとき、
node.assert_selector(:fillable_field, css: ".form1 input", with: "x") rescue $! # => #<ArgumentError: Invalid option(s) :css, should be one of :above, :below, :left_of, :right_of, :near, :count, :minimum, :maximum, :between, :text, :id, :class, :style, :visible, :obscured, :exact, :exact_text, :normalize_ws, :match, :wait, :filter_set, :focused, :disabled, :valid, :name, :placeholder, :validation_message, :with, :type, :multiple>
node.assert_selector(:fillable_field, ".form1 input", with: "x") rescue $! # => #<Capybara::ExpectationNotMet: expected to find field ".form1 input" that is not disabled but there were no matches>
とは書けないので外側でいったん css 表記で探してから入れ子で fillable_field を使うとうまくいく。
node.find(".form1").assert_selector(:fillable_field, with: "x") # => true
これを知らないと、このように書くことになってしまう。
node.assert_selector(".form1 input") { |e| e.value == "x" } # => true
さらに補足すると find(...).assert_selector
は within(...) { assert_selector }
に置き換えることができる。ただ、ややこしいことに Capybara.string
が返すオブジェクトには within
が生えてないので、小さなサンプルで検証するときは find
で連結した方がよい。
フォーカスがあたっているのを検証する
assert_selector(".xxx", focused: true)
LINE のような UI で過去の発言を見るためにさかのぼる
スクロールする外側の要素の scrollTop を 0 にすれば、
Capybara.execute_script(%(document.querySelector(".ScrollArea").scrollTop = 0))
最上位までいっきにスクロール (というかジャンプ) できる。おそらくそれがトリガーになってさらに古い発言が読み込まれるので全件読み込むには上を何度も繰り返すことになる。一方、最新の発言までスクロールするには、
Capybara.execute_script(%(document.querySelector(".ScrollArea").scrollTop = document.querySelector(".ScrollArea").scrollHeight))
scrollHeight を scrollTop に指定する。
ヘッドレスモードでは visibilitychange イベントが発生しない
タブを切り替えたときに「来た」と「出た」を判定できる visibilitychange イベントがある。ヘッドレスモードにすると、このイベントが発生しなくなる。他のブラウザでは確認していないが Google Chrome 120 ではそうだった。不具合かどうかはわからない。
この挙動を知らないと、デバッグの際には(ヘッドありにするので)動くけど、デバッグを終えると(ヘッドレスにするので)動かなくなって、かなりはまる。
ラジオボタンをクリックする
SOURCE = <<~EOT
<div class="block1">
<label><input type="radio" value="v1">選択肢1</label>
<label><input type="radio" value="v2">選択肢2</label>
</div>
<div class="block2">
<label><input type="radio" value="v1">選択肢1</label>
<label><input type="radio" value="v2">選択肢2</label>
</div>
EOT
path = Tempfile.open(["", ".html"]) { |e| e.tap { |e| e << SOURCE } }.path
Capybara.visit("file://#{path}")
block2 側にある「選択肢2」をクリックする例:
Capybara.find(".block2 input[value='v2']").click # => #<Capybara::Node::Element tag="input" path="/HTML/BODY[1]/DIV[2]/LABEL[2]/INPUT[1]">
Capybara.within(".block2") { Capybara.find(:label, text: "選択肢2", exact_text: true).click } # => #<Capybara::Node::Element tag="label" path="/HTML/BODY[1]/DIV[2]/LABEL[2]">
Capybara.within(".block2") { Capybara.find(:field, with: "v2").click } # => #<Capybara::Node::Element tag="input" path="/HTML/BODY[1]/DIV[2]/LABEL[2]/INPUT[1]">
Capybara.within(".block2") { Capybara.choose("選択肢2", exact: true) } # => #<Capybara::Node::Element tag="input" path="/HTML/BODY[1]/DIV[2]/LABEL[2]/INPUT[1]">
choose を使うときは exact: true
で完全一致させる。または初期値を Capybara.exact = true
としておく。そうしないともし "選択肢20" をあとから追加されると複数にマッチして動かなくなる。
参照
-
^ActiveSupportが使える状態と仮定する ↩︎
Discussion