LoginSignup
48
54

【RSpec】フレーキーなテスト(たまに落ちるテスト)の直し方

Last updated at Posted at 2024-04-08

はじめに

自動テストを整備しておくと大量のテストを自動実行してくれるので大変便利です。
ですが、テストコードが増えてくると「パスするはずなのに、なぜかたまに失敗する」というテストが出てきます。
このような不安定なテストを「フレーキー(flaky)なテスト」と呼びます。

フレーキーなテストの問題点

フレーキーなテストは「たまに失敗するだけ」なので、何度かやり直せばパスします。
なので、GitHub ActionsのようなCIツール上でテストが落ちても、「あ、また落ちた。再実行したら直るかな(ポチッ)」という安易な解決策に走りがちです。

しかし、フレーキーなテストを放置するのはよくありません。
理由は以下の通りです。

  • 本当はバグのせいで失敗しているのに「きっとフレーキーなテストだからに違いない」と思い込んで、そのままリリースしてしまうから(いわゆる「狼少年」状態)
  • 実務レベルの巨大なテストコードになると、再実行するにしても完了まで数十分かかるので開発のテンポが遅くなるから
  • 1回の再実行で全パスする保証はどこにもなく、再々実行や再々々実行しないとパスしない可能性もあるから(テストAは再実行でパスしたが、今度は別のテストBが落ちた、みたいなことも起こりがち)
  • CIツールを何度も実行すると、その都度どんどん課金されていってしまうから

僕のこれまでの経験上、フレーキーなテストにはフレーキーになってしまう原因が必ずあります。
そして、原因がわかればそれを解決する方法、つまり「百発百中でパスするテストに直す方法」があります。

「再実行すればパスするから」が常態化していませんか?

ですが、原因を見つけ出すのには時間がかかるので、「今開発の手を止めてテストが落ちる原因を調査する苦労」と「再実行する手軽さ」を天秤にかけると、人間はどうしても後者を選びがちです(落ちたテストが自分の書いたテストコードでなければなおさらですよね)。

しかし、それを繰り返すとどんどんフレーキーなテストが増えていき、最後には「CIのテストは落ちるのが当たり前。一発で全パスすれば超ラッキー」という良くない状況に陥ります。

<あなたのCIツール、こんな状況になってませんか?>
ci.jpg

そこで、この記事では僕がこれまでに経験した、「こんな理由でテストがフレーキーになっていた」「それをこんなふうに直した」という事例をまとめていきます。

想定する実行環境

この記事では以下の実行環境を想定しています。

  • webアプリケーションフレームワーク=Ruby on Rails
  • テスティングツール=RSpec

ですが、テスティングツールにminitestを使っている場合や、Rails以外のwebアプリケーションフレームワークを使っている場合でも基本的な考え方は同じはずです。

ストックしてね!

この記事の事例は必要に応じて今後追記していく予定です。
「新しい事例が知りたい」と思った人はぜひこの記事をストックしてください。
新しい事例を追加したときにQiitaの通知欄でお知らせします!

Screenshot 2024-04-09 at 8.22.15.png

それでは以下が本編です。

💀 JSの実行を待っていない

最近のUIはJavaScriptを活用したリッチなUIやSPA(Single Page Application)が増えてきています(例:Vue.jsやReact)。
またUIをリッチにする目的ではなく、画面表示のパフォーマンスを向上させるために人知れずJSが活用されている場合があります(例:Turbo)。

が、これらの技術は往々にしてシステムスペック(E2Eテスト)との相性が悪いです。

たとえば以下のような画面とテストがあったとします。

Screenshot 2024-04-06 at 17.32.27.png

example "日報の編集" do
  # トップページを開く
  visit root_path
  # 日報一覧を開く
  click_on "日報"
  # 日報の編集リンクをクリックする
  click_on "編集"
  # 編集画面に遷移したことを検証する
  expect(page).to have_content "日報の編集"
end

が、上のような単純なテストですら、JSが活用されていると場合によっては失敗する可能性があります。

テストが失敗するケース

Rails 7以降ではTurboがデフォルトで有効になっているため、「日報のリンクをクリックしてから日報一覧を表示するまで」の画面遷移にもJS(Turbo)が使われます。

そのため、システムスペックの実行に実際のブラウザ(Chromeなど)を使っていると、JSの処理が完了する前(=日報一覧を表示する前)に click_on "編集" が実行されることがあります。
このとき、同じ画面に表示されている「アカウント編集」ボタンの「編集」がクリックされてしまい、意図せずアカウント編集画面が表示されてテストが落ちます。

