Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第28章 Hotwire のテスト

この章のねらい

第7部までで、Relay は管理画面の基本機能を備えた教材になりました。第8部では、動いた後のアプリを保守します。最初はテストです。

この章のねらいは、テストの戦略を立てることです。何を System Test で守り、何をモデルやリクエストのテストに委ねるか。その配分を決め、第3〜7部で各章に書いてきた小さなテストを、その中に位置づけます。

この部を貫く軸は「Hotwire は、遅い Rails も危ない Rails も隠してくれない」です。Hotwire は Rails の上の薄い層なので、テストも「Rails のテスト+Hotwire 固有の観察点」になります。

28.1 なぜ System Test が重要か

Hotwire の動きは、部分更新の積み重ねです。フォームを送ると一覧が prepend され、件数が変わり、フラッシュが出る。これらは、ブラウザの上で JavaScript(Turbo・Stimulus)が動いて初めて成立します。

サーバー側のテストだけでは、ここを確かめきれません。コントローラが正しい Turbo Streams を返しても、それがブラウザで正しく適用されるか、Stimulus のコントローラが結びついて動くか、までは見られないからです。

だから、ブラウザを動かす System Test が要ります。System Test は、実際のブラウザ(ヘッドレスの Chrome など)で画面を操作し、Hotwire の結合部を確かめます。

28.2 テストの配分

とはいえ、何でも System Test で確かめるのは重すぎます。System Test は遅く、壊れやすいからです。テストは、層で分けて配分します。

  • モデルのテスト … バリデーション、enum、検索のスコープなど、サーバー側のロジック。速くて確実なので、ここで手厚く。
  • リクエストのテスト … コントローラが返すステータスや形式。422 が返るか(第8章)、turbo_stream 形式で応答するか、検索パラメータで正しく絞り込むか(第23章)。条件の組み合わせは、ここで網羅。
  • System Test … 上の積み重ねが、ブラウザで結合して動くか。インライン編集の差し替え、Streams の追加、Stimulus の振る舞いなど、JavaScript が絡む経路に絞る。

第7部までで各章に書いてきた小さなテストは、この配分の中に収まります。「条件は下の層で、結合は System Test で」と分けると、軽く確実なテストになります。

テストの配分(ピラミッド)。下からモデル(多い・速い)・リクエスト(中)・System(少ない・遅い)を積み、Hotwire の結合を System で守ることを示すピラミッド図

28.3 Turbo Drive のテスト

Turbo Drive の確認は、第8章で書いたフォーム送信のテストが代表です。

  • 成功すると詳細へ遷移し、成功メッセージが出る
  • 失敗すると、ページ遷移せず、同じ画面にエラーが出る(422)

「ページ遷移したか/しなかったか」が、Turbo Drive のテストの肝です。失敗時にページ遷移していないことを確かめると、422 の契約が効いていると分かります。

28.4 Frames のテスト

Turbo Frames の確認は、第12章のインライン編集が代表です。

within "##{dom_id(task)}" で frame の中に操作を絞り、編集して保存し、その frame が表示に戻ったことを確かめます。このとき、編集フォームが消えたこと(assert_no_field)まで見るのが大事でした(第12章)。frame の中だけが差し替わり、ほかが動いていないことが、Frames のテストの肝です。

28.5 Streams のテスト

Turbo Streams の確認は、第16章・第17章が代表です。

  • 作成すると、一覧の先頭にタスクが追加される(ページ遷移なし)
  • 削除すると、一覧から消える
  • 件数や空状態が、同時に更新される

within "#tasks" で一覧の中を確かめ、assert_no_text で削除を確かめます。複数箇所が同時に変わることを、それぞれの領域で確認します。

28.6 Stimulus の振る舞いを確認する

Stimulus の確認は、第20章の文字数カウンタが代表です。入力すると、文字数の表示が変わる。これはブラウザで JavaScript が動かないと起きないので、System Test で確かめます。

fill_in で入力し、assert_selector で表示が変わったことを見ます。Stimulus が要素に結びついて動いていることが、これで分かります。

28.7 非同期更新を待つ

System Test でいちばん大事なのが、非同期更新の待ち方です。Hotwire の更新は、リクエストの往復を挟むので、操作した瞬間には終わっていません。

ここで sleep を使ってはいけません。待ち時間は環境で変わり、長すぎれば遅く、短すぎれば失敗するからです。

代わりに、Capybara の待つマッチャを使います。assert_selector や assert_text、assert_no_text は、条件が満たされるまで(既定の待ち時間まで)自動で待ち、再試行します。

click_on "保存"
within "#tasks" do
  assert_text "新しいタスク"   # 追加されるまで自動で待つ
end

assert_text が、タスクが追加されるまで待ってくれます。削除の確認も、assert_no_text が「消えるまで待つ」ので、sleep は要りません。

28.8 壊れやすいテストを避ける

最後に、フレーク(不安定なテスト)を避ける勘所です。

  • 更新の前に assert しない。操作の直後、待つマッチャを使わずに値を読むと、まだ更新前で偶然通ったり落ちたりします。必ず、待つマッチャ(assert_selector など)で「変わったこと」を確かめます。
  • broadcast は同期して確かめる。Action Cable のリアルタイム更新(第18章)は、配信が非同期になりがちで、System Test では特に不安定です。配信の中身(どんな Turbo Streams を流すか)は、モデルやリクエストのテストで確かめ、System Test では「2 つのセッション(Capybara の using_session)を開いて、片方の操作がもう片方に反映される」ような結合だけに絞ると、安定します。非同期ジョブ(*_later)を使うなら、テストでジョブを実行させてから確かめます。

