🦫

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

2022/05/22に公開約40,800字

はじめに

ブラウザ上で行う面倒な作業を自動化しているうちにいろんなノウハウが溜まったのでまとめました。

もともと 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 を呼べる
  • 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 オプションを組み合わせると検証よりもその状態になるまで待つのに使える

送信ボタンが表われるまで最大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 だとなんだっけ? な場合に役立つ。

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_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("送信")

文言だけを頼りにそれを直下に持つタグを探す

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

node.find("h2", text: "2000-01-01").path                                            # => "/html/body/div/h2"
node.find(:xpath, "//h2[contains(text(),'2000-01-01')]/../ul").path                 # => "/html/body/div/ul"
node.find(:xpath, "//h2[contains(text(),'2000-01-01')]/parent::*/ul").path          # => "/html/body/div/ul"
node.find(:xpath, "//h2[contains(text(),'2000-01-01')]/following-sibling::ul").path # => "/html/body/div/ul"
  • CSS の書き方では h2 までは行けるが上に上がれない
  • ..parent::* は同じ
  • following-sibling::ul は h2 のあとでに出てくる兄弟の ul
  • というか全部 XPath で書くのではなく上に上がるところだけ XPath で書いた方が楽
node.find("h2", text: "2000-01-01").find(:xpath, "..").all("li")

指定のクラスが含まれないセレクタ

!クラス名 で「含まれない」を指定できる

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 xcolor='white'>A</foo>
<foo xcolor='black'>B</foo>
<bar xcolor='black'>C</bar>
<Baz/>
EOT
node.find(:element, "foo", xcolor: "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

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

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" なリンクをクリックして開かれた新しいタブに移動する

window_opened_by のなかでクリックするのが重要

window = Capybara.window_opened_by do
  Capybara.find("#foo").click
end
Capybara.switch_to_window(window)

次の方法でもたまたま動くが新しく開かれたタブが windows.last である保証はない

Capybara.find("#foo").click
Capybara.switch_to_window(Capybara.windows.last)

タブ操作

新しいタブを開いて移動して何かして閉じて戻る

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"

D&Dでファイルアップロードする

dropybara gem を使わせてもらう

`convert logo: file.png`
Capybara.visit("https://css-tricks.com/examples/DragAndDropFileUploading/")
Capybara.page.drop_file("form", "file.png")

テキストフィールドに 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
# >> ..
# >>
# >> Finished in 7.38 seconds (files took 0.11407 seconds to load)
# >> 2 examples, 0 failures
# >>
$ 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
  s = $stdin.gets.strip
  if s == "d"
    Capybara.using_wait_time(0) { debugger }
  end
  Capybara.switch_to_window(Capybara.current_window)
  nil
end

人間の助けが必要なところに挟む

pause "reCAPTCHA を倒せ"
  • $stdin.gets しているのは Thor から使う場合を考慮している
  • gets だとファイルと見なした引数を読み込もうとしてしまう
  • ついでに d ENTER でデバッガーを起動させる
  • ENTER のあとターミナルからブラウザに切り替える
  • pause をユーザー入力して使うと面倒なことになるのであえて nil を返す

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 が元凶だったことがある

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"

リファレンス

https://github.com/teamcapybara/capybara
https://gist.github.com/palkan/9a9ba6fd0b54f41428eb62b32f988bfc

Discussion

ログインするとコメントできます