1 of 52

不安定テストを生み出すCapybaraを調教する

2020/06/12

@銀座Rails #22

Masatoshi Iwasaki

2 of 52

自己紹介

Masatoshi Iwasaki

masa-iwasaki

masa_iwasaki

フリーランスエンジニア

3 of 52

今日のテーマ

RailsでCapybaraを使ったテストが不安定なときに

「とりあえずこれ試して!」

というTipsについてまとめました

(「もうやっているよ!」ということも多いかも...)

4 of 52

対象とする環境

  • rspecを対象としてます
    • minitestでも役に立つことはあるかもしれません
  • Capybara + SeleniumDriver前提
    • webdrivers gem使ってるならこのパターン
    • headless chrome以外の経験が無いのでFirefoxとか他のブラウザだと違うこともあるかもしれません
  • system spec前提
    • feature spec使ってる場合は適宜読み替えてください

5 of 52

話さないこと

  • Capybaraが関与しないテストに関すること
    • model spec, request specなど
  • 高速化
    • 一部高速化に寄与するものもあります

6 of 52

約束できないこと

  • ここに書かれていることを全部実施したから不安定なテストが撲滅できるということは、たぶんない

7 of 52

(一応)Capybaraとは

“Capybara is a library written in the Ruby programming language which makes it easy to simulate how a user interacts with your application.”

  • ざっくり書くと、いい感じにブラウザ上でユーザーが行う操作を記述&実行するためのライブラリ

  • Seleniumなどの外部ライブラリをdriverとして利用することができ、Capybaraがそれらを使うための便利メソッドを提供してくれている

8 of 52

不安定テストとは?

9 of 52

(本発表での)不安定テストの定義

ほんとは通る(greenになる)はずなのに、

なぜか原因不明で落ちる(redになる)テストのこと。

つまり、テスト自体は正しく書けている(はず)前提。

稀に「ほんとは落ちるべきだったのにほとんどのケースで通っていた」という悲しい事実が発覚することもある

10 of 52

不安定テストの発生要因

  • 開発環境とCI環境の違い
    • ハードウェア・VM環境の違い
    • OS
    • 利用しているライブラリのバージョン
    • テストの実行順序(rspec --rand)
  • 落ちる可能性のあるテストの書き方
  • 原因不明
    • 調べてもよくわからないケース

11 of 52

不安定テストの発生要因

  • 開発環境とCI環境の違い
    • ハードウェア・VM環境の違い
    • OS
    • 利用しているライブラリのバージョン
    • テストの実行順序(rspec --rand)
  • 落ちる可能性のあるテストの書き方
  • 原因不明
    • 調べてもよくわからないケース

今日のメイントピック

12 of 52

落ちづらいテストを書かない方法

13 of 52

落ちづらいテストを書かないために

  • findの挙動を知る
  • 正しく要素をfindする
  • ユーザー目線で起こる状態変化をチェックする

14 of 52

findの実装

def find(*args, **options, &optional_filter_block)

options[:session_options] = session_options

synced_resolve Capybara::Queries::SelectorQuery.new(*args, **options, &optional_filter_block)

end

findメソッドのコードは以下のようになっている。

  • 引数はすべてCapybara::Queries::SelectorQuery へと引き渡される
    • find_buttonなどのfind_系メソッドはfindのラッパー
    • 複雑なクエリの処理をすべて引き受けてくれるのでこれ以降のコードを追うのが楽
  • Capybara::Node::Finders#synced_resolve から Capybara::Node::Base#synchronize へと処理が進む

15 of 52

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

個々の要素を

追っていくと結構複雑

16 of 52

簡略化

begin

セレクタを探すクエリの実行

rescue StandardError => e

raise e if 例外がElementNotFound以外

raise e if 制限時間超えている

sleep(0.01)

retry

...

大筋に関係ない処理を抜いてざっくりな挙動を抽出するとこんな感じ

17 of 52

