フィクスチャのYAMLパース結果をキャッシュしてテスト高速化
トランザクションを使わないテストが混在する環境でフィクスチャキャッシュが無効化されるたびにYAMLファイルを再パースしていた問題を解消するため、パース結果をクラス変数でメモ化する仕組みが追加されました。
背景
use_transactional_tests = false のテストが実行されるたびに、フィクスチャキャッシュ全体をリセットしなければならないという制約があります。テストの順序がスイート(テストクラス)単位で固定されるMinitestではこの問題は比較的軽微ですが、#57326 で検討されているMegatestへの移行では、テストメソッド単位でランダムに順序が決まるため、トランザクションを使わないテストが他のテストと頻繁に混在し、キャッシュのバスト(無効化)が著しく増加します。
プロファイリングの結果、キャッシュバスト時のコストのうち約半分がYAMLファイルの再パースに起因していることが判明しました。データベースのリセット自体は不可避ですが、変更されていないYAMLファイルを何度もパースし直す必要はなく、この部分はメモ化によって最適化できます。
技術的な変更
FixtureSet にクラス変数 @@parsing_cache を導入し、ファイルパスをキーとしてパース済みの FixtureSet::File オブジェクトをキャッシュするようにしました。
fixtures.rb の read_fixture_files メソッドでは、従来 FixtureSet::File.open をブロック付きで都度呼び出していたところを、キャッシュを参照してヒットしない場合のみパースを実行する構造に変更されています。
変更前:
yaml_files.each_with_object({}) do |file, fixtures|
FixtureSet::File.open(file) do |fh|
self.model_class ||= fh.model_class if fh.model_class
self.model_class ||= default_fixture_model_class
self.ignored_fixtures ||= fh.ignored_fixtures
fh.each do |fixture_name, row|
fixtures[fixture_name] = ActiveRecord::Fixture.new(row, model_class)
end
end
end
変更後:
yaml_files.each_with_object({}) do |file, fixtures|
fh = (@@parsing_cache[file] ||= FixtureSet::File.open(file))
self.model_class ||= fh.model_class if fh.model_class
self.model_class ||= default_fixture_model_class
self.ignored_fixtures ||= fh.ignored_fixtures
fh.each do |fixture_name, row|
fixtures[fixture_name] = ActiveRecord::Fixture.new(row.dup, model_class)
end
end
キャッシュされた row オブジェクトが複数の Fixture から共有されて変異されることを防ぐため、row.dup が追加されている点も重要です。また、file.rb 側では ActiveSupport::ConfigurationFile.parse の呼び出しに freeze: true オプションが追加され、パース結果のオブジェクトをイミュータブルにすることでキャッシュの安全性が高められています。
キャッシュは通常プロセス全体を通じて保持されますが、テストでファイル内容を動的に書き換える場合など、意図的にキャッシュを無効化したいケースのために without_parsing_cache クラスメソッドが提供されています。
def without_parsing_cache
parsing_cache_was = @@parsing_cache
@@parsing_cache = {}
yield
ensure
@@parsing_cache = parsing_cache_was
end
fixtures_test.rb の外部キー制約テストでは、テスト内でフィクスチャファイルを書き換えているため、without_parsing_cache ブロックで囲むよう修正されています。
設計判断
キャッシュのスコープをクラス変数に置く設計 が採用されました。フィクスチャファイルはテストスイートの実行期間中に内容が変わらないことが前提のため、プロセス生存期間を通じて保持できるクラス変数は適切なスコープです。
パース結果をキャッシュする際に row.dup で行データをコピーしているのは、Fixture オブジェクトが後から行データを変更する可能性があるためです。freeze: true と row.dup の組み合わせにより、キャッシュのソースオブジェクトをイミュータブルに保ちつつ、各 Fixture には変更可能なコピーを渡す二段構えの安全策が取られています。
PR本文では「フィクスチャコードは全面的な書き直しに値するほど複雑」と言及されており、本変更はその方向への小さな一歩と位置付けられています。理想的にはキャッシュバスト時にSQLコマンドの事前計算済みリストを実行するだけで済むよう、より大規模なリファクタリングが想定されています。
まとめ
YAMLパース結果のメモ化という局所的な変更でありながら、キャッシュバスト時のコストを大幅に削減できる実用的な改善です。without_parsing_cache によるエスケープハッチも備えており、既存の動作との互換性を保ちつつ、将来のフィクスチャコード全体の最適化に向けた土台を築いています。