assert_* 系列で静的値が渡されたときに ArgumentError を発生させる安全化
Rails のテストヘルパー assert_difference、assert_no_difference、assert_changes、assert_no_changes が、式として 静的な値(例: a.size)が渡された場合に常に成功してしまう問題を解消します。これらのメソッドは式を評価して変化を検知しますが、値そのものを渡すと before と after が同一になるため、意図しないテスト合格が起きます。本稿では、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_difference、assert_no_difference、assert_changes、assert_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、文字列、シンボル)を意識して記述できるようになります。設計はシンプルなバリデーション追加に留め、後方互換性と既存利用法の保護を両立させた点が評価できます。