Capybaraで壊れにくいテストを書くために気を付けていること

この記事は[SmartHR Advent Calendar 2019](https://qiita.com/advent-calendar/2019/smarthr) 2日目の記事です。

SmartHRではRuby on Railsを広く採用しています。アプリケーションを長期的にメンテナンスしていくためにテストは欠かせません。特にReact.jsなどを用いた複雑なUIにおいては、単なるAPIのテストやモデルのテストだけではなく「実際にブラウザを操作して、ユーザーが期待する結果を得られるかどうか」をテストすることが重要です。

Rubyではこのようなブラウザを操作するテストを書くために、[Capybara](https://github.com/teamcapybara/capybara)という便利なフレームワークがあり、比較的簡単にテストを書き始めることができます。ただ、この手のテストは保守が大変であったり、手間の大きさからテストが追加されなくなったり、ということがよくあります。本記事では、私がこれまでの経験から学んだ、壊れにくいテストを書くためのTipsを紹介します。

なお、特に説明のない限り、ここではCapybara + RSpec + Selenium + Chrome (Headless)の環境を想定しています。

## `sleep`しない
出オチっぽいですが、非常に重要です。非同期なリクエストの結果を待っているときや、時間差でレンダリングされる画面など「いい感じに少し待って」と言いたくなる状況は確かにあります。

そういった場合に、単に`sleep 5`などとしてしまうと

- テストを実行する環境によって適切な待ち時間が異なるため、落ちたり落ちなかったりするテストが生まれやすい
- テストが落ちたときに、適当に待ち時間を伸ばされることが多く、テストの実行時間が伸びがち

などの問題があります。このような場合では、何を待っているかを短いスパンで定期的にタイムアウトまで待つようなヘルパーメソッドを定義して、それを利用するようにしましょう。

```ruby
it "何かのアクションの結果、Successメッセージが帰ってくる" do
  click_button "何かのアクション"
  finally do
    expect(page).to have_content "Success!"
  end
end

def finally(timeout: Capybara.default_max_wait_time)
  start = Time.now

  begin
    yield
    return
  rescue RSpec::Expectations::ExpectationNotMetError, Capybara::ElementNotFound
    raise if Time.now > start + timeout
    sleep 0.1
    retry
  end
end
```

## XPathやclassに依存しない
Capybaraでは`have_button`や`fill_in`など基本的なHTMLの要素に対するヘルパーが定義されているため、素直な画面に対しては比較的読みやすいテストを書くことができます。

しかし、現実には画面が複雑な構造になっていることが多く、これらのヘルパーだけでは力不足であることはよくあります。そういったときに、XPathやclassに依存したテストを書いてしまうこともきっとよくあるでしょう。

```ruby
find('form active-form button').click
expect(page).to have_xpath '//*[@id="form"]/div[2]'
```

しかし、これでは後からテストだけ見たときに、何をテストしているのかわからなくなってしまいます。例えば、あなたが画面を大幅に弄った後に、「よーし、テスト直すかー」とこのテストを見たら... きっとこのテストごと消えてしまうことになるでしょう。

このような悲劇を産まないためにも、テストは後から読めるように、XPathやclassに依存しないことをおすすめしています。とはいえ、素直にヘルパーが利用できない画面というのは当然ありますから、話はそんな簡単ではありません。こういった場合には[データ属性](https://developer.mozilla.org/ja/docs/Learn/HTML/Howto/Use_data_attributes)を利用し、さらにそれを指定するヘルパーメソッドを生やすと良い感じになります。

```ruby
click_form_button
expect_to_have_error_message
```

```ruby
def click_form_button
  find(spec_selector('active-form-button')).click
end

def expect_to_have_error_message
  expect(page).to have spec_selector('active-form-error-message')
end

def spec_selector(name)
  "[data-spec='#{name}']"
end
```

データ属性は他の用途に利用されることがなく、自由に目印をつけられるので、テストを見たときに何を指しているかわかりやすく、HTML側の編集時にも目を引く良い方法です。適切な単位でデータ属性を割り当てていれば「なぜかわからないけどdivをひとつズラしたらテストが通らなくなった」といった問題も起きにくくなるでしょう。

## `within`を活用する

この記事をテストすると仮定して、「本文の書き出しに"Capybara"というリンクが含まれていること」をテストするとします。

```ruby
expect(page).to have_link "Capybara", href: "https://github.com/teamcapybara/capybara"
```

これでもテストは通りますが、これでは「本文の書き出しの中に」という重要な条件が抜けてしまっています。例えば、末尾の参考文献に"Capybara"を含むリンクを追加した途端、本当にテストしたかった書き出しのリンクが消えても、テストが通る状態になってしまいます。

他にも「保存」ボタンをクリックしたい状況があるとして、同じ画面中にいくつも「保存」ボタンがあると、単に`click_button "保存"`では、Ambiguous matchを引き起こしてしまいます。`match: :first`や`all`してアクセスする方法もありますが、あまり良い方法ではありませんよね。

```ruby
click_button "保存", match: :first # firstって何？
all('button', text: "保存")[1].click # うーん...
```

こういった場合では、[`within`によるスコープの絞り込み](https://github.com/teamcapybara/capybara/tree/3.29.0#scoping)が役に立ちます。

```ruby
it "書き出しにリンクが含まれる" do 
  within_introduction do
    expect(page).to have_link "Capybara", href: "https://github.com/teamcapybara/capybara"
  end
end

it "ヘッダーの保存ボタンをクリック" do
  within_header do
    click_button "保存"
  end
end

def within_introduction
  within(spec_selector("introduction")) { yield }
end

def within_header
  within(spec_selector("header")) { yield }
end
```

データ属性を使ったヘルパーメソッドの定義と合わせると、随分と読みやすく感じるはずです。

## ユーザーの目に見えないもの（気にしないもの）をテストしない

これは書き方というか、心構えの問題だと思うのですが、基本的にデータベースの中身だったり、DOMの構造など「ユーザーが意識しないもの」はテストするべきではない、と考えています。例えば、こんなテストです。

```ruby
visit edit_user_path(user)
fill_in "名前", with: "新しい名前"
click_button "保存"

expect(user).to have_attribute(name: "新しい名前")
```

もちろん、こういったテストを書かざるを得ない状況というのもあると思うのですが、可能な限りユーザーの体験をテストしたいので、ユーザーが知ることができないデータベースの値をテストするのは望ましくないでしょう。実際にユーザーが更新済みの値を見ることができる画面でテストするべきです。

```ruby
visit edit_user_path(user)
fill_in "名前", with: "新しい名前"
click_button "保存"

expect(page).to have_current_path user_path(user)
expect(page).to have_content "新しい名前"
```

ボタンクリックなどの操作も同様です。ユーザーは「divタグの３番目の中のボタンをクリックするぞ！」とクリックすることはありませんよね。`within`などと組み合わせて「新着メニューの中にあるボタンをクリックする」というように表現すると、後から見た時に読みやすくなります。

```ruby
# Bad
all('button', text: "詳細")[2].click

# Good
within_new_menu do
  click_button "詳細"
end
```

## ブラウザのサイズを大きくする

当たり前のことだからしれませんが、あまり言及されている印象がないので書いておきます。ブラウザのサイズは大きければ大きいほどいいです。

```ruby
Capybara.register_driver(:chrome_headless) do |app|
  options = Selenium::WebDriver::Chrome::Options.new(args: [
    "window-size=3000,3000",
    "headless",
    "disable-gpu",
  ])
  Capybara::Selenium::Driver.new(app, browser: chrome, options: options)
end
```

ブラウザのサイズが小さい場合、別の要素が被ってくることによって、テストが落ちるなどの問題が起きることがあります。もちろん、ブラウザサイズが小さい画面でテストをしたい状況もあるので、必ずしもこの手が使えるわけではないのですが、特に理由がないならば、ある程度大きく設定しておくことをおすすめします。

## 簡単にブラウザを起動できる環境を用意する

CIでテストを回すことを考えると、ヘッドレスモードでChromeを動かすことになると思いますが、開発中やテストを書いている段階では、実際にブラウザが立ち上がって操作しているところを見れる方がテンションもあがりますし、問題を特定しやすくなります。

個人的には、環境変数でdriverを簡単に切り替えられるようにしておくと、さっとブラウザを起動してテストが落ちた原因を探ることができるので便利です。

```ruby
Capybara.configure do
  config.default_driver = ENV['FOREGROUND'] ? :chrome : :chrome_headless
end
```

## おわりに

ブラウザを操作するテストはコストが高く、メンテが難しい、という意見をよく聞きますが、個人的にはコツを抑えて書けば、もっとうまくできるのではないかと思っています。

SmartHRもまだ十分と言える状況ではありませんが、QAチームとも協力しながら、うまくテストを増やしていきたいところです。