findの挙動まとめ

  • findは渡されたセレクタにマッチする要素を見つけるためのクエリを作る
  • まずクエリを実行してElementNotFound以外の例外が来たらそのままraiseする
  • 例外がElementNotFoundで制限時間(後述)以内だったら0.01秒寝てから再度クエリを投げることを繰り返す
  • 制限時間を超えていたら例外をraiseする
    • 最後の実行がそれまで通り終了したらElementNotFoundã‚’raiseすることになる

18 of 52

落ちづらいテストを書かないために

  • findの挙動を知る
  • 正しく要素をfindする
  • ユーザー目線で起こる状態変化をチェックする

19 of 52

findの挙動まとめ(再掲)

  • findは渡されたセレクタにマッチする要素を見つけるためのクエリを作る
  • まずクエリを実行してElementNotFound以外の例外が来たらそのままraiseする
  • 例外がElementNotFoundで制限時間(後述)以内だったら0.01秒寝てから再度クエリを投げることを繰り返す
  • 制限時間を超えていたら例外をraiseする
    • 最後の実行がそれまで通り終了したらElementNotFoundã‚’raiseすることになる

20 of 52

findの隙を突かれるケース

  • セレクタの指定が不十分
  • findと異なる待ち方をするfinderメソッド
  • 制限時間を超える実行時間

21 of 52

1)セレクタの指定が不十分

# Bad:

expect(find(".user")["data-name"]).to eq("Joe")

# Good:

expect(page).to have_css(".user[data-name='Joe']")

Badのコードでは最初に見つかった .user なエレメントを返してくるが、datasetが期待する値でないことがある。

上記の例は .user な要素がもともとあり、その要素のdata属性に name='Joe' が追加されることを想定しているケース。JSの実行時間が長い、もしくはサーバーサイドのレスポンスが遅いなどの理由でdata属性への変更が起こる前にクエリが実行されてしまうとテストが失敗する。

Goodのコードを使えばfindが要素が見つかるまで一定時間待ってくれる。

22 of 52

2)findと異なる待ち方をするfinderメソッド

  • Capybara::Node::Finders#all
    • find_allはallのエイリアス
    • firstも内部でallを呼んでいる
  • クエリ生成はfindと一緒だが、クエリ実行時にElementNotFoundã‚’raiseすることはない
    • allの場合は何もマッチしないと空配列を返す
    • firstは1つも該当しなかったら例外を出すが、1つでも見つかったらそれでOK
  • 「all使ったから指定したセレクタの要素が全部返ってくる」という保証はない

23 of 52

このあたりの元ネタ

  • Write Reliable, Asynchronous Integration Tests With Capybara
    • 2014年に書かれた記事(最終更新は2019年)
    • 先ほどのbad/goodのコードは本記事から引用
  • この記事ではfind等の待ち方に関する解説が薄く、紹介されているbad/goodの違いがわかりづらいのが難点だなと感じていた
    • 実はこれが今回発表しようと思ったきっかけ
  • さすがに古くなっていて挙動が最新と違う点もあるが、bad/goodコードは今でも使えるノウハウ
    • 不安定なテストに遭遇したらいつも読み直してます

24 of 52

3)制限時間を超える実行時間

  • Capybara.default_max_wait_time
    • これがfindがクエリ実行を繰り返す場合の制限時間(待ち時間)
    • デフォルトで2秒(秒単位の数値で指定)
  • 2秒では終わらない処理があるような場合、この待ち時間を伸ばす

# spec/rails_helper.rb などでsystem spec全体で待ち時間を延ばす

Capybara.default_max_wait_time = 5

# 特定の要素について秒数指定で待つ

find '.something', wait: 10

# 特に根拠無く待ち時間を延ばしたい場合は整数値を掛けると

# 「適当に増やしてるんだな」感がでるかもしれない

find '.something-red', wait: Capybara.default_max_wait_time * 3

25 of 52

落ちづらいテストを書かないために

  • findの挙動を知る
  • 正しく要素をfindする
  • ユーザー目線で起こる状態変化をチェックする

26 of 52

個人的な不安定テストの頻出例

ブログ記事登録のsystem specで登録してからDBの中身をチェックするようなケース

# system specの一部

fill_in "タイトル", with: "CI不安定で切ない"

click_on "登録"

