assert_* 系列で静的値が渡されたときに ArgumentError を発生させる安全化

rails/rails

Rails のテストヘルパー assert_differenceassert_no_differenceassert_changesassert_no_changes が、式として 静的な値(例: a.size)が渡された場合に常に成功してしまう問題を解消します。これらのメソッドは式を評価して変化を検知しますが、値そのものを渡すと beforeafter が同一になるため、意図しないテスト合格が起きます。本稿では、PR #57680 における実装変更と、その設計判断を解説します。

背景

assert_no_changes 系列は、ブロック実行前後の式の評価結果が変化しないことを検証します。静的な値(整数や文字列)を式として渡すと、評価は常に同一になるため、実際に変更が起きてもテストはパスしてしまいます。たとえば assert_no_changes(a.size) { a << "x" } は、a.size が整数である限り失敗しません。このようなサイレントな合格はバグの検出を妨げ、テストの信頼性を低下させます。PR はこの鋭いエッジケースを捕捉し、明示的なエラーを提示することで安全性を向上させました。

技術的な変更

activesupport/lib/active_support/testing/assertions.rb では、式を 呼び出し可能オブジェクト(Proc など)または 文字列・シンボル に限定するヘルパー _expression_to_callable が新たに導入されました。既存の exps = expressions.keys.map { |e} …exp = expression.respond_to?(:call) … のロジックは、_expression_to_callable へ置き換えられ、非呼び出し可能なオブジェクトが渡されると ArgumentError が発生します。

-def _callable_to_source_string(callable)
-  # ... 省略 ...
-end
+def _expression_to_callable(callable_or_ruby_source, binding)
+  return callable_or_ruby_source if callable_or_ruby_source.respond_to?(:call)
+
+  case callable_or_ruby_source
+  when String, Symbol
+    -> { eval(callable_or_ruby_source.to_s, binding) }
+  else
+    raise ArgumentError, "The expression must be a callable object like a Proc, or a String of Ruby code. Got #{callable_or_ruby_source.inspect}"
+  end
+end

assert_differenceassert_no_differenceassert_changesassert_no_changes の全メソッドが新ヘルパーを使用するよう修正され、テストケースも非呼び出し可能な式に対して ArgumentError が発生することを確認するテストが追加されました。例えば assert_no_difference @object.num do … end がエラーになる様子は以下の通りです。

def test_assert_no_difference_with_non_callable
  error = assert_raises ArgumentError do
    assert_no_difference @object.num do # Should have been `-> { @object.num }`!
      @object.increment
    end
  end
  assert_match("The expression must be a callable object like a Proc, or a String of Ruby code. Got 0", error.message)
end

CHANGELOG も更新され、assert_* 系列が静的値を受け取った際に ArgumentError を送出する旨が明記されています。

+*   `assert_difference`, `assert_no_difference`, `assert_changes`, and
+    `assert_no_changes` now raise `ArgumentError` when given an expression that
+    is not a callable (like a Proc), String, or Symbol.

設計判断

この変更は 入力バリデーション の追加というシンプルな設計選択です。文字列やシンボルは eval で評価できるため許容し、完全に排除しないことで既存の文字列ベースの利用法を壊さないよう配慮しています。代替案として新しい設定キーやオプションを導入する案も議論されましたが、API の破壊を最小限に抑えるため、既存メソッド内部での型チェックに留める方針が採られました。互換性への配慮として、to_s で文字列化できても eval が成功するケースは極稀であり、実際にエラーになるのは意図しない静的値を渡した場合のみです。結果として、テストコードの安全性は向上しつつ、既存ユーザーへの影響は最小限に抑えられました。

まとめ

assert_* 系列に対する 式タイプ検証 が導入され、非呼び出し可能な静的値が渡されたときに即座に ArgumentError が発生します。これにより、テストが意図せず成功するリスクが排除され、開発者は正しい式(Proc、文字列、シンボル)を意識して記述できるようになります。設計はシンプルなバリデーション追加に留め、後方互換性と既存利用法の保護を両立させた点が評価できます。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
0206be10

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文→背景→技術的な変更→設計判断→まとめの構成が揃っており、結論は単なる繰り返しではなく意義が述べられている。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックのファイル名付きシンタックスハイライトは正しいが、PRへのリンク記法が `[PR #57680](URL)` となっており、要求された `[#57680](URL)` 形式になっていない。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Rails のテストヘルパーに詳しいエンジニア向けの内容で、初心者向けの過度な説明はない。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論・各論・結論の流れで構成され、段落はトピックセンテンスで始まり、1段落1トピック、長さも適切に分割されている。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のコードブロックは提供された Diff の追加・変更箇所と一致しており、抜けや誤りは見られない。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「callable」「Expression」「ArgumentError」などの用語は PR と一致し、誤用はない。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的な説明は PR の背景・目的・実装内容と合致しており、論理的に正しい。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事の主張はすべて PR タイトル、説明、Diff で裏付けられており、捏造や推測はない。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

数値や固有名詞は PR 番号 #57680 のみで正確に記載されている。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

タイトルは PR が対象とする assert_* 系列全体への変更を正しく表現している。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事に PR には無関係なバージョンサポートやリリーススケジュール等の外部知識は含まれていない。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現の記載はなく、PR と矛盾する表現もない。