Screenshot 2024-04-06 at 17.32.27.png

example "日報の編集" do
  visit root_path
  click_on "日報"
  # Turboの実行が遅れると、日報一覧が表示される前に
  # 「アカウント編集」ボタンがクリックされる
  click_on "編集"
  # アカウント編集画面が開くのでテストが落ちる
  expect(page).to have_content "日報の編集"
end

✅ 修正方法 1

JSの処理が終わったこと、つまりちゃんと日報一覧画面に遷移したことを確認し、それから click_on "編集" を実行しましょう。
具体的には以下のようなテストを書きます。

example "日報の編集" do
  visit root_path
  click_on "日報"

  # ページ内の見出しに「日報の一覧」が表示されるのを待つ
  expect(page).to have_selector "h1", text: "日報の一覧"

  # 完全一致するリンクを優先的にクリックする
  # (「アカウント編集」ではなく、日報の「編集」がクリックされる)
  click_on "編集"
  expect(page).to have_content "日報の編集"
end

Screenshot 2024-04-06 at 17.48.01.png

✅ 修正方法 2

Capybaraはデフォルトで部分一致したリンクもクリック可能なリンクと見なします。
click_on "編集"で「アカウント編集」がクリックされてしまうのはそのためです。

ただし、exact: trueオプションを付けると完全一致したリンクだけが対象になります。
また、「編集」リンクが表示されるまで、Capybaraは処理を待ってくれます。
これにより、「アカウント編集」が誤ってクリックされる事故を防げます。

# 完全一致する「編集」リンクをクリックする(「アカウント編集」はクリックされない)
# 画面に「編集」リンクが見つからなければ、表示されるまでしばらく待ってくれる
click_on "編集", exact: true

Screenshot 2024-04-06 at 17.48.01.png

sleepはなるべく避ける

「JSの完了を待つ」と聞くと、「1秒ぐらいsleepさせたらいいのでは?」と思う人もいるかもしれません。

click_on "日報"

# ❌ JSの処理が完了し、日報一覧が表示されるのを待つ  
sleep 1

click_on "編集"

たしかにこれでもテストはパスしますが、sleepするのは1秒がいいのか、0.5秒がいいのか、5秒がいいのか、ベストな秒数がわかりません。
そもそも、テストを実行するマシンのスペックや、そのときの負荷状況、実行される処理の複雑さ等々の要因で待つべき秒数は変わってきます。

また、あちこちにsleepを埋め込むとチリツモでどんどんテスト全体の実行時間が延びてしまいます。

Capybaraに待たせる(お勧め)

なので、代わりにCapybaraに待ってもらいましょう。
こうすれば必要最小限の待ち時間で済みます。

# ✅ 画面に"こんにちは"が表示されるまで待つ  
expect(page).to have_text "こんにちは"

デフォルトでは最大2秒待ってくれます。
2秒待ってもダメな場合はテストが失敗します。

デフォルトの待機時間は設定で変更できます。

# 最大5秒待つ
Capybara.default_max_wait_time = 5

waitオプションで個別に指定することもできます。

# ここだけ最大5秒待つ
expect(page).to have_text "こんにちは", wait: 5

have_textに限らず、have_linkhave_selectorなど、have_で始まるマッチャはだいたい待ってくれます。

他にも、find_linkclick_onなども表示を待ってくれます。

# 画面に"編集"リンクが表示されるまで待つ
link = find_link("編集")
# "保存"ボタンまたは"保存"リンクが表示されるまで待つ
click_on "保存"

こういったテクニックを使って「JSの実行が完了したときに発生する画面の変化」をCapybaraに待たせるようにするとテストが安定します。

コラム:デバッグテクニックあれこれ

システムスペックが失敗すると自動的にそのときのスクリーンショットが保存されるので、その画像を見るとテストが失敗した原因を推測できるかもしれません。
(実行結果の[Screenshot Image]にスクショの保存先のパスが載っています)

  0) Tasks user toggles a task
     Failure/Error: expect(page).to have_css "label.completed", text: name
       expected to find css "label.completed" but there were no matches

     [Screenshot Image]: /path/to/your-app/tmp/capybara/failures_r_spec_example_groups_tasks_user_toggles_a_task_151.png

