先日、capybaraでJS等の処理を安定的にテストするテクニックを知ったのですが、その仕組みが気になって調べてみたので記事にしました。
不安定なテストの例
例えば、「Finish」というリンクをクリックすると、そのリンクのテキストが「Done」にJSで変わるテキストがあったとします。
(そのリンクにはtask-status-link
のclassが付与されているとします)
その動作テストをcapybaraを使ったSystemSpecで書きたいとき、以下のようにするとテストが不安定になることがあります。
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にも関連の記載がありました。
先ほどの例だと、以下のように書くと安定します。
Capybara.default_max_wait_time = 5
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
指定時間の間、成功するまでリトライしていた
ソースコードを追いかけたところ、以下に辿り着きました。
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
省略しながらやっていることをコメントアウトで記載すると以下の通りです。
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
を使う場合、以下のところでチェックされます。
def resolve_for(node)
@node = node
@actual_text = text # 取得したテキスト(ブラウザで表示されているテキスト)
# 取得したテキスト(ブラウザで表示されているテキスト) とテスト対象のテキストが一致する数を計算
@count = @actual_text.scan(@search_regexp).size
end
ここで@count
が計算され、以下が実行されます。
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)通りの結果になるまでテストをリトライする」という仕組みになっていることを知れました。