ActiveRecordテストの通知サブスクライバ手動実装を `NotificationAssertions` ヘルパーに統一
ActiveSupport::Notifications.subscribed を使った手動サブスクライバパターンを、NotificationAssertions ヘルパーに置き換えることで、ActiveRecordのテストコードが簡潔になりました。#53822 で始まったリファクタリングの続編にあたります。
背景
ActiveSupport::Testing::NotificationAssertions は、通知イベントの捕捉・検証を宣言的に行えるテストヘルパーモジュールです。従来、ActiveRecordのテストでは ActiveSupport::Notifications.subscribed にラムダを渡し、ローカル配列にイベントを蓄積するパターンが各所に散在していました。このパターンは機能的に問題はないものの、コールバック定義・ブロック・蓄積変数が混在してテストの意図が読み取りにくくなっていました。
#53822 がActiveRecordの主要テストに NotificationAssertions を適用した初回のリファクタリングPRであり、本PR #56989 はその後継として、対応が残っていた instrumentation_test.rb と load_async_test.rb の計6テストに同様の置き換えを適用しています。
技術的な変更
変更は大きく2つのパターンに分類される。ペイロードの内容を検査する必要があるケースには capture_notifications を、通知が発火したかどうかだけを確認するケースには assert_notification を使用しています。
capture_notifications + map/reject への置き換え
instrumentation_test.rb の test_payload_affected_rows では、ブロック内でイベントを蓄積していた手動サブスクライバが capture_notifications に置き換えられました。
変更前:
affected_row_values = []
ActiveSupport::Notifications.subscribed(
-> (event) { affected_row_values << event.payload[:affected_rows] },
"sql.active_record",
) do
Book.insert_all!([...], returning: false)
# ...
end
変更後:
events = capture_notifications("sql.active_record") do
Book.insert_all!([...], returning: false)
# ...
end
affected_row_values = events.map { |e| e.payload[:affected_rows] }
capture_notifications はブロック内で発火した全イベントを配列として返すため、ペイロードの抽出ロジックをブロックの外に分離できます。コールバック定義と蓄積変数の初期化が不要になり、テストの「何を検証しているか」が前面に出る構造になっています。
フィルタリングの外部化
test_payload_affected_rows_cascade では、コールバック内で SCHEMA と TRANSACTION イベントを除外していた条件分岐が、capture_notifications のブロック外の reject に移動されています。
変更前:
ActiveSupport::Notifications.subscribed(
-> (event) do
unless event.payload[:name].in? ["SCHEMA", "TRANSACTION"]
affected_row_values << event.payload[:affected_rows]
end
end,
"sql.active_record",
) do
# ...
end
変更後:
events = capture_notifications("sql.active_record") do
# ...
end
affected_row_values = events
.reject { |e| e.payload[:name].in?(["SCHEMA", "TRANSACTION"]) }
.map { |e| e.payload[:affected_rows] }
load_async_test.rb でも同様に、SCHEMA除外の条件がコールバック内から reject チェーンに移動されています。フィルタリングロジックが通知の購読から切り離されることで、どのイベントを対象にしているかが一目で把握できます。
assert_notification による存在確認の簡略化
test_load_async_instrumentation_is_thread_safe では、通知が発火したかどうかのフラグ管理が assert_notification に置き換えられました。
変更前:
notification_called = false
ActiveSupport::Notifications.subscribed(->(*) { notification_called = true }, "sql.active_record") do
Post.count
end
assert(notification_called)
変更後:
assert_notification("sql.active_record") do
Post.count
end
フラグ変数・サブスクライバ・後続アサーションの3点セットが、assert_notification の1ブロックに集約されています。
設計判断
capture_notifications と assert_notification を使い分ける アプローチが採用されています。ペイロードの具体的な値を検証する必要がある場合は capture_notifications でイベントを一括取得してから map/reject で後処理し、発火の有無だけを確認する場合は assert_notification を使うという明確な使い分けがPR本文に示されています。
この設計は、テストコードの意図をヘルパーの選択で表現するものです。assert_notification を使ったテストは「このブロックで通知が発火すること」を主張しており、capture_notifications を使ったテストは「発火した通知のペイロードを検証すること」を主張しています。ヘルパーの種類がそのままテストの目的を示すドキュメントになっています。
なお、フィルタリング処理をコールバック内からブロック外の reject に移したことで、全イベントが一度キャプチャされてから後処理される動作に変わっています。テストの検証結果は同等ですが、コールバック内での条件分岐という副作用的な実装が排除され、データ変換のパイプラインとして明示的に表現されています。
まとめ
本PRは ActiveSupport::Notifications.subscribed の手動パターンを NotificationAssertions ヘルパーに統一することで、通知のキャプチャ・フィルタリング・アサーションの各ステップを明確に分離しました。コールバック定義・ローカル変数・購読ブロックが入り組んだコードが、目的を宣言するヘルパー呼び出しに置き換わったことで、テストが何を検証しているかを読み解くコストが大幅に下がっています。