もしくはsave_screenshotメソッドを使って明示的にスクショを保存することもできます。
(スクショはデフォルトでtmp/capybaraに保存されます)

click_on "日報"
# "日報"リンクをクリックした直後のスクショを保存する
save_screenshot

ヘッドレスモードでブラウザを動かしているときは、非ヘッドレスモード、つまり通常のブラウザモードにしてブラウザの動作を目視で確認できるようにしてみましょう。

 RSpec.configure do |config|
   config.before(:each, type: :system) do
     driven_by :rack_test
   end
 
   config.before(:each, type: :system, js: true) do
     # 目視で確認したいので通常のブラウザモードでシステムスペックを実行する
     # (デバッグが終わったら元のヘッドレスモードに戻すこと!!)
-    driven_by :selenium_chrome_headless
+    driven_by :selenium_chrome
   end
 end

ただし、目視で確認しようとしても目にも止まらぬ速さで実行されてしまうので、必要に応じてsleepを挟むのもアリです。

click_on "日報"
# ブラウザ操作が速すぎて付いていけないのでここで10秒間停止する
# (デバッグが終わったら必ず削除すること!!)
sleep 10

💀DBから取得したデータの並び順が不定

orderを指定しない場合、DBから返ってくるデータの戻り順は不定です。
たとえば、以下のメソッドを実行したとき、

User.pluck(:name)

毎回 "Alice", "Bob", "Carol" の順番で返ってくるからといって、

# ❌ NG!! 
expect(User.pluck(:name)).to eq ["Alice", "Bob", "Carol"]

のようなテストを書いてはいけません。
100回これでパスしたとしても、101回目でテストがコケる可能性があります。

✅ 修正方法 1

並び順が重要なテストならorderを指定しましょう。

# ✅ OK 
expect(User.order(:name).pluck(:name)).to eq ["Alice", "Bob", "Carol"]

ただし、orderは必ず一意になるようにしてください。
たとえば"Alice"が2人いるようなデータだと、以下のようなテストは失敗する可能性があります。

# ❌ NG (nameだけでは順番が一意に決まらない)
expect(User.order(:name).pluck(:id)).to eq [alice_1.id, alice_2.id, bob.id]

以下のようにnameに加えて、データの並びが一意になる条件(たとえばidなど)を指定してください。

# ✅ OK(名前が同じ場合はid順にして一意性を担保する)
expect(User.order(:name, :id).pluck(:id)).to eq [alice_1.id, alice_2.id, bob.id]

✅ 修正方法 2

並び順は重要ではないが、過不足なく要素が含まれていることを検証したい、という場合は contain_exactly を使いましょう。

# ✅ OK(要素に過不足がないことを検証する。並び順は変わっても構わない)
expect(User.pluck(:name)).to contain_exactly("Alice", "Bob", "Carol")

[n]にも注意

同じ理屈で以下のようなテストもNGです。

# ❌ NG
users = User.all
expect(users[0].name).to eq "Alice"
expect(users[1].name).to eq "Bob"
expect(users[2].name).to eq "Carol"

こういった場合もやはりorderの指定が必要です。

# ✅ OK 
users = User.order(:name)
expect(users[0].name).to eq "Alice"
expect(users[1].name).to eq "Bob"
expect(users[2].name).to eq "Carol"

備考 1:実務レベルのテストコードはもっと複雑

上で挙げたようなコード例はいずれも「ぱっと見れば間違いがわかるレベル」の単純なものですが、実務のテストコードはもっと複雑なテストになると思います。

「テスト対象のメソッドが呼び出しているメソッド、が呼び出しているメソッド、が呼び出しているメソッド、が呼び出しているメソッド」でorderを指定しないといけなかった、みたいなこともよくあります。

ロジックをじっくり追いかけて、真犯人(orderを指定していないデータ取得メソッド)を見つけ出してください。

備考 2:システムスペックでも同じ問題は起きる

上のコード例はモデルスペックをイメージして書きましたが、システムスペック(E2Eテスト)でも考え方は同じです。

たとえば、以下のようなテストはUserの並び順を指定していないと失敗する可能性があります。

visit users_path
user_containers = all('.user')

# User一覧の並び順が一意に指定されていれば毎回パスするが、そうでなければたまに落ちる
within user_containers[0] do
  expect(page).to have_text "Alice"
end
within user_containers[1] do
  expect(page).to have_text "Bob"
end
within user_containers[2] do
  expect(page).to have_text "Carol"
