nevir/rubocop-rspec

View on GitHub
lib/rubocop/cop/rspec/empty_example_group.rb

Summary

Maintainability
A
50 mins
Test Coverage
# frozen_string_literal: true

module RuboCop
  module Cop
    module RSpec
      # Checks if an example group does not include any tests.
      #
      # @example usage
      #   # bad
      #   describe Bacon do
      #     let(:bacon)      { Bacon.new(chunkiness) }
      #     let(:chunkiness) { false                 }
      #
      #     context 'extra chunky' do   # flagged by rubocop
      #       let(:chunkiness) { true }
      #     end
      #
      #     it 'is chunky' do
      #       expect(bacon.chunky?).to be_truthy
      #     end
      #   end
      #
      #   # good
      #   describe Bacon do
      #     let(:bacon)      { Bacon.new(chunkiness) }
      #     let(:chunkiness) { false                 }
      #
      #     it 'is chunky' do
      #       expect(bacon.chunky?).to be_truthy
      #     end
      #   end
      #
      #   # good
      #   describe Bacon do
      #     pending 'will add tests later'
      #   end
      #
      class EmptyExampleGroup < Base
        extend AutoCorrector

        include RangeHelp

        MSG = 'Empty example group detected.'

        def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
          return if node.each_ancestor(:def, :defs).any?
          return if node.each_ancestor(:block).any? { |block| example?(block) }

          example_group_body(node) do |body|
            next unless offensive?(body)

            add_offense(node.send_node) do |corrector|
              corrector.remove(removed_range(node))
            end
          end
        end

        private

        # @!method example_group_body(node)
        #   Match example group blocks and yield their body
        #
        #   @example source that matches
        #     describe 'example group' do
        #       it { is_expected.to be }
        #     end
        #
        #   @param node [RuboCop::AST::Node]
        #   @yield [RuboCop::AST::Node] example group body
        def_node_matcher :example_group_body, <<~PATTERN
          (block (send #rspec? #ExampleGroups.all ...) args $_)
        PATTERN

        # @!method example_or_group_or_include?(node)
        #   Match examples, example groups and includes
        #
        #   @example source that matches
        #     it { is_expected.to fly }
        #     describe('non-empty example groups too') { }
        #     it_behaves_like 'an animal'
        #     it_behaves_like('a cat') { let(:food) { 'milk' } }
        #     it_has_root_access
        #     skip
        #     it 'will be implemented later'
        #
        #   @param node [RuboCop::AST::Node]
        #   @return [Array<RuboCop::AST::Node>] matching nodes
        def_node_matcher :example_or_group_or_include?, <<~PATTERN
          {
            (block
              (send #rspec? {#Examples.all #ExampleGroups.all #Includes.all} ...)
            ...)
            (send nil? {#Examples.all #Includes.all} ...)
          }
        PATTERN

        # @!method examples_inside_block?(node)
        #   Match examples defined inside a block which is not a hook
        #
        #   @example source that matches
        #     %w(r g b).each do |color|
        #       it { is_expected.to have_color(color) }
        #     end
        #
        #   @example source that does not match
        #     before do
        #       it { is_expected.to fall_into_oblivion }
        #     end
        #
        #   @param node [RuboCop::AST::Node]
        #   @return [Array<RuboCop::AST::Node>] matching nodes
        def_node_matcher :examples_inside_block?, <<~PATTERN
          (block !(send nil? #Hooks.all ...) _ #examples?)
        PATTERN

        # @!method examples_directly_or_in_block?(node)
        #   Match examples or examples inside blocks
        #
        #   @example source that matches
        #     it { expect(drink).to be_cold }
        #     context('when winter') { it { expect(drink).to be_hot } }
        #     (1..5).each { |divisor| it { is_expected.to divide_by(divisor) } }
        #
        #   @param node [RuboCop::AST::Node]
        #   @return [Array<RuboCop::AST::Node>] matching nodes
        def_node_matcher :examples_directly_or_in_block?, <<~PATTERN
          {
            #example_or_group_or_include?
            #examples_inside_block?
          }
        PATTERN

        # @!method examples?(node)
        #   Matches examples defined in scopes where they could run
        #
        #   @example source that matches
        #     it { expect(myself).to be_run }
        #     describe { it { i_run_as_well } }
        #
        #   @example source that does not match
        #     before { it { whatever here won't run anyway } }
        #
        #   @param node [RuboCop::AST::Node]
        #   @return [Array<RuboCop::AST::Node>] matching nodes
        def_node_matcher :examples?, <<~PATTERN
          {
            #examples_directly_or_in_block?
            (begin <#examples_directly_or_in_block? ...>)
            (begin <#examples_in_branches? ...>)
          }
        PATTERN

        def offensive?(body)
          return true unless body
          return false if conditionals_with_examples?(body)

          if body.if_type? || body.case_type?
            !examples_in_branches?(body)
          else
            !examples?(body)
          end
        end

        def conditionals_with_examples?(body)
          return false unless body.begin_type? || body.case_type?

          body.each_descendant(:if, :case).any? do |condition_node|
            examples_in_branches?(condition_node)
          end
        end

        def examples_in_branches?(condition_node)
          return false if !condition_node.if_type? && !condition_node.case_type?

          condition_node.branches.any? { |branch| examples?(branch) }
        end

        def removed_range(node)
          range_by_whole_lines(
            node.source_range,
            include_final_newline: true
          )
        end
      end
    end
  end
end