【capybara/RSpec】JS等の処理でhave_xxxのメソッドを使うテクニックと仕組み

先日、[capybara](https://github.com/teamcapybara/capybara)でJS等の処理を安定的にテストするテクニックを知ったのですが、その仕組みが気になって調べてみたので記事にしました。
## 不安定なテストの例
例えば、「Finish」というリンクをクリックすると、そのリンクのテキストが「Done」にJSで変わるテキストがあったとします。
（そのリンクには`task-status-link`のclassが付与されているとします）
その動作テストをcapybaraを使ったSystemSpecで書きたいとき、以下のようにするとテストが不安定になることがあります。

```ruby:system_spec
RSpec.describe "Books", type: :system do
  it 'sample test' do
    # 略
    click_on 'Finish'
    expect(find('.task-status-link').text).to eq 'Done'
  end
end
```

Capybaraが`find('.task-status-link')`するタイミングがテキストが変わるよりも早いと、テストが落ちます。

## `have_xxx`を使う
`have_contenxt`や`have_text`のメソッドを使うと、`Capybara.default_max_wait_time`で設定した時間もしくはデフォルトの2秒は非同期処理を待ってくれます。
確認したら[CapybaraのREADME](https://github.com/teamcapybara/capybara/blob/master/README.md#asynchronous-javascript-ajax-and-friends)にも関連の記載がありました。

先ほどの例だと、以下のように書くと安定します。
```ruby:spec/spec_helper.rb
Capybara.default_max_wait_time = 5
```

```ruby:system_spec
RSpec.describe "Books", type: :system do
  it 'sample test' do
    # 略
    click_on 'Finish'
    expect(page.find('.task-status-link')).to have_content 'Done'
  end
end
```

今まで、`sleep`や`Timeout.timeout...`を使う方法は知っていたのですが、こういったメソッドに処理を待ってくれる仕組みがあることは知らなかったので勉強になりました。

## 仕組みを調べた

この「処理を待つ」という処理はどのように実現いるんだろう？と思ってソースを追って調べてみました。
Capybaraのバージョン： 3.36.0

### 指定時間の間、成功するまでリトライしていた

ソースコードを追いかけたところ、以下に辿り着きました。

```ruby:capybara-3.36.0/lib/capybara/node/base.rb
def synchronize(seconds = nil, errors: nil)
  return yield if session.synchronized

  seconds = session_options.default_max_wait_time if [nil, true].include? seconds
  session.synchronized = true
  timer = Capybara::Helpers.timer(expire_in: seconds)
  begin
    yield
  rescue StandardError => e
    session.raise_server_error!
    raise e unless catch_error?(e, errors)

    if driver.wait?
      raise e if timer.expired?

      sleep(0.01)
      reload if session_options.automatic_reload
    else
      old_base = @base
      reload if session_options.automatic_reload
      raise e if old_base == @base
    end
    retry
  ensure
    session.synchronized = false
  end
end
```

省略しながらやっていることをコメントアウトで記載すると以下の通りです。

```ruby:capybara-3.36.0/lib/capybara/node/base.rb
def synchronize(seconds = nil, errors: nil)
  # 略
  # 1. 待ち時間を決める（Capybara.default_max_wait_time）が使われる
  seconds = session_options.default_max_wait_time if [nil, true].include? seconds
  # 略
  # 2. 上記で決めた待ち時間をもとに、期限を設定
  timer = Capybara::Helpers.timer(expire_in: seconds)
  begin
    # 3. このメソッドの呼び出しもとのブロックが実行される。この中で'Done'というテキストになっているか？をチェックする
    yield
  rescue StandardError => e
    # 略
    if driver.wait
      # 5. 期限切れ（待ち時間中retryし続けたが成功することがなかった）の場合はe（Capybara::ExpectationNotMet）のエラーを発生させ、retryさせない
      raise e if timer.expired?
      # 略
    else
      # 略
    end
    # 4. 失敗した場合はリトライする
    retry
  ensure
    # 略
  end
end
```

ちなみに、`have_content`を使う場合、以下のところでチェックされます。

```ruby:capybara-3.36.0/lib/capybara/queries/text_query.rb
def resolve_for(node)
  @node = node
  @actual_text = text # 取得したテキスト（ブラウザで表示されているテキスト） 
  # 取得したテキスト（ブラウザで表示されているテキスト） とテスト対象のテキストが一致する数を計算
  @count = @actual_text.scan(@search_regexp).size 
end
```

ここで`@count`が計算され、以下が実行されます。
```ruby:capybara-3.36.0/lib/capybara/node/matchers.rb
def assert_text(type_or_text, *args, **opts)
  _verify_text(type_or_text, *args, **opts) do |count, query|
    unless query.matches_count?(count) && (count.positive? || query.expects_none?)
      raise Capybara::ExpectationNotMet, query.failure_message
    end
  end
end
```

もし`@count`が1以上であれば、
`query.matches_count?(count) && (count.positive? || query.expects_none?)`
がtrueになり、2つ上で示したコードの
`「3. このメソッドの呼び出しもとのブロックが実行される。この中で'Done'というテキストになっているか？をチェックする」`
と書いたところでエラーが発生せず、retryされずにテストが通過する、という仕組みのようです。

## さいごに

よく「JSの処理を待つ」と言ったりしますが、
この方法を使った場合は、正確には「**期待（expect）通りの結果になるまでテストをリトライする**」という仕組みになっていることを知れました。