背景・事情
Capybaraでユーザの動作をシミュレートする場合、Capybara::Node::Actions
モジュールのメソッドが便利ですよね。
例えば、入力欄に文字を入力する#fill_in
、ラジオボタンによる選択肢を選ぶ#choose
、プルダウンメニューによる選択肢を選ぶ#select
、チェックボックスのチェックを入れる#check
、リンクをクリックする#click_link
などのメソッドがあります。
これらのメソッドを使ってユーザのアクションをシミュレートしていくわけですが、そのアクションの対象となる要素を指定する方法として、メソッドの引数にlocatorというものを渡します。このlocatorはメソッドによって異なりますが、例えば「name属性の値・id属性の値・対応するlabel要素のテキストのいずれか」といった具合になっています。
しかし、ここはlocatorじゃなくCSSセレクタを使いたい、と思うわけです。
CSSセレクタが使えればテストコードの記述の柔軟性も上がりますし、テスト中の要素の指定方法としてCSSセレクタに記述の形式を統一しておきたいのです。
ということで、Capybara::Node::Actions
モジュールのメソッドについて、CSSセレクタを使いつつ同じ動作を実現するための方法を書いてみようと思います。
実現方法に至るまでの説明
Capybara::Node::Actions
モジュールのメソッドの動作を知るために、Capybaraのソースコードをちょっと覗いてみます。例えばCapybara::Node::Actions#click_link
の定義部分はこんな感じです。
def click_link(locator, options={})
find(:link, locator, options).click
end
Capybara::Node::Finders#find
メソッドによって要素を指定して、Capybara::Node::Element#click
メソッドを呼んでいます。これは他のアクションも同様で、#find
で要素を指定した後、#click
や#set
などを呼んでいます。
#find
メソッドにはCSSセレクタを使うことができるので、要素の指定方法を要素の種類とlocatorの組からCSSセレクタに置き換えればアクションの動作自体は実現できます。
ですが、それだと要素の種類は限定できません。例えば、#click_link
メソッドの場合、アクションの対象をリンクに限定してくれるわけです。単純にCSSセレクタに置き換えるだけだと、選択される要素の種類を限定することができません。
そこで、もう少し調べてみます。ソースコードを追っていくと、#find
メソッドによる要素指定には、XPath::HTML
モジュールのメソッドが使われているようです。例えば、#click_link
メソッドに使われているのは、XPath::HTML.link
メソッドです。
irbで実行して、どんなXPathが生成されるかを見てみます。
irb(main):001:0> require 'xpath'
=> true
irb(main):002:0> XPath::HTML.link('locator').to_xpath
=> ".//a[./@href][(((./@id = 'locator' or contains(normalize-space(string(.)), 'locator')) or contains(./@title, 'locator')) or .//img[contains(./@alt, 'locator')])]"
.//a[./@href]
の部分が要素の種類を限定している部分で、残りの部分は「id属性・titile属性・(子孫img要素の)alt属性のいずれかにlocatorを含む」という条件を付与していますね。
CSSセレクタに要素の種類を限定している部分を組み合わせれば目的は達成できそうです。もちろんXPathとCSSセレクタはそのままでは結合はできませんが、Nokogiri::CSS.xpath_for
メソッドを使うとCSSセレクタをXPathに変換できるので結合できます。
ということで、#click_link
に相当する動作をCSSセレクタで行うメソッドは、こんな風に定義できます。
def click_link_css(css_selector)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../a][@href]"
find(:xpath, xpath).click
end
この要領でCapybara::Node::Actionsモジュールのメソッドに相当するメソッドを独自に定義して使えばいいんじゃないか、ということです。
というわけで具体例
以下のような感じでメソッド定義して使えばいいんじゃないかと思います。
def click_link_css(css_selector)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../a][@href]"
find(:xpath, xpath).click
end
def click_button_css(css_selector)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "(#{base_xpath}[../input][@type='submit' or @type='reset' or @type='image' or @type='button'] | #{base_xpath}[../button])"
find(:xpath, xpath).click
end
def click_link_or_button_css(css_selector)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "(#{base_xpath}[../a][@href] | #{base_xpath}[../input][@type='submit' or @type='reset' or @type='image' or @type='button'] | #{base_xpath}[../button])"
find(:xpath, xpath).click
end
def click_on_css(css_selector)
click_link_or_button_css(css_selector)
end
def fill_in_css(css_selector, with)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../input or ../textarea][not(@type='submit')][not(@type='image')][not(@type='radio')][not(@type='checkbox')][not(@type='hidden')][not(@type='file')]"
find(:xpath, xpath).set(with)
end
def choose_css(css_selector)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../input][@type='radio']"
find(:xpath, xpath).set(true)
end
def check_css(css_selector)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../input][@type='checkbox']"
find(:xpath, xpath).set(true)
end
def uncheck_css(css_selector)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../input][@type='checkbox']"
find(:xpath, xpath).set(false)
end
def select_css(css_selector, value)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../select]//option[@value='#{value}']"
find(:xpath, xpath).select_option
end
def unselect_css(css_selector, value)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../select]//option[@value='#{value}']"
find(:xpath, xpath).unselect_option
end
def attach_file_css(css_selector, file_path)
base_xpath = Nokogiri::CSS.xpath_for(css_selector)[0]
xpath = "#{base_xpath}[../input][@type='file']"
find(:xpath, xpath).set(file_path)
end
補足というか言い訳
CSSセレクタを使いたいだけなら、#find
メソッドでCSSセレクタを使って絞り込んでからメソッドチェーンでアクションを繋げばいいじゃん、という意見もありそうです。例えばこんな感じですね。
find('div.foo').fill_in('FirstName', :with => 'Hoge')
でもやっぱりlocatorによる指定が必要になるので、これがlabelのテキストだったりすると文言を変更したらテストコードも変更しなきゃいけないし、idを指定するくらいなら直接CSSセレクタでアクションの対象要素を指定できたほういいよなー、なんて思っちゃいます。
参考
https://github.com/jnicklas/capybara/blob/2.4.4/lib/capybara/node/actions.rb
https://github.com/jnicklas/capybara/blob/2.4.4/lib/capybara/selector.rb
https://github.com/jnicklas/xpath/blob/2.0.0/lib/xpath/html.rb