From f14b0b0de1ef7c763a0ee21fd0065c0df4f35bd5 Mon Sep 17 00:00:00 2001 From: Rob Smith Date: Thu, 19 Sep 2024 02:10:54 -0400 Subject: [PATCH] Refactor ExampleConstants to use stub_const. --- .../concerns/example_constants.rb | 177 ++++++++------ .../concerns/example_constants_spec.rb | 40 ++- .../concerns/example_constants_spec.rb | 230 ++++++------------ 3 files changed, 211 insertions(+), 236 deletions(-) diff --git a/lib/rspec/sleeping_king_studios/concerns/example_constants.rb b/lib/rspec/sleeping_king_studios/concerns/example_constants.rb index c3dfe49..a2c204f 100644 --- a/lib/rspec/sleeping_king_studios/concerns/example_constants.rb +++ b/lib/rspec/sleeping_king_studios/concerns/example_constants.rb @@ -4,10 +4,40 @@ module RSpec::SleepingKingStudios::Concerns # Methods for defining example-scoped classes and constants. - module ExampleConstants # rubocop:disable Metrics/ModuleLength + module ExampleConstants DEFAULT_VALUE = Object.new.freeze private_constant :DEFAULT_VALUE + # @api private + ExampleConstant = Struct.new(:name, :value) + + # @api private + module InstanceMethods + # @api private + def apply_example_constants(example_constants) # rubocop:disable Metrics/MethodLength + return if example_constants_applied? + + example_constants.each do |example_constant| + resolved_value = + if example_constant.value.is_a?(Proc) + instance_exec(&example_constant.value) + else + example_constant.value + end + + stub_const(example_constant.name, resolved_value) + end + + @example_constants_applied = true + end + + private + + def example_constants_applied? + @example_constants_applied + end + end + class << self # @api private def define_class(class_name:, example:, base_class: nil, &block) @@ -23,66 +53,14 @@ def define_class(class_name:, example:, base_class: nil, &block) end # @api private - def with_constant( # rubocop:disable Metrics/MethodLength - constant_name:, - constant_value:, - namespace:, - force: false - ) - guard_existing_constant!(namespace, constant_name) unless force - - prior_value = DEFAULT_VALUE - - if namespace.const_defined?(constant_name) - prior_value = namespace.const_get(constant_name) - end - - namespace.const_set(constant_name, constant_value) + def extended(other) + super - yield - ensure - if prior_value == DEFAULT_VALUE - namespace.send :remove_const, constant_name - else - namespace.const_set(constant_name, prior_value) - end - end - - # @api private - def with_namespace(module_names) # rubocop:disable Metrics/MethodLength - last_defined = nil - - resolved = - module_names.reduce(Object) do |namespace, module_name| - if namespace.const_defined?(module_name) - next namespace.const_get(module_name) - end - - last_defined ||= { namespace:, module_name: } - - namespace.const_set(module_name, Module.new) - end - - yield resolved - ensure - if last_defined - last_defined[:namespace] - .send(:remove_const, last_defined[:module_name]) - end + other.include InstanceMethods end private - def guard_existing_constant!(namespace, constant_name) - return unless namespace.const_defined?(constant_name) - - message = - "constant #{constant_name} is already defined with value " \ - "#{namespace.const_get(constant_name).inspect}" - - raise NameError, message - end - def resolve_base_class(value) value = value.fetch(:base_class, nil) if value.is_a?(Hash) @@ -94,6 +72,29 @@ def resolve_base_class(value) end end + # @api private + def each_example_constant(&) + return enum_for(:each_example_constant) unless block_given? + + ancestors.reverse_each do |ancestor| + next unless ancestor.respond_to?(:defined_example_constants, true) + + ancestor.defined_example_constants.each(&) + end + end + + # Defines a temporary class scoped to the current example. + # + # @param class_name [String] the qualified name of the class. + # @param base_class [Class, String] the base class or name of the base + # class. This can be the name of another example class, as long as the + # base class is defined earlier in the example group or in a parent + # example group. + # + # @yield definitions for the temporary class. This block is evaluated in + # the context of the example, meaning that methods or memoized helpers + # can be referenced. + # @yieldparam klass [Class] the temporary class. def example_class(class_name, base_class = nil, &block) class_name = class_name.to_s if class_name.is_a?(Symbol) @@ -107,34 +108,52 @@ def example_class(class_name, base_class = nil, &block) end end + # @overload example_constant(constant_name, constant_value) + # Defines a temporary constant scoped to the current example. + # + # @param constant_name [String] the qualified name of the constant. + # @param constant_value [Object] the value of the constant. + # + # @overload example_constant(constant_name, &block) + # + # @param constant_name [String] the qualified name of the constant. + # + # @yield generates the constant value. This block is evaluated in the + # context of the example, meaning that methods or memoized helpers can + # be referenced. + # @yieldreturn the value of the constant. + # + # @deprecate 2.8.0 with force: true parameter. def example_constant( # rubocop:disable Metrics/MethodLength - qualified_name, - constant_value = DEFAULT_VALUE, + constant_name, + constant_value = nil, force: false, &block ) - around(:example) do |wrapped_example| - resolved_value = - if constant_value == DEFAULT_VALUE && block_given? - wrapped_example.example.instance_exec(&block) - else - constant_value - end - - *module_names, constant_name = qualified_name.to_s.split('::') - - ExampleConstants.with_namespace(module_names) do |namespace| - ExampleConstants.with_constant( - constant_name:, - constant_value: resolved_value, - namespace:, - force: - ) \ - do - wrapped_example.call - end - end + if force + SleepingKingStudios::Tools::Toolbelt + .instance + .core_tools + .deprecate( + 'ExampleConstants.example_constant with force: true', + message: 'The :force parameter is no longer required.' + ) end + + defined_example_constants << ExampleConstant.new( + constant_name, + constant_value || block + ) + + prepend_before(:example) do + apply_example_constants(self.class.each_example_constant) + end + end + + protected + + def defined_example_constants + @defined_example_constants ||= [] end end end diff --git a/spec/integration/concerns/example_constants_spec.rb b/spec/integration/concerns/example_constants_spec.rb index 15fa31f..b130f0d 100644 --- a/spec/integration/concerns/example_constants_spec.rb +++ b/spec/integration/concerns/example_constants_spec.rb @@ -5,7 +5,7 @@ RSpec.describe RSpec::SleepingKingStudios::Concerns::ExampleConstants do extend RSpec::SleepingKingStudios::Concerns::ExampleConstants # rubocop:disable RSpec/DescribedClass - describe '#example_class' do + describe '.example_class' do let(:described_class) { ExampleClass } describe 'with a class name' do @@ -69,9 +69,45 @@ it { expect(instance.ok).to be true } end + + describe 'hooks' do + let(:ref) { Struct.new(:value).new } + + example_class 'ExampleClass' + + before(:example) do + ref.value = ExampleClass + end + + it { expect(ref.value).to be_a Class } + end + + describe 'inheritance' do + example_class 'Spec::Grandparent' + + it { expect(Spec::Grandparent).to be_a Class } + + describe 'with a child example group' do + example_class 'Spec::Parent', 'Spec::Grandparent' + + it { expect(Spec::Grandparent).to be_a Class } + + it { expect(Spec::Parent).to be_a Class } + + describe 'with a grandchild example group' do + example_class 'Spec::Child', 'Spec::Parent' + + it { expect(Spec::Grandparent).to be_a Class } + + it { expect(Spec::Parent).to be_a Class } + + it { expect(Spec::Child).to be_a Class } + end + end + end end - describe '#example_constant' do + describe '.example_constant' do describe 'with a constant name and a block' do let(:the_answer) { 42 } diff --git a/spec/rspec/sleeping_king_studios/concerns/example_constants_spec.rb b/spec/rspec/sleeping_king_studios/concerns/example_constants_spec.rb index bf23718..85d2f99 100644 --- a/spec/rspec/sleeping_king_studios/concerns/example_constants_spec.rb +++ b/spec/rspec/sleeping_king_studios/concerns/example_constants_spec.rb @@ -7,8 +7,12 @@ require 'support/mock_example_group' RSpec.describe RSpec::SleepingKingStudios::Concerns::ExampleConstants do + extend RSpec::SleepingKingStudios::Concerns::ExampleConstants # rubocop:disable RSpec/DescribedClass + + subject(:example) { described_class.new } + let(:described_class) do - Class.new(Spec::Support::MockExampleGroup) do + Class.new(RSpec::Core::ExampleGroup) do extend RSpec::SleepingKingStudios::Concerns::ExampleConstants def helper_method @@ -18,51 +22,46 @@ def helper_method end describe '::example_class' do - shared_examples 'should define the class' do |proc = nil| + shared_examples 'should define the class' do def define_example_class described_class.example_class(class_name, *class_args, &class_proc) end - context 'before the example is run' do # rubocop:disable RSpec/ContextWording - it 'should not define the class' do - define_example_class - - expect { Object.const_get(class_name) }.to raise_error NameError + before(:example) do + allow(described_class).to receive(:prepend_before).with(:example) \ + do |&block| + example.instance_exec(&block) end - end - context 'while the example is running' do # rubocop:disable RSpec/ContextWording - it 'should define the class', :aggregate_failures do # rubocop:disable RSpec/ExampleLength - defined_class = nil - - define_example_class + allow(example).to receive(:stub_const) # rubocop:disable RSpec/SubjectStub + end - allow(described_class.example).to receive(:call) do - defined_class = Object.const_get(class_name) - end + it 'should delegate to #stub_const' do + define_example_class - described_class.run_example + expect(example) # rubocop:disable RSpec/SubjectStub + .to have_received(:stub_const) + .with(class_name.to_s, an_instance_of(Class)) + end - expect(defined_class).to be_a Class - expect(defined_class.inspect).to be == class_name.to_s - expect(defined_class.name).to be == class_name.to_s - expect(defined_class.to_s).to be == class_name.to_s + it 'should define the class', :aggregate_failures do + defined_class = nil - instance_exec(defined_class, &proc) unless proc.nil? + allow(example).to receive(:stub_const) do |_, value| # rubocop:disable RSpec/SubjectStub + defined_class = value end - end - - context 'after the example has run' do # rubocop:disable RSpec/ContextWording - it 'should not define the class' do - define_example_class - described_class.run_example + define_example_class - expect { Object.const_get(class_name) }.to raise_error NameError - end + expect(defined_class).to be_a Class + expect(defined_class).to be < base_class + expect(defined_class.inspect).to be == class_name.to_s + expect(defined_class.name).to be == class_name.to_s + expect(defined_class.to_s).to be == class_name.to_s end end + let(:base_class) { Object } let(:class_name) { 'AnswerClass' } let(:class_args) { [] } let(:class_proc) { nil } @@ -140,38 +139,21 @@ def define_example_class describe '::example_constant' do shared_examples 'should define the constant' do - context 'before the example is run' do # rubocop:disable RSpec/ContextWording - it 'should not define the constant' do - define_example_constant - - expect { Object.const_get(class_name) }.to raise_error NameError + before(:example) do + allow(described_class).to receive(:prepend_before).with(:example) \ + do |&block| + example.instance_exec(&block) end - end - - context 'while the example is running' do # rubocop:disable RSpec/ContextWording - it 'should define the constant' do - defined_constant = nil - define_example_constant - - allow(described_class.example).to receive(:call) do - defined_constant = Object.const_get(constant_name) - end - - described_class.run_example - - expect(defined_constant).to be constant_value - end + allow(example).to receive(:stub_const) # rubocop:disable RSpec/SubjectStub end - context 'after the example has run' do # rubocop:disable RSpec/ContextWording - it 'should not define the constant' do - define_example_constant + it 'should delegate to #stub_const' do + define_example_constant - described_class.run_example - - expect { Object.const_get(constant_name) }.to raise_error NameError - end + expect(example) # rubocop:disable RSpec/SubjectStub + .to have_received(:stub_const) + .with(constant_name, constant_value) end end @@ -192,85 +174,6 @@ def define_example_constant end include_examples 'should define the constant' - - context 'when the constant is already defined' do - let(:prior_value) { 'Forty-two' } - let(:error_message) do - "constant #{constant_name} is already defined with value " \ - "#{prior_value.inspect}" - end - - around(:example) do |example| - Object.const_set(:THE_ANSWER, prior_value) - - example.call - ensure - Object.send(:remove_const, :THE_ANSWER) # rubocop:disable RSpec/RemoveConst - end - - it 'should raise an error' do - described_class.example_constant(constant_name, constant_value) - - expect { described_class.run_example } - .to raise_error(NameError, error_message) - .and(output.to_stderr) - end - - describe 'with force: true' do - let(:expected_warning) do - /warning: already initialized constant THE_ANSWER/ - end - - def define_example_constant - described_class - .example_constant(constant_name, constant_value, force: true) - end - - # rubocop:disable RSpec/NestedGroups - context 'before the example is run' do # rubocop:disable RSpec/ContextWording - it 'should define the constant with the prior value' do - define_example_constant - - expect(Object.const_get(constant_name)).to be == prior_value - end - end - - context 'while the example is running' do # rubocop:disable RSpec/ContextWording - it 'should define the constant', :aggregate_failures do - defined_constant = nil - - define_example_constant - - allow(described_class.example).to receive(:call) do - defined_constant = Object.const_get(constant_name) - end - - expect { described_class.run_example } - .to output(expected_warning) - .to_stderr - - expect(defined_constant).to be constant_value - end - end - - context 'after the example has run' do # rubocop:disable RSpec/ContextWording - # rubocop:disable Style/RedundantLineContinuation - it 'should define the constant with the prior value', - :aggregate_failures \ - do - define_example_constant - - expect { described_class.run_example } - .to output - .to_stderr - - expect(Object.const_get(constant_name)).to be == prior_value - end - # rubocop:enable Style/RedundantLineContinuation - end - # rubocop:enable RSpec/NestedGroups - end - end end describe 'with a constant name and a block' do @@ -281,9 +184,7 @@ def define_example_constant described_class.send(:define_method, :answer) { value } - allow(described_class.example) - .to receive(:answer) - .and_call_original + allow(example).to receive(:answer).and_call_original # rubocop:disable RSpec/SubjectStub end def define_example_constant @@ -302,30 +203,49 @@ def define_example_constant end include_examples 'should define the constant' + end + + describe 'with force: true' do + let(:constant_value) { 42 } - context 'when the namespace is undefined' do - let(:constant_name) { 'Examples::Constants::THE_ANSWER' } + before(:example) do + allow(tools.core_tools).to receive(:deprecate) + end - include_examples 'should define the constant' + def tools + SleepingKingStudios::Tools::Toolbelt.instance + end - context 'before the example is run' do # rubocop:disable RSpec/ContextWording - it 'should not define the namespace' do - define_example_constant + it 'should print a deprecation warning' do + described_class + .example_constant(constant_name, constant_value, force: true) - expect { Object.const_get('Examples') }.to raise_error NameError - end - end + expect(tools.core_tools) + .to have_received(:deprecate) + .with( + 'ExampleConstants.example_constant with force: true', + message: 'The :force parameter is no longer required.' + ) + end + end - context 'after the example has run' do # rubocop:disable RSpec/ContextWording - it 'should not define the namespace' do - define_example_constant + context 'when the constant is already defined' do + let(:prior_value) { 'Forty-two' } + let(:constant_value) { 42 } - described_class.run_example + def define_example_constant + described_class.example_constant(constant_name, constant_value) + end - expect { Object.const_get('Examples') }.to raise_error NameError - end - end + around(:example) do |example| + Object.const_set(:THE_ANSWER, prior_value) + + example.call + ensure + Object.send(:remove_const, :THE_ANSWER) # rubocop:disable RSpec/RemoveConst end + + include_examples 'should define the constant' end end end