rspec/rspec-core

View on GitHub
lib/rspec/core/memoized_helpers.rb

Summary

Maintainability
A
40 mins
Test Coverage
RSpec::Support.require_rspec_support 'reentrant_mutex'

module RSpec
  module Core
    # This module is included in {ExampleGroup}, making the methods
    # available to be called from within example blocks.
    #
    # @see ClassMethods
    module MemoizedHelpers
      # @note `subject` was contributed by Joe Ferris to support the one-liner
      #   syntax embraced by shoulda matchers:
      #
      #       RSpec.describe Widget do
      #         it { is_expected.to validate_presence_of(:name) }
      #         # or
      #         it { should validate_presence_of(:name) }
      #       end
      #
      #   While the examples below demonstrate how to use `subject`
      #   explicitly in examples, we recommend that you define a method with
      #   an intention revealing name instead.
      #
      # @example
      #
      #   # Explicit declaration of subject.
      #   RSpec.describe Person do
      #     subject { Person.new(:birthdate => 19.years.ago) }
      #     it "should be eligible to vote" do
      #       subject.should be_eligible_to_vote
      #       # ^ ^ explicit reference to subject not recommended
      #     end
      #   end
      #
      #   # Implicit subject => { Person.new }.
      #   RSpec.describe Person do
      #     it "should be eligible to vote" do
      #       subject.should be_eligible_to_vote
      #       # ^ ^ explicit reference to subject not recommended
      #     end
      #   end
      #
      #   # One-liner syntax - expectation is set on the subject.
      #   RSpec.describe Person do
      #     it { is_expected.to be_eligible_to_vote }
      #     # or
      #     it { should be_eligible_to_vote }
      #   end
      #
      # @note Because `subject` is designed to create state that is reset
      #   between each example, and `before(:context)` is designed to setup
      #   state that is shared across _all_ examples in an example group,
      #   `subject` is _not_ intended to be used in a `before(:context)` hook.
      #
      # @see #should
      # @see #should_not
      # @see #is_expected
      def subject
        __memoized.fetch_or_store(:subject) do
          described = described_class || self.class.metadata.fetch(:description_args).first
          Class === described ? described.new : described
        end
      end

      # When `should` is called with no explicit receiver, the call is
      # delegated to the object returned by `subject`. Combined with an
      # implicit subject this supports very concise expressions.
      #
      # @example
      #
      #   RSpec.describe Person do
      #     it { should be_eligible_to_vote }
      #   end
      #
      # @see #subject
      # @see #is_expected
      #
      # @note This only works if you are using rspec-expectations.
      # @note If you are using RSpec's newer expect-based syntax you may
      #       want to use `is_expected.to` instead of `should`.
      def should(matcher=nil, message=nil)
        RSpec::Expectations::PositiveExpectationHandler.handle_matcher(subject, matcher, message)
      end

      # Just like `should`, `should_not` delegates to the subject (implicit or
      # explicit) of the example group.
      #
      # @example
      #
      #   RSpec.describe Person do
      #     it { should_not be_eligible_to_vote }
      #   end
      #
      # @see #subject
      # @see #is_expected
      #
      # @note This only works if you are using rspec-expectations.
      # @note If you are using RSpec's newer expect-based syntax you may
      #       want to use `is_expected.to_not` instead of `should_not`.
      def should_not(matcher=nil, message=nil)
        RSpec::Expectations::NegativeExpectationHandler.handle_matcher(subject, matcher, message)
      end

      # Wraps the `subject` in `expect` to make it the target of an expectation.
      # Designed to read nicely for one-liners.
      #
      # @example
      #
      #   describe [1, 2, 3] do
      #     it { is_expected.to be_an Array }
      #     it { is_expected.not_to include 4 }
      #   end
      #
      # @see #subject
      # @see #should
      # @see #should_not
      #
      # @note This only works if you are using rspec-expectations.
      def is_expected
        expect(subject)
      end

      # @private
      # should just be placed in private section,
      # but Ruby issues warnings on private attributes.
      # and expanding it to the equivalent method upsets Rubocop,
      # b/c it should obviously be a reader
      attr_reader :__memoized
      private :__memoized

    private

      # @private
      def initialize(*)
        __init_memoized
        super
      end

      # @private
      def __init_memoized
        @__memoized = if RSpec.configuration.threadsafe?
                        ThreadsafeMemoized.new
                      else
                        NonThreadSafeMemoized.new
                      end
      end

      # @private
      class ThreadsafeMemoized
        def initialize
          @memoized = {}
          @mutex = Support::ReentrantMutex.new
        end

        def fetch_or_store(key)
          @memoized.fetch(key) do # only first access pays for synchronization
            @mutex.synchronize do
              @memoized.fetch(key) { @memoized[key] = yield }
            end
          end
        end
      end

      # @private
      class NonThreadSafeMemoized
        def initialize
          @memoized = {}
        end

        def fetch_or_store(key)
          @memoized.fetch(key) { @memoized[key] = yield }
        end
      end

      # Used internally to customize the behavior of the
      # memoized hash when used in a `before(:context)` hook.
      #
      # @private
      class ContextHookMemoized
        def self.isolate_for_context_hook(example_group_instance)
          exploding_memoized = self

          example_group_instance.instance_exec do
            @__memoized = exploding_memoized

            begin
              yield
            ensure
              # This is doing a reset instead of just isolating for context hook.
              # Really, this should set the old @__memoized back into place.
              #
              # Caller is the before and after context hooks
              # which are both called from self.run
              # I didn't look at why it made tests fail, maybe an object was getting reused in RSpec tests,
              # if so, then that probably already works, and its the tests that are wrong.
              __init_memoized
            end
          end
        end

        def self.fetch_or_store(key, &_block)
          description = if key == :subject
                          "subject"
                        else
                          "let declaration `#{key}`"
                        end

          raise <<-EOS