end

あわせて読みたい

💀 動的に決まる連番にテストが依存している

FactoryBotではsequenceという機能を使って、連番の名前を作成することができます。

FactoryBot.define do
  factory :company do
    sequence(:name) { |n| "会社 #{n}" }
  end
end

だからといって以下のようなテストを書いてはいけません。

let(:company) { FactoryBot.create(:company) }
example do
  # ❌ NG!!
  expect(company.name).to eq "会社 1"
end

実務レベルの長く複雑なテストになってくると、たまたま"会社 2"がDBに保存されていてテストが落ちる、みたいなことが発生します。

✅ 修正方法

検証したい項目はFactoryBotに自動生成させるのではなく、静的な値を指定して生成しましょう。

# 静的な会社名を指定する
let(:company) { FactoryBot.create(:company, name: "ソニックガーデン") }
example do
  # ✅ OK(静的な値同士であればズレる心配がない)
  expect(company.name).to eq "ソニックガーデン"
end

あわせて読みたい

静的な値をベタ書きするとテストが安定するだけでなく、テストコードの可読性も向上します。

💀 ゼロ詰めしていない連番で並び替えしている

上の例と同じく、sequenceで会社名が決まるファクトリがあったとします。

FactoryBot.define do
  factory :company do
    sequence(:name) { |n| "会社 #{n}" }
  end
end

この場合、以下のようなテストを書くとテストが落ちる場合があります。

# ❌ NG!!
example "名前順に会社が並ぶ" do
  company_1 = FactoryBot.create(:company)
  company_2 = FactoryBot.create(:company)
  company_3 = FactoryBot.create(:company)
  expect(Company.order_by_name).to eq [company_1, company_2, company_3]
end

どういう場合に落ちるのかというと、以下のような連番になっていた場合です。

company_1.name #=> 会社 9
company_2.name #=> 会社 10
company_3.name #=> 会社 11

この場合、名前順で並び替えると「"会社 10", "会社 11", "会社 9"」の順、つまり「company_2, company_3, company_1」の順で並ぶことになり、テストが失敗します。

✅ 修正方法

FactoryBotに名前を自動生成させるのではなく、静的な名前を指定してデータを生成しましょう。

# ✅ OK(静的な名前を指定してデータを生成する)
example "名前順に会社が並ぶ" do
  company_1 = FactoryBot.create(:company, name: "会社 1")
  company_2 = FactoryBot.create(:company, name: "会社 2")
  company_3 = FactoryBot.create(:company, name: "会社 3")
  expect(Company.order_by_name).to eq [company_1, company_2, company_3]
end

お勧めしない修正方法

別の修正方法として、「"会社 09", "会社 10", "会社 11"」のように、ゼロ詰めした名前をFactoryBot側で生成するようにする方法もあります。

FactoryBot.define do
  factory :company do
    # 会社 01, 会社 02 ...のようにゼロ詰めした会社名を生成する(が、いまいち)
    sequence(:name) { |n| "会社 #{n.to_s.rjust(2, "0")}" }
  end
end

たしかにこの方法でも直りますが、「"会社 99"、"会社 100"」のように桁が溢れてしまったときに同じ問題が発生します。

「じゃあ、"会社 00001"なら絶対大丈夫でしょ!」という声が聞こえてそうですが、テストが暗黙的に決まる値に依存しているとテストコードの可読性が低下します。
より具体的に言えば、ファクトリの定義を見に行かないと、どんなデータが作られているのかわかりません。

example "名前順に会社が並ぶ" do
  # ❌ NG!!(ファクトリの定義を見に行かないとどんなデータが作られているのかがわからない)
  company_1 = FactoryBot.create(:company)
  company_2 = FactoryBot.create(:company)
  company_3 = FactoryBot.create(:company)
  expect(Company.order_by_name).to eq [company_1, company_2, company_3]
end

テストコード内で静的な値を指定すれば、ファクトリの定義を見に行く必要がありません。

# ✅ OK(静的な値を指定すれば、ファクトリの定義を見に行く必要がない)
example "名前順に会社が並ぶ" do
  company_1 = FactoryBot.create(:company, name: "会社 1")
  company_2 = FactoryBot.create(:company, name: "会社 2")
  company_3 = FactoryBot.create(:company, name: "会社 3")
  expect(Company.order_by_name).to eq [company_1, company_2, company_3]
end