# 登録ボタンを押したときに非同期処理だとBlogEntryの追加が完了していない可能性があるが、

# 開発環境だと高速に処理が行われるので失敗することがほとんどない

expect(BlogEntry.last.title).to eq "CI不安定で切ない"

※system specでActiveRecordインスタンスの値をチェックすることの是非に議論はあるかもしれないが、本発表では対象外とする。

27 of 52

ユーザーへの表示を待つ

flashなどで表示されるメッセージやメッセージが含まれる要素を待つことで、DBにデータがある状態が保証される

fill_in "本文", "CI不安定で切ない"

click_on "登録"

expect(page).to have_css(".flash.notice")

expect(page).to have_content("登録が完了しました")

expect(BlogEntry.last.body).to "CI不安定で切ない"

28 of 52

疑問

ユーザ通知がすぐ消える(見えなくなる)ときも大丈夫?

29 of 52

findの挙動まとめ(また出たな!)

  • findは渡されたセレクタにマッチする要素を見つけるためのクエリを作る
  • まずクエリを実行してElementNotFound以外の例外が来たらそのままraiseする
  • ElementNotFoundで制限時間(後述)以内だったら0.01秒寝てから再度クエリを投げることを繰り返す
  • 制限時間を超えていたら例外をraiseする
    • 最後の実行がそれまで通り終了したらElementNotFoundã‚’raiseすることになる
  • 登録処理が非同期であればこの初回クエリ実行のほうがflashが表示される(サーバーサイドの処理が終わる)より先に実行される可能性のほうが高い。

  • (待ち時間超えない限りは)flashが表示されるまでfindが待ってくれる。
  • 人間が認知するために必要な表示時間はコンピュータに取っては十分すぎる時間

30 of 52

ユーザーに対する確認表示をひたすらチェック

  • 同期・非同期にかかわらず、状態遷移の確認はユーザーに対して表示される通知及び通知を含むDOM要素をチェックする
    • 現時点で同期的処理していても将来的に非同期処理になることもある
  • 要所要所でチェックする習慣をつけることで、不安定テストが生じる可能性を下げることができる。
    • かつ、ユーザー目線でのsystem spec実装になってくる

31 of 52

セレクタ力を高めて不安定テストを減らす

  • findしたい要素を一意に指定するにはセレクタの記述力が求められるケースがある
  • CSSセレクタを学ぶ(学び直す)
    • 知らなかった便利セレクタが実装されていたりするかも
    • caniuse.comで実装されているブラウザのバージョンを把握する必要あり
  • XPathも必要に応じて使う
    • xpathのほうが簡単なケースもあるため(親要素見つけるときとか)

32 of 52

sleep() less

  • ここまで紹介してきた事項を組み合わせることでsleep()は消せる
  • 「そもそもsleep使わなくていいのが普通」のほうがよくて、頻繁に利用するものではないという認識が良さそう
  • 参考資料:ちょうどよいRails E2E test
    • アプローチによりsleep使ってないという実例
    • 不安定テスト対策については言及されていないが、ユーザー目線での実装についてはアプローチが共通していて、かつこちらのほうがより説明が充実している

33 of 52

sleep()は再現手法として使う

  • 待ち時間超過を手元で再現するのにsleepは有効
    • サーバーサイドで sleep(3) とかdefault_max_wait_timeより大きい数値を指定するだけ
  • CIでこけたテストが「これ待ち時間切れたかなぁ」というときに手軽に検証できる
    • 闇雲にsystem spec側で待ち時間を増やさなくて済む

34 of 52

wait_for_ajaxメソッドについて

  • Automatically Wait for AJAX with Capybara で紹介されている手法
    • 古くからあるRailsプロジェクトだと実装されているかも
  • jQuery前提の実装
    • かつ、jQueryをサーバーサイドとの通信に使っていなければ使えない
    • そもそも最近のjQueryで動くんですかね...(検証してない)
  • (仮に動いても)今後は使わないほうがいい
    • ここまでで紹介した手法で置き換えが可能

35 of 52

Seleniumの詳細な動作を知りたい場合

Selenium::Webdriver::Loggerが環境変数のDEBUGを見て標準出力に詳細ログを出してくれる(!?)