#{description} accessed in #{article} #{hook_expression} hook at:
  #{CallerFilter.first_non_rspec_line}

`let` and `subject` declarations are not intended to be called
in #{article} #{hook_expression} hook, as they exist to define state that
is reset between each example, while #{hook_expression} exists to
#{hook_intention}.
EOS
        end

        # @private
        class Before < self
          def self.hook_expression
            "`before(:context)`"
          end

          def self.article
            "a"
          end

          def self.hook_intention
            "define state that is shared across examples in an example group"
          end
        end

        # @private
        class After < self
          def self.hook_expression
            "`after(:context)`"
          end

          def self.article
            "an"
          end

          def self.hook_intention
            "cleanup state that is shared across examples in an example group"
          end
        end
      end

      # This module is extended onto {ExampleGroup}, making the methods
      # available to be called from within example group blocks.
      # You can think of them as being analagous to class macros.
      module ClassMethods
        # Generates a method whose return value is memoized after the first
        # call. Useful for reducing duplication between examples that assign
        # values to the same local variable.
        #
        # @note `let` _can_ enhance readability when used sparingly (1,2, or
        #   maybe 3 declarations) in any given example group, but that can
        #   quickly degrade with overuse. YMMV.
        #
        # @note `let` can be configured to be threadsafe or not.
        #   If it is threadsafe, it will take longer to access the value.
        #   If it is not threadsafe, it may behave in surprising ways in examples
        #   that spawn separate threads. Specify this on `RSpec.configure`
        #
        # @note Because `let` is designed to create state that is reset between
        #   each example, and `before(:context)` is designed to setup state that
        #   is shared across _all_ examples in an example group, `let` is _not_
        #   intended to be used in a `before(:context)` hook.
        #
        # @example
        #
        #   RSpec.describe Thing do
        #     let(:thing) { Thing.new }
        #
        #     it "does something" do
        #       # First invocation, executes block, memoizes and returns result.
        #       thing.do_something
        #
        #       # Second invocation, returns the memoized value.
        #       thing.should be_something
        #     end
        #   end
        def let(name, &block)
          # We have to pass the block directly to `define_method` to
          # allow it to use method constructs like `super` and `return`.
          raise "#let or #subject called without a block" if block.nil?
          raise(
            "#let or #subject called with a reserved name #initialize"
          ) if :initialize == name
          our_module = MemoizedHelpers.module_for(self)

          # If we have a module clash in our helper module
          # then we need to remove it to prevent a warning.
          #
          # Note we do not check ancestor modules (see: `instance_methods(false)`)
          # as we can override them.
          if our_module.instance_methods(false).include?(name)
            our_module.__send__(:remove_method, name)
          end
          our_module.__send__(:define_method, name, &block)

          # If we have a module clash in the example module
          # then we need to remove it to prevent a warning.
          #
          # Note we do not check ancestor modules (see: `instance_methods(false)`)
          # as we can override them.
          if instance_methods(false).include?(name)
            remove_method(name)
          end

          # Apply the memoization. The method has been defined in an ancestor
          # module so we can use `super` here to get the value.
          if block.arity == 1
            define_method(name) { __memoized.fetch_or_store(name) { super(RSpec.current_example, &nil) } }
          else
            define_method(name) { __memoized.fetch_or_store(name) { super(&nil) } }
          end
        end

        # Just like `let`, except the block is invoked by an implicit `before`
        # hook. This serves a dual purpose of setting up state and providing a
        # memoized reference to that state.
        #
        # @example
        #
        #   class Thing
        #     def self.count
        #       @count ||= 0
        #     end
        #
        #     def self.count=(val)
        #       @count += val
        #     end
        #
        #     def self.reset_count
        #       @count = 0
        #     end
        #
        #     def initialize
        #       self.class.count += 1
        #     end
        #   end
        #
        #   RSpec.describe Thing do
        #     after(:example) { Thing.reset_count }
        #
        #     context "using let" do
        #       let(:thing) { Thing.new }
        #
        #       it "is not invoked implicitly" do
        #         Thing.count.should eq(0)
        #       end
        #
        #       it "can be invoked explicitly" do
        #         thing
        #         Thing.count.should eq(1)
        #       end
        #     end
        #
        #     context "using let!" do
        #       let!(:thing) { Thing.new }
        #
        #       it "is invoked implicitly" do
        #         Thing.count.should eq(1)
        #       end
        #
        #       it "returns memoized version on first invocation" do
        #         thing
        #         Thing.count.should eq(1)
        #       end
        #     end
        #   end
        def let!(name, &block)
          let(name, &block)
          before { __send__(name) }
        end

        # Declares a `subject` for an example group which can then be wrapped
        # with `expect` using `is_expected` to make it the target of an
        # expectation in a concise, one-line example.
        #
        # Given a `name`, defines a method with that name which returns the
        # `subject`. This lets you declare the subject once and access it
        # implicitly in one-liners and explicitly using an intention revealing
        # name.
        #
        # When given a `name`, calling `super` in the block is not supported.
        #
        # @note `subject` can be configured to be threadsafe or not.
        #   If it is threadsafe, it will take longer to access the value.
        #   If it is not threadsafe, it may behave in surprising ways in examples
        #   that spawn separate threads. Specify this on `RSpec.configure`
        #
        # @param name [String,Symbol] used to define an accessor with an
        #   intention revealing name
        # @param block defines the value to be returned by `subject` in examples
        #
        # @example
        #
        #   RSpec.describe CheckingAccount, "with $50" do
        #     subject { CheckingAccount.new(Money.new(50, :USD)) }
        #     it { is_expected.to have_a_balance_of(Money.new(50, :USD)) }
        #     it { is_expected.not_to be_overdrawn }
        #   end
        #
        #   RSpec.describe CheckingAccount, "with a non-zero starting balance" do
        #     subject(:account) { CheckingAccount.new(Money.new(50, :USD)) }
        #     it { is_expected.not_to be_overdrawn }
        #     it "has a balance equal to the starting balance" do
        #       account.balance.should eq(Money.new(50, :USD))
        #     end
        #   end
        #
        # @see MemoizedHelpers#should
        # @see MemoizedHelpers#should_not
        # @see MemoizedHelpers#is_expected
        def subject(name=nil, &block)
          if name
            let(name, &block)
            alias_method :subject, name

            self::NamedSubjectPreventSuper.__send__(:define_method, name) do
              raise NotImplementedError, "`super` in named subjects is not supported"
            end
          else
            let(:subject, &block)
          end
        end

        # Just like `subject`, except the block is invoked by an implicit
        # `before` hook. This serves a dual purpose of setting up state and
        # providing a memoized reference to that state.
        #
        # @example
        #
        #   class Thing
        #     def self.count
        #       @count ||= 0
        #     end
        #
        #     def self.count=(val)
        #       @count += val
        #     end
        #
        #     def self.reset_count
        #       @count = 0
        #     end
        #
        #     def initialize
        #       self.class.count += 1
        #     end
        #   end
        #
        #   RSpec.describe Thing do
        #     after(:example) { Thing.reset_count }
        #
        #     context "using subject" do
        #       subject { Thing.new }
        #
        #       it "is not invoked implicitly" do
        #         Thing.count.should eq(0)
        #       end
        #
        #       it "can be invoked explicitly" do
        #         subject
        #         Thing.count.should eq(1)
        #       end
        #     end
        #
        #     context "using subject!" do
        #       subject!(:thing) { Thing.new }
        #
        #       it "is invoked implicitly" do
        #         Thing.count.should eq(1)
        #       end
        #
        #       it "returns memoized version on first invocation" do
        #         subject
        #         Thing.count.should eq(1)
        #       end
        #     end
        #   end
        def subject!(name=nil, &block)
          subject(name, &block)
          before { subject }
        end
      end

      # @private
      #
      # Gets the LetDefinitions module. The module is mixed into
      # the example group and is used to hold all let definitions.
      # This is done so that the block passed to `let` can be
      # forwarded directly on to `define_method`, so that all method
      # constructs (including `super` and `return`) can be used in
      # a `let` block.
      #
      # The memoization is provided by a method definition on the
      # example group that supers to the LetDefinitions definition
      # in order to get the value to memoize.
      def self.module_for(example_group)
        get_constant_or_yield(example_group, :LetDefinitions) do
          mod = Module.new do
            include(Module.new {
              example_group.const_set(:NamedSubjectPreventSuper, self)
            })
          end

          example_group.const_set(:LetDefinitions, mod)
          mod
        end
      end

      # @private
      def self.define_helpers_on(example_group)
        example_group.__send__(:include, module_for(example_group))
      end

      if Module.method(:const_defined?).arity == 1 # for 1.8
        # @private
        #
        # Gets the named constant or yields.
        # On 1.8, const_defined? / const_get do not take into
        # account the inheritance hierarchy.
        # :nocov:
        def self.get_constant_or_yield(example_group, name)
          if example_group.const_defined?(name)
            example_group.const_get(name)
          else
            yield
          end
        end
        # :nocov:
      else
        # @private
        #
        # Gets the named constant or yields.
        # On 1.9, const_defined? / const_get take into account the
        # the inheritance by default, and accept an argument to
        # disable this behavior. It's important that we don't
        # consider inheritance here; each example group level that
        # uses a `let` should get its own `LetDefinitions` module.
        def self.get_constant_or_yield(example_group, name)
          if example_group.const_defined?(name, (check_ancestors = false))
            example_group.const_get(name, check_ancestors)
          else
            yield
          end
        end
      end
    end
  end
end