第28章では、テストを層で配分し、Hotwire の結合を System Test で守る戦略を立てました。次の第29章では、テストでは捉えきれない不具合を追うための、デバッグとイベント観察の道具を整えます。

28.10 完成版の回帰テストへ更新する

共通基底も完成版では購読完了の待機を追加します。第5章の基底を次へ更新します。

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  include ActionView::RecordIdentifier
  driven_by :selenium, using: :headless_chrome, screen_size: [ 1200, 1000 ]

  Capybara.default_max_wait_time = 10

  teardown do
    errors = page.driver.browser.logs.get(:browser).select { |entry| entry.level == "SEVERE" && !entry.message.include?("422") }
    puts errors.map(&:message) if errors.any?
  end

  setup do
    visit new_session_path
    fill_in "email_address", with: users(:one).email_address
    fill_in "password", with: "password"
    click_on "Sign in"
    assert_selector "h1", text: "タスク"
    assert_current_path root_path
    assert_selector "turbo-cable-stream-source[connected]", visible: :all
  end
end

第8・12・16・20章のテストは、その時点の UI を確かめるものです。完成版では以下へまとめ、古いラベルや行の追加方式を前提にするテストは置き換えます。

test/system/tasks_test.rb

require "application_system_test_case"

class TasksTest < ApplicationSystemTestCase
  test "inline create and validation preserve the page" do
    page.execute_script("window.relayTestMarker = 'unchanged'")
    within "#new_task_form" do
      select projects(:one).name, from: "プロジェクト"
      click_on "保存"
      assert_selector ".error-summary"
      fill_in "タイトル", with: "System new task"
      click_on "保存"
    end
    within "#tasks" do
      assert_text "System new task"
      assert_selector "turbo-frame:first-child", text: "System new task"
    end
    assert_equal "unchanged", page.evaluate_script("window.relayTestMarker")
  end

  test "modal errors stay inside dialog and success closes it" do
    click_on "新規作成(モーダル)"
    within "dialog[open]" do
      select projects(:one).name, from: "プロジェクト"
      click_on "保存"
      assert_selector ".error-summary"
      fill_in "タイトル", with: "Modal task"
      click_on "保存"
    end
    assert_no_selector "dialog[open]"
    within("#tasks") { assert_text "Modal task" }
    assert_selector "#new_task_modal_link:focus"
  end

  test "inline editing and deleting work" do
    task = tasks(:one)
    within "##{dom_id(task)}" do
      click_on "編集"
      fill_in "タイトル", with: "Edited inline", fill_options: { clear: :backspace }
      assert_field "タイトル", with: "Edited inline"
      click_on "保存"
    end
    assert_selector "##{dom_id(task)}", text: "Edited inline"
    assert_no_selector "#task_list[busy]"
    within "##{dom_id(task)}" do
      accept_confirm { click_on "削除" }
    end
    assert_no_selector "##{dom_id(task)}"
  end

  test "search and pagination preserve filters" do
    visit project_path(projects(:one))
    24.times { |n| projects(:one).tasks.create!(title: "Needle #{n}") }
    visit tasks_path
    page.document.synchronize do
      assert page.evaluate_script("!!window.Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller=search]'), 'search')")
    end
    fill_in "タイトルで検索", with: "Needle", fill_options: { clear: :backspace }
    assert_field "タイトルで検索", with: "Needle"
    within("#task_list") { assert_text "24 件該当" }
    click_on "次へ"
    assert_current_path tasks_path(q: "Needle", status: "", project_id: "", tag_id: "", mode: "pages", page: "2")
    within("#tasks") { assert_selector "turbo-frame", count: 4 }
  end

  test "more mode appends all pages without duplicate ids" do
    visit project_path(projects(:one))
    43.times { |n| projects(:one).tasks.create!(title: "More #{n}") }
    visit tasks_path(q: "More", mode: "more")
    within("#tasks") { assert_selector "turbo-frame", count: 20 }
    within("#pagination") { click_on "もっと読む" }
    within("#tasks") { assert_selector "turbo-frame", count: 40 }
    within("#pagination") { click_on "もっと読む" }
    within("#tasks") { assert_selector "turbo-frame", count: 43 }
    assert_no_selector "#pagination a"
    ids = all("#tasks turbo-frame").map { |node| node[:id] }
    assert_equal ids.uniq, ids
  end

  test "comments validate and append" do
    visit task_path(tasks(:one))
    within "##{dom_id(tasks(:one), :comment_form)}" do
      click_on "コメント"
      assert_selector "[role=alert]"
      fill_in "コメント本文", with: "System comment"
      click_on "コメント"
    end
    assert_text "System comment"
  end
end

HTTP ステータス、検索の % / _、HTML と Stream の形式はリクエストテストで検証します。System Test では JavaScript の印が残ること、対象行の表示・フォーム消去・URL・追記件数を確認します。配信を含む場合は購読完了を待ち、Cable のアダプタもブラウザに配信する設定にしてください。

参考資料