Capybara のアクションにCSSセレクタを使う

# 背景・事情

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`の定義部分はこんな感じです。

```rb
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
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セレクタで行うメソッドは、こんな風に定義できます。

```rb
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モジュールのメソッドに相当するメソッドを独自に定義して使えばいいんじゃないか、ということです。

# というわけで具体例

以下のような感じでメソッド定義して使えばいいんじゃないかと思います。

```rb
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セレクタを使って絞り込んでからメソッドチェーンでアクションを繋げばいいじゃん、という意見もありそうです。例えばこんな感じですね。

```rb
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