From 04b641786094ec2e5d58f9df42b35242aa79b364 Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Mon, 3 Aug 2026 16:31:37 -0700 Subject: [PATCH] Reuse one adapter harness class instead of one per generation Both adapters built a throwaway test class for every fixture generation, and neither could be collected. Two roots held them: - RSpec assigns a permanent constant to every example group it builds (ExampleGroups.assign_const), and only clears them in World#reset, which runs before a suite rather than during one. - rspec-rails includes FixtureSupport into every example group with no metadata filter, and MinitestAdapter included ActiveRecord::TestFixtures directly. Either way that runs ActiveSupport.run_load_hooks(:active_record_fixtures, self), which appends to ActiveSupport's @loaded array. That array is never pruned. Removing the constants alone does not free the classes -- verified by removing them and recounting -- so the fix is to stop building them. Each adapter now builds one harness lazily and reuses it. The block that closes over the Cache is kept off the shared class. RSpec clears the group's examples after each run, since the example holds the block. Minitest defines its test method on the instance instead of the class: a test is run by sending its name to the instance, so a singleton method is enough, and it goes away with the instance. That also leaves the harness class untouched by generation -- nothing to define, remove, or clear. Measured at 300 fixtures: retained heap 26.5 MB -> 21.1 MB, live objects 622,161 -> 452,940, allocations 4,336,506 -> 3,987,015, and harness classes retained for the whole run 301 -> 1. Reusing the harness means fixture code that explicitly reaches `self.class` now mutates a shared class rather than a per-generation one. Fixture blocks run at instance level, and code they call has its own `self`, so only a literal `self.class` in a definition is affected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PQjkiuuGX2t3TSZKpzqpPv --- lib/fixture_kit/adapters/minitest_adapter.rb | 16 ++++--- lib/fixture_kit/adapters/rspec_adapter.rb | 15 ++++--- spec/unit/minitest_adapter_spec.rb | 44 +++++++++++++++----- spec/unit/rspec_adapter_spec.rb | 31 ++++++++++++++ 4 files changed, 86 insertions(+), 20 deletions(-) diff --git a/lib/fixture_kit/adapters/minitest_adapter.rb b/lib/fixture_kit/adapters/minitest_adapter.rb index 4f3039f..a6c90df 100644 --- a/lib/fixture_kit/adapters/minitest_adapter.rb +++ b/lib/fixture_kit/adapters/minitest_adapter.rb @@ -7,15 +7,18 @@ module FixtureKit class MinitestAdapter < FixtureKit::Adapter TEST_NAME = "fixture kit cache pregeneration" + TEST_METHOD = :"test_#{TEST_NAME.tr(" ", "_")}" def execute(&block) - test_class = build_test_class - test_method = test_class.test(TEST_NAME) do + test = test_class.new(TEST_METHOD) + # Minitest runs a test by sending its name to the instance, so defining it + # here leaves the reused class untouched and the block dies with the test. + test.define_singleton_method(TEST_METHOD) do block.call(self) pass end - result = test_class.new(test_method).run + result = test.run return if result.passed? raise result.failures.first.error @@ -27,8 +30,11 @@ def identifier_for(identifier) private - def build_test_class - Class.new(ActiveSupport::TestCase) do + # Reused rather than built per generation: including + # ActiveRecord::TestFixtures appends the class to ActiveSupport's + # :active_record_fixtures load hooks, and that never shrinks. + def test_class + @test_class ||= Class.new(ActiveSupport::TestCase) do ::Minitest::Runnable.runnables.delete(self) include(::ActiveRecord::TestFixtures) end diff --git a/lib/fixture_kit/adapters/rspec_adapter.rb b/lib/fixture_kit/adapters/rspec_adapter.rb index e1dbe7b..5e8d3a7 100644 --- a/lib/fixture_kit/adapters/rspec_adapter.rb +++ b/lib/fixture_kit/adapters/rspec_adapter.rb @@ -7,12 +7,14 @@ class RSpecAdapter < FixtureKit::Adapter def execute(&block) previous_example = ::RSpec.current_example previous_scope = ::RSpec.current_scope - example_group = build_example_group - example = example_group.example { block.call(self) } + group = example_group + example = group.example { block.call(self) } succeeded = begin - example.run(example_group.new, ::RSpec::Core::NullReporter) + example.run(group.new, ::RSpec::Core::NullReporter) ensure + # The group is reused, and the example it holds retains this block. + group.examples.clear ::RSpec.current_example = previous_example ::RSpec.current_scope = previous_scope end @@ -27,8 +29,11 @@ def identifier_for(identifier) private - def build_example_group - ::RSpec::Core::ExampleGroup.subclass( + # Reused rather than built per generation: rspec-rails includes + # ActiveRecord::TestFixtures into every group, which appends it to + # ActiveSupport's :active_record_fixtures load hooks, and that never shrinks. + def example_group + @example_group ||= ::RSpec::Core::ExampleGroup.subclass( ::RSpec::Core::ExampleGroup, "FixtureKit", [], diff --git a/spec/unit/minitest_adapter_spec.rb b/spec/unit/minitest_adapter_spec.rb index 6c5cb92..081c057 100644 --- a/spec/unit/minitest_adapter_spec.rb +++ b/spec/unit/minitest_adapter_spec.rb @@ -16,24 +16,48 @@ expect(captured_test_case_class).to be < ActiveSupport::TestCase expect(captured_test_case_class.included_modules).to include(ActiveRecord::TestFixtures) - expect(captured_test_case_class.public_instance_methods(false).map(&:to_s)) - .to include("test_fixture_kit_cache_pregeneration") end - it "builds a fresh harness class for each execute on the same adapter instance" do - captured_test_case_classes = [] + it "defines the pregeneration test method on the instance, not the harness class" do + harness_class = nil + defined_on_instance = nil - allow(Minitest::Runnable.runnables).to receive(:delete).and_wrap_original do |original, test_case_class| - captured_test_case_classes << test_case_class - original.call(test_case_class) + described_class.new.execute do |context| + harness_class = context.class + defined_on_instance = context.singleton_class + .instance_methods(false) + .include?(described_class::TEST_METHOD) end + expect(defined_on_instance).to be(true) + expect(harness_class.method_defined?(described_class::TEST_METHOD)).to be(false) + end + + it "reuses one harness class across executes on the same adapter instance" do + harness_classes = [] adapter = described_class.new + + adapter.execute { |context| harness_classes << context.class } + adapter.execute { |context| harness_classes << context.class } + + expect(harness_classes.size).to eq(2) + expect(harness_classes.first).to equal(harness_classes.last) + end + + it "leaves no per-generation state on the reused harness class" do + adapter = described_class.new + harness_class = nil + adapter.execute { |context| harness_class = context.class } + snapshot = lambda do + [harness_class.instance_variables.sort, harness_class.public_instance_methods(false).sort] + end + baseline = snapshot.call + adapter.execute { nil } - adapter.execute { nil } + expect { adapter.execute { raise "harness exploded" } } + .to raise_error(RuntimeError, "harness exploded") - expect(captured_test_case_classes.size).to be >= 2 - expect(captured_test_case_classes[-2]).not_to equal(captured_test_case_classes[-1]) + expect(snapshot.call).to eq(baseline) end it "does not leak harness test cases into minitest runnables" do diff --git a/spec/unit/rspec_adapter_spec.rb b/spec/unit/rspec_adapter_spec.rb index 4ee6d38..1e0fd9a 100644 --- a/spec/unit/rspec_adapter_spec.rb +++ b/spec/unit/rspec_adapter_spec.rb @@ -58,6 +58,36 @@ end.to raise_error(RuntimeError, "harness exploded") end + it "reuses one harness example group across executes" do + harness_groups = [] + adapter = described_class.new + + adapter.execute { |context| harness_groups << context.class } + adapter.execute { |context| harness_groups << context.class } + + expect(harness_groups.size).to eq(2) + expect(harness_groups.first).to equal(harness_groups.last) + end + + it "assigns only one RSpec::ExampleGroups constant no matter how many times it runs" do + adapter = described_class.new + adapter.execute { nil } + constants_after_first = RSpec::ExampleGroups.constants.grep(/\AFixtureKit/).size + + 3.times { adapter.execute { nil } } + + expect(RSpec::ExampleGroups.constants.grep(/\AFixtureKit/).size) + .to eq(constants_after_first) + end + + it "does not retain the harness example after running" do + harness_group = nil + + described_class.new.execute { |context| harness_group = context.class } + + expect(harness_group.examples).to be_empty + end + it "re-raises example.exception when execute returns false" do example_group = double("example_group") example = instance_double(RSpec::Core::Example) @@ -66,6 +96,7 @@ allow(::RSpec::Core::ExampleGroup).to receive(:subclass).and_return(example_group) allow(example_group).to receive(:example).and_return(example) + allow(example_group).to receive(:examples).and_return([]) allow(example_group).to receive(:new).and_return(instance) allow(example).to receive(:run).with(instance, RSpec::Core::NullReporter).and_return(false) allow(example).to receive(:exception).and_return(failure)