🦫

Capybaraによるブラウザ操作自動化の知見まとめ

2022/05/22に公開

はじめに

ブラウザ上で行う面倒な作業を自動化しているうちにいろんなノウハウが溜まったのでまとめた。もともと 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 のバージョンが一致していない
  • コマンドラインですぐにバージョンを確認できるようにしておく
~/bin/capybara-version-check
"/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/capybara-env-backup.sh
#!/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. 自動更新を止める
  2. バックアップも取る
  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 オプションの組み合わせは検証よりもその状態になるまで待つのに使える。

想定のタイトルになるまで待つ

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">]>

見ると、

  1. CSS として //a が入力された (そんな CSS の表記はない)
  2. ////a に変換された (そんな XPath の表記もない)
  3. しかし、引けた (なぜ?)

たまたまでも動くなら 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")

サイト訪問時のスピナーが出る順序に気をつける

次のようになっているように見えて、

  1. 最初にローディングが始まってスピナーが数秒間表示される
  2. 入力フォームに入力できるようになる

実際はこうなっている場合がある

  1. 入力フォームが一瞬表示される
  2. 最初にローディングが始まってスピナーが数秒間表示される
  3. 入力フォームに入力できるようになる

前者だと思って次のように書くと、いきなり 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 でなない場合はマッチしない

組み込みセレクタの引数一覧

https://github.com/teamcapybara/capybara/blob/8350c78823d6869c3380f5d8174f18e86dff4d9c/lib/capybara/selector.rb#L8-L174

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_scriptexecute_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 を送るので良い。

ラジオボタンが有効になっているか調べる

セレクタに checked? が含まれないのでいまいち
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 のみで書ける
間違った方法その1
node.assert_selector(".b[value='x']")  # => true
  • 上の場合、静的なHTMLが最初から value="x" となっていたときだけ true になる
  • 後から value が x に変わったときは正しく判定されない
間違った方法その2
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_selectorwithin(...) { 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" をあとから追加されると複数にマッチして動かなくなる。

参照

https://github.com/teamcapybara/capybara
https://github.com/SeleniumHQ/selenium/tree/trunk/rb
https://gist.github.com/palkan/9a9ba6fd0b54f41428eb62b32f988bfc

脚注
  1. ^ActiveSupportが使える状態と仮定する ↩︎

Discussion