こういった理由から、テストを実行する上で並び順が重要になる場合は、静的な値をベタ書きすることをお勧めします。

💀 JSの実行を待っているようで待っていない

JSを利用してプルダウンメニューを表示するようなUIの場合、「リンクをクリックしてからプルダウンが表示されるまで」をexpectを使って待つ必要があります。

Screenshot 2024-04-06 at 16.16.49.png

expect "プルダウンメニュー内のリンクが正しい" do
  # トップページを開く
  visit root_path
  # "LINKS"をクリック
  click_link "LINKS"
  # プルダウンが開くのを待つ
  # (プルダウン内の"INSTAGRAM"が表示されたら開いたと見なす)
  expect(page).to have_text "INSTAGRAM"
  # プルダウン内のリンクは全部で6件
  links = all(".dropdown-menu a")
  expect(links.size).to eq 6
end

ところが、上のようなテストを書いたとき、links.sizeが0になってテストが失敗する場合があります。

どういうときに落ちるのかというと、expectで表示を待とうとしている文言と同じ文言がすでに画面に表示されている場合です。

Screenshot 2024-04-06 at 16.24.02.png

# プルダウン内の"INSTAGRAM"ではなく、元から表示されている"INSTAGRAM"にマッチする
expect(page).to have_text "INSTAGRAM"

# プルダウンがまだ開いていないので、links.sizeが0になってテストが落ちる
links = all(".dropdown-menu a")
expect(links.size).to eq 6

✅ 修正方法

expectでJSの実行を待つ場合は、「実行する前は確実に表示されていない要素」が表示するのを待ちましょう。

今回のようなケースであれば、プルダウンメニューの枠に相当する.dropdown-menuの表示を待つのが確実です。

Screenshot 2024-04-06 at 16.36.00 (2).png

expect "プルダウンメニュー内のリンクが正しい" do
  visit root_path
  click_link "LINKS"
  
  # プルダウンが開くのを待つ
  # (プルダウンの枠が表示されたら開いたと見なす)
  expect(page).to have_css ".dropdown-menu"
  
  links = all(".dropdown-menu a")
  expect(links.size).to eq 6
end

コラム:僕が「修正せねば」と考える基準

冒頭で僕は「失敗したテストの再実行が常態化するのはよくない」と書きましたが、全く再実行しないのかというとそういうわけではありません。

僕だって「あれ、なんかCIが落ちたな?でもローカルではパスしてるな。ちょっと再実行するか(ポチッ)」をやることもあります(人間だもの)。

ですが、あまりひどいときは「これは直した方がいいね」と考えて修正にとりかかります。
それは 「あ、あなたまたお会いしましたね。これで3回目?」 と思ったときです。

「このテスト、前も落ちてたぞ?」としっかり記憶に残るようなテストはかなり失敗する率が高いテストなので、早めに修正した方がいいです。

しかし、あっちこっちで毎回何らかのテストが落ちていると、どれが初めてで、どれが前も落ちてたのか区別が付かなくなります。
これはプロジェクトとして赤信号です🚨

僕の中での許容範囲は 「CIのテストが落ちるのは5回につき1回まで」 です。
それ以上の頻度で落ちるようなら自動テストの信頼性がかなり低下しているので、開発の手を止めてフレーキーなテストの修正に時間を割いた方が良いと思います。

まとめ

というわけでこの記事ではフレーキーなテストが発生する原因と、その修正方法をまとめてみました。

この記事を参考にして、ぜひ「CIのテストは全パスしてるのが当たり前」という状態を目指してください。

<目指せ、常時オールグリーン✅✅✅>
ci2.jpg

ストックしてね!(再)

この記事の事例は必要に応じて今後追記していく予定です。
「新しい事例が知りたい」と思った人はぜひこの記事をストックしてください。
新しい事例を追加したときにQiitaの通知欄でお知らせします!

Screenshot 2024-04-09 at 8.22.15.png

PR:Railsでテストコードが書けるようになりたいという人へ

フレーキーなテストうんぬんの前に、そもそもテストコードの書き方に自信がありません!😫 という方へ。

僕が翻訳した電子書籍「Everyday Rails - RSpecによるRailsテスト入門」では、Railsでテストコードを書いたことがないという人に向けて、テストコードの書き方を優しく詳しく説明しています。
RSpecの使い方だけでなく、FactoryBotの使い方も載っています。

まだ読んだことのない方はぜひ一度チェックしてみてください!

48
54
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
48
54