https://github.com/SeleniumHQ/selenium/blob/6f36f8eff770eb3329a7ad49d9bc2c411f4983a5/rb/lib/selenium/webdriver/common/logger.rb#L143-L143

これは...

DEBUG=true bin/rspec path/to/some_spec.rb

36 of 52

(おまけ)

不安定テスト対策の便利設定

37 of 52

不安定テストの防止・検出に役立つ設定

特にCI環境ではやっておきたい。

  • CSSアニメーションを切る
  • assets:precompileは先に走らせておく
  • ブラウザコンソールのログをチェックする

38 of 52

CSSアニメーションを切る

CSSアニメーションの実行速度が影響してテスト実行結果が不安定になることを防止する

# in spec/rails_helper.rb

Capybara.disable_animation = true

Capybara::Server::AnimationDisabler でHTMLヘッダーにアニメーションを止めるスタイル指定を埋め込んでいる。

この設定では不十分という場合は上記設定を使わず、自前でテスト実行時に使うアニメーション無効化するためのCSSを用意してtest実行時だけそれを読み込むようにすれば良い。

39 of 52

assets:precompileを先に走らせる

# CI上で bin/rspec 実行前に走らせる

RAILS_ENV=test bin/rails assets:precompile

最初のsystem specが実行される前にassets:precompileを走らせておき、初回に実行されるテストでのロード時間延長などによる不安定な結果やタイムアウトを防ぐ。

CI上でassets:precompileを実行するため、staging/production環境にデプロイする前の動作確認にもなる。

40 of 52

(オプション)

config.assets.compileの無効化

# in config/environments/test.rb

if ENV[‘CI’]

config.assets.compile = false

end

加えて、config.assets.compileを無効化することでassets:precompileした後で参照できないassetsがあるとそのままテストが落ちてくれる。

41 of 52

ブラウザコンソールのログをチェックする

  • JSの実行時エラー・警告などが出ていないかをチェックすることで、JSが理由でテストが失敗しているケースに気づきやすくする。
  • 最初にCapybaraで利用するdriverの設定が必要であるため、やや手間がかかる。
  • capybara-chromedriver-logger を利用したり、このgemの中身を見て既存の設定と上手く合わせるようにするとスムーズかもしれない。

42 of 52

capybara-chromedriver-logger

READMEにある画像(以下)のようにブラウザのコンソールに表示されるログが出る。

ここからcapybara-chromedriver-logger を使った場合のtipsを紹介。使わない場合でもログ出力する場合は共通する話のはず。

43 of 52

ログのノイズを減らす

Vue.config.devtools = false;

Vue.config.productionTip = false;

運用上、通常は一切ログがでないことを前提として、ログが出たらすべてfailさせるようにしないと、CIの実行時ログを追わないといけないため、気づきづらい。

たとえばVue.jsだとDevToolsやproduction向けのtipsなどが表示されるので、assets:precompileの時点などで表示しないようにしておきたい。

44 of 52

filterを活用する

# spec/rails_helper.rb

Capybara::Chromedriver::Logger.filters = [

/the server responded with a status of 422/

]

JSライブラリが出すログを抑制するオプションがなかったり、HTTPステータスコードで4xxが返ってくるケース等ではログが出てしまうのでfilterを使って抑制する。

rspecのexample毎に制御したい仕組みを作る場合はbefore/after hooksを組み合わせたりと追加の手間がかかる。

45 of 52

本日のまとめ

46 of 52

不安定テストの防止・検出に役立つ設定

落ちづらいテストを書かないためのポイント

  • CSSアニメーションを切る
  • assets:precompileは先に走らせておく
  • ブラウザコンソールのログをチェックする
  • findの挙動を知る
  • 正しく要素をfindする
  • ユーザー目線で起こる状態変化をチェックする

47 of 52

最後に

48 of 52

本日のタイトル

49 of 52

不安定テストを生み出すCapybaraを調教する

🙄❓

50 of 52

Capybara全然悪くない!!

51 of 52

調教が必要なのは我々だった

52 of 52

ご清聴ありがとうございました