rubocop-rspec-guide 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -6
  3. data/.yardopts +9 -0
  4. data/CHANGELOG.md +92 -0
  5. data/CONTRIBUTING.md +358 -0
  6. data/INTEGRATION_TESTING.md +324 -0
  7. data/README.md +443 -16
  8. data/Rakefile +49 -0
  9. data/benchmark/README.md +349 -0
  10. data/benchmark/baseline_v0.3.1.txt +67 -0
  11. data/benchmark/baseline_v0.4.0.txt +167 -0
  12. data/benchmark/benchmark_helper.rb +92 -0
  13. data/benchmark/compare_versions.rb +136 -0
  14. data/benchmark/cops_benchmark.rb +428 -0
  15. data/benchmark/cops_performance.rb +109 -0
  16. data/benchmark/quick_comparison.rb +58 -0
  17. data/benchmark/quick_invariant_bench.rb +52 -0
  18. data/benchmark/rspec_base_integration.rb +86 -0
  19. data/benchmark/save_baseline.rb +18 -0
  20. data/benchmark/scalability_benchmark.rb +181 -0
  21. data/config/default.yml +43 -2
  22. data/config/obsoletion.yml +6 -0
  23. data/lib/rubocop/cop/factory_bot_guide/dynamic_attribute_evaluation.rb +193 -0
  24. data/lib/rubocop/cop/factory_bot_guide/dynamic_attributes_for_time_and_random.rb +10 -106
  25. data/lib/rubocop/cop/rspec_guide/characteristics_and_contexts.rb +13 -78
  26. data/lib/rubocop/cop/rspec_guide/context_setup.rb +81 -30
  27. data/lib/rubocop/cop/rspec_guide/duplicate_before_hooks.rb +89 -22
  28. data/lib/rubocop/cop/rspec_guide/duplicate_let_values.rb +91 -22
  29. data/lib/rubocop/cop/rspec_guide/happy_path_first.rb +52 -21
  30. data/lib/rubocop/cop/rspec_guide/invariant_examples.rb +60 -19
  31. data/lib/rubocop/cop/rspec_guide/minimum_behavioral_coverage.rb +165 -0
  32. data/lib/rubocop/rspec/guide/inject.rb +26 -0
  33. data/lib/rubocop/rspec/guide/plugin.rb +45 -0
  34. data/lib/rubocop/rspec/guide/version.rb +1 -1
  35. data/lib/rubocop-rspec-guide.rb +4 -0
  36. metadata +49 -1
@@ -1,120 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "dynamic_attribute_evaluation"
4
+
3
5
  module RuboCop
4
6
  module Cop
5
7
  module FactoryBotGuide
6
- # Checks that time-related and random methods in FactoryBot definitions
7
- # are wrapped in blocks for dynamic evaluation.
8
+ # @deprecated Use `DynamicAttributeEvaluation` instead.
9
+ # This cop has been renamed to better reflect its broader scope.
8
10
  #
9
- # @example
10
- # # bad
11
- # factory :user do
12
- # created_at Time.now
13
- # token SecureRandom.hex
14
- # expires_at 1.day.from_now
15
- # end
11
+ # Checks that method calls in FactoryBot attribute definitions
12
+ # are wrapped in blocks for dynamic evaluation.
16
13
  #
17
- # # good
18
- # factory :user do
19
- # created_at { Time.now }
20
- # token { SecureRandom.hex }
21
- # expires_at { 1.day.from_now }
22
- # name "John" # Static values are OK
23
- # end
14
+ # This cop is deprecated and will be removed in a future release.
15
+ # Please use `FactoryBotGuide/DynamicAttributeEvaluation` instead.
24
16
  #
25
- class DynamicAttributesForTimeAndRandom < Base
17
+ class DynamicAttributesForTimeAndRandom < DynamicAttributeEvaluation
18
+ # Override to provide deprecation warning with the old cop name
26
19
  MSG = "Use block syntax for attribute `%<attribute>s` because `%<method>s` " \
27
20
  "is evaluated once at factory definition time. " \
28
21
  "Wrap in block: `%<attribute>s { %<value>s }`"
29
-
30
- TIME_CLASSES = %w[Time Date DateTime].freeze
31
- RANDOM_CLASSES = %w[SecureRandom].freeze
32
-
33
- # @!method factory_block?(node)
34
- def_node_matcher :factory_block?, <<~PATTERN
35
- (block
36
- (send {nil? (const {nil? cbase} :FactoryBot)} :factory ...)
37
- ...)
38
- PATTERN
39
-
40
- # @!method attribute_assignment?(node)
41
- def_node_matcher :attribute_assignment?, <<~PATTERN
42
- (send nil? $_ $_value)
43
- PATTERN
44
-
45
- def on_block(node)
46
- return unless factory_block?(node)
47
-
48
- # Check all attribute assignments within the factory
49
- node.each_descendant(:send) do |send_node|
50
- check_attribute(send_node)
51
- end
52
- end
53
-
54
- private
55
-
56
- def check_attribute(node)
57
- attribute_assignment?(node) do |attribute_name, value|
58
- # Skip if value is already a block
59
- next if value.block_type?
60
-
61
- # Check if the value is a dangerous method call
62
- next unless dangerous_method_call?(value)
63
-
64
- add_offense(
65
- node,
66
- message: format(
67
- MSG,
68
- attribute: attribute_name,
69
- method: method_description(value),
70
- value: value.source
71
- )
72
- )
73
- end
74
- end
75
-
76
- def dangerous_method_call?(node)
77
- # Only method calls are potentially dangerous
78
- return false unless node.send_type?
79
-
80
- # Time.now, Date.today, DateTime.now, etc.
81
- return true if time_method?(node)
82
-
83
- # SecureRandom.hex, SecureRandom.uuid, etc.
84
- return true if random_method?(node)
85
-
86
- # Any other method calls (e.g., 1.day.ago, Array.new, etc.)
87
- # are evaluated at factory load time
88
- true
89
- end
90
-
91
- def time_method?(node)
92
- return false unless node.receiver
93
-
94
- receiver_name = if node.receiver.const_type?
95
- node.receiver.const_name
96
- end
97
-
98
- TIME_CLASSES.include?(receiver_name)
99
- end
100
-
101
- def random_method?(node)
102
- return false unless node.receiver
103
-
104
- receiver_name = if node.receiver.const_type?
105
- node.receiver.const_name
106
- end
107
-
108
- RANDOM_CLASSES.include?(receiver_name)
109
- end
110
-
111
- def method_description(node)
112
- if node.receiver
113
- "#{node.receiver.source}.#{node.method_name}"
114
- else
115
- node.method_name.to_s
116
- end
117
- end
118
22
  end
119
23
  end
120
24
  end
@@ -1,90 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "minimum_behavioral_coverage"
4
+
3
5
  module RuboCop
4
6
  module Cop
5
7
  module RSpecGuide
6
- # Checks that describe blocks have at least 2 context blocks
7
- # to separate happy path from corner cases.
8
- #
9
- # @example
10
- # # bad
11
- # describe '#process' do
12
- # it 'works' do
13
- # expect(subject.process).to be_success
14
- # end
15
- # end
16
- #
17
- # # bad
18
- # describe '#process' do
19
- # context 'when data is valid' do
20
- # it 'processes' do
21
- # expect(subject.process).to be_success
22
- # end
23
- # end
24
- # end
8
+ # @deprecated Use `MinimumBehavioralCoverage` instead.
9
+ # This cop has been renamed to better reflect its purpose.
25
10
  #
26
- # # good
27
- # describe '#process' do
28
- # context 'when data is valid' do
29
- # it 'processes successfully' do
30
- # expect(subject.process).to be_success
31
- # end
32
- # end
11
+ # Checks that describe blocks test at least 2 behavioral variations.
33
12
  #
34
- # context 'when data is invalid' do
35
- # it 'returns error' do
36
- # expect(subject.process).to be_error
37
- # end
38
- # end
39
- # end
13
+ # This cop is deprecated and will be removed in a future release.
14
+ # Please use `RSpecGuide/MinimumBehavioralCoverage` instead.
40
15
  #
41
- class CharacteristicsAndContexts < Base
42
- MSG = "Describe block should have at least 2 contexts " \
43
- "(happy path + edge cases). " \
16
+ class CharacteristicsAndContexts < MinimumBehavioralCoverage
17
+ # Override to provide deprecation warning with the old cop name
18
+ MSG = "Describe block should test at least 2 behavioral variations: " \
19
+ "either use 2+ sibling contexts (happy path + edge cases), " \
20
+ "or combine it-blocks for default behavior with context-blocks for edge cases. " \
44
21
  "Use `# rubocop:disable RSpecGuide/CharacteristicsAndContexts` " \
45
- "if truly no edge cases exist."
46
-
47
- # @!method describe_block?(node)
48
- def_node_matcher :describe_block?, <<~PATTERN
49
- (block
50
- (send nil? :describe ...)
51
- ...)
52
- PATTERN
53
-
54
- # @!method context_block?(node)
55
- def_node_matcher :context_block?, <<~PATTERN
56
- (block (send nil? :context ...) ...)
57
- PATTERN
58
-
59
- def on_block(node)
60
- return unless describe_block?(node)
61
-
62
- # Collect direct child context blocks
63
- contexts = collect_sibling_contexts(node)
64
-
65
- # Add offense if less than 2 contexts
66
- add_offense(node) if contexts.size < 2
67
- end
68
-
69
- private
70
-
71
- def collect_sibling_contexts(node)
72
- # The body of a describe/context block may be:
73
- # 1. A single block node (if only one child)
74
- # 2. A begin node containing multiple children
75
- body = node.body
76
- return [] unless body
77
-
78
- if body.begin_type?
79
- # Multiple children wrapped in begin node
80
- body.children.select { |child| child.block_type? && context_block?(child) }
81
- elsif body.block_type? && context_block?(body)
82
- # Single context child
83
- [body]
84
- else
85
- []
86
- end
87
- end
22
+ "for simple cases (e.g., getters) with no edge cases."
88
23
  end
89
24
  end
90
25
  end
@@ -6,58 +6,108 @@ module RuboCop
6
6
  # Checks that context blocks have setup (let/before) to distinguish
7
7
  # them from the parent context.
8
8
  #
9
+ # Contexts exist to test different scenarios or states. Without explicit setup,
10
+ # the context doesn't actually change anything from its parent, making the
11
+ # context boundary meaningless and confusing.
12
+ #
13
+ # Valid setup methods: let, let!, let_it_be, let_it_be!, before
14
+ #
9
15
  # Note: subject should be defined at describe level, not in contexts,
10
16
  # as it describes the object under test, not context-specific state.
11
17
  # Use RSpec/LeadingSubject cop to ensure subject is defined first.
12
18
  #
13
- # @example
14
- # # bad
19
+ # @safety
20
+ # This cop is safe to run automatically. It only checks for presence
21
+ # of setup, not for semantic correctness.
22
+ #
23
+ # @example Bad - no setup
24
+ # # bad - context has no setup, so what's different?
15
25
  # context 'when user is premium' do
16
- # it 'has access' do
17
- # expect(user).to have_access
18
- # end
26
+ # it { expect(user).to have_access }
27
+ # end
28
+ #
29
+ # # bad - subject in context (should be in describe)
30
+ # context 'with custom config' do
31
+ # subject { Calculator.new(config) } # Wrong place!
32
+ # it { is_expected.to be_valid }
19
33
  # end
20
34
  #
21
- # # good
35
+ # @example Good - using let
36
+ # # good - let defines context-specific state
22
37
  # context 'when user is premium' do
23
38
  # let(:user) { create(:user, :premium) }
39
+ # it { expect(user).to have_access }
40
+ # end
24
41
  #
25
- # it 'has access' do
26
- # expect(user).to have_access
27
- # end
42
+ # # good - let! for immediate evaluation
43
+ # context 'with existing records' do
44
+ # let!(:records) { create_list(:record, 3) }
45
+ # it { expect(Record.count).to eq(3) }
28
46
  # end
29
47
  #
30
- # # good
31
- # context 'when user is premium' do
48
+ # @example Good - using let_it_be (test-prof/rspec-rails)
49
+ # # good - let_it_be for performance (created once per context)
50
+ # context 'with many users' do
51
+ # let_it_be(:users) { create_list(:user, 100) }
52
+ # it { expect(users.size).to eq(100) }
53
+ # end
54
+ #
55
+ # # good - let_it_be! for immediate evaluation
56
+ # context 'with frozen time' do
57
+ # let_it_be!(:timestamp) { Time.current }
58
+ # it { expect(timestamp).to be_frozen }
59
+ # end
60
+ #
61
+ # @example Good - using before
62
+ # # good - before modifies existing state
63
+ # context 'when user is upgraded' do
32
64
  # before { user.upgrade_to_premium! }
65
+ # it { expect(user).to be_premium }
66
+ # end
67
+ #
68
+ # # good - before with multiple setup steps
69
+ # context 'with configured environment' do
70
+ # before do
71
+ # allow(ENV).to receive(:[]).with('API_KEY').and_return('test-key')
72
+ # allow(ENV).to receive(:[]).with('API_URL').and_return('http://test')
73
+ # end
74
+ # it { expect(api_client).to be_configured }
75
+ # end
76
+ #
77
+ # @example Subject placement
78
+ # # bad - subject in context
79
+ # describe Calculator do
80
+ # context 'with custom config' do
81
+ # subject { Calculator.new(custom_config) } # Wrong!
82
+ # it { is_expected.to be_valid }
83
+ # end
84
+ # end
85
+ #
86
+ # # good - subject in describe, config in context
87
+ # describe Calculator do
88
+ # subject { Calculator.new(config) }
33
89
  #
34
- # it 'has access' do
35
- # expect(user).to have_access
90
+ # context 'with custom config' do
91
+ # let(:config) { CustomConfig.new } # Right!
92
+ # it { is_expected.to be_valid }
36
93
  # end
37
94
  # end
38
95
  #
39
- class ContextSetup < Base
96
+ class ContextSetup < RuboCop::Cop::RSpec::Base
40
97
  MSG = "Context should have setup (let/let!/let_it_be/let_it_be!/before) to distinguish it from parent context"
41
98
 
42
- # @!method context_block?(node)
43
- def_node_matcher :context_block?, <<~PATTERN
44
- (block
45
- (send nil? :context ...)
46
- ...)
47
- PATTERN
48
-
49
- # @!method let_declaration?(node)
50
- def_node_matcher :let_declaration?, <<~PATTERN
51
- (block (send nil? {:let :let! :let_it_be :let_it_be!} ...) ...)
52
- PATTERN
99
+ # Using rubocop-rspec API: let?(node) and hook?(node) from Base
100
+ # Custom matcher for context-only:
53
101
 
54
- # @!method before_hook?(node)
55
- def_node_matcher :before_hook?, <<~PATTERN
56
- (block (send nil? :before ...) ...)
102
+ # @!method context_only?(node)
103
+ def_node_matcher :context_only?, <<~PATTERN
104
+ (block (send nil? :context ...) ...)
57
105
  PATTERN
58
106
 
59
107
  def on_block(node)
60
- return unless context_block?(node)
108
+ # Fast pre-check: only process context blocks
109
+ return unless node.method?(:context)
110
+ return unless context_only?(node)
61
111
 
62
112
  # Check if context has at least one setup node (let or before)
63
113
  # Note: subject is NOT counted as context setup because it describes
@@ -77,7 +127,8 @@ module RuboCop
77
127
  (block_node.parent.begin_type? && block_node.parent.parent == context_node)
78
128
  next unless is_immediate_child
79
129
 
80
- return true if let_declaration?(block_node) || before_hook?(block_node)
130
+ # Use rubocop-rspec API matchers
131
+ return true if let?(block_node) || (hook?(block_node) && block_node.method?(:before))
81
132
  end
82
133
 
83
134
  false
@@ -3,11 +3,19 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module RSpecGuide
6
- # Detects duplicate before hooks with identical code across all sibling contexts.
7
- # These should be extracted to the parent context.
6
+ # Detects duplicate before hooks with identical code across sibling contexts.
8
7
  #
9
- # @example
10
- # # bad
8
+ # When the same before hook appears in multiple sibling contexts,
9
+ # it indicates one of two problems:
10
+ # 1. ERROR: Duplicate in ALL contexts → must extract to parent
11
+ # 2. WARNING: Duplicate in SOME contexts → suggests poor test hierarchy
12
+ #
13
+ # @safety
14
+ # This cop is safe to run automatically. It compares hook bodies for
15
+ # exact matches, not semantic equivalence.
16
+ #
17
+ # @example ERROR - duplicate in ALL contexts
18
+ # # bad - before hook duplicated in all 2 contexts
11
19
  # describe 'Controller' do
12
20
  # context 'as admin' do
13
21
  # before { sign_in(user) }
@@ -15,14 +23,14 @@ module RuboCop
15
23
  # end
16
24
  #
17
25
  # context 'as guest' do
18
- # before { sign_in(user) } # Duplicate!
26
+ # before { sign_in(user) } # ERROR: in all contexts!
19
27
  # it { expect(response).to be_forbidden }
20
28
  # end
21
29
  # end
22
30
  #
23
- # # good
31
+ # # good - extracted to parent
24
32
  # describe 'Controller' do
25
- # before { sign_in(user) } # Extracted to parent
33
+ # before { sign_in(user) } # Moved to parent
26
34
  #
27
35
  # context 'as admin' do
28
36
  # it { expect(response).to be_successful }
@@ -33,33 +41,92 @@ module RuboCop
33
41
  # end
34
42
  # end
35
43
  #
36
- class DuplicateBeforeHooks < Base
44
+ # @example WARNING - duplicate in SOME contexts
45
+ # # bad - before hook duplicated in 2/3 contexts (code smell)
46
+ # describe 'API' do
47
+ # context 'scenario A' do
48
+ # before { setup_api }
49
+ # it { expect(response).to be_ok }
50
+ # end
51
+ #
52
+ # context 'scenario B' do
53
+ # before { setup_api } # WARNING: duplicated in 2/3
54
+ # it { expect(response).to be_ok }
55
+ # end
56
+ #
57
+ # context 'scenario C' do
58
+ # before { setup_different_api } # Different setup
59
+ # it { expect(response).to be_ok }
60
+ # end
61
+ # end
62
+ #
63
+ # # good - refactor hierarchy
64
+ # describe 'API' do
65
+ # context 'with standard setup' do
66
+ # before { setup_api }
67
+ #
68
+ # context 'scenario A' do
69
+ # it { expect(response).to be_ok }
70
+ # end
71
+ #
72
+ # context 'scenario B' do
73
+ # it { expect(response).to be_ok }
74
+ # end
75
+ # end
76
+ #
77
+ # context 'with different setup' do
78
+ # before { setup_different_api }
79
+ #
80
+ # context 'scenario C' do
81
+ # it { expect(response).to be_ok }
82
+ # end
83
+ # end
84
+ # end
85
+ #
86
+ # @example Configuration
87
+ # # To disable warnings for partial duplicates:
88
+ # RSpecGuide/DuplicateBeforeHooks:
89
+ # WarnOnPartialDuplicates: false # Only report full duplicates
90
+ #
91
+ # @example Edge case - different hooks
92
+ # # good - different before hooks (no duplicate)
93
+ # describe 'Service' do
94
+ # context 'with user A' do
95
+ # before { sign_in(user_a) }
96
+ # it { expect(service.call).to be_success }
97
+ # end
98
+ #
99
+ # context 'with user B' do
100
+ # before { sign_in(user_b) } # Different, OK
101
+ # it { expect(service.call).to be_success }
102
+ # end
103
+ # end
104
+ #
105
+ class DuplicateBeforeHooks < RuboCop::Cop::RSpec::Base
37
106
  MSG_ERROR = "Duplicate `before` hook in ALL sibling contexts. " \
38
107
  "Extract to parent context."
39
108
  MSG_WARNING = "Before hook duplicated in %<count>d/%<total>d sibling contexts. " \
40
109
  "Consider refactoring test hierarchy - this suggests poor organization."
41
110
 
42
- # @!method context_block?(node)
43
- def_node_matcher :context_block?, <<~PATTERN
111
+ # Using rubocop-rspec API: example_group?(node) and hook?(node) from Base
112
+ # Custom matchers:
113
+
114
+ # @!method context_only?(node)
115
+ def_node_matcher :context_only?, <<~PATTERN
44
116
  (block (send nil? :context ...) ...)
45
117
  PATTERN
46
118
 
47
- # @!method before_hook?(node)
48
- def_node_matcher :before_hook?, <<~PATTERN
119
+ # @!method before_hook_with_body?(node)
120
+ def_node_matcher :before_hook_with_body?, <<~PATTERN
49
121
  (block
50
122
  (send nil? :before ...)
51
123
  (args)
52
124
  $_body)
53
125
  PATTERN
54
126
 
55
- # @!method example_group?(node)
56
- def_node_matcher :example_group?, <<~PATTERN
57
- (block
58
- (send nil? {:describe :context} ...)
59
- ...)
60
- PATTERN
61
-
62
127
  def on_block(node)
128
+ # Fast pre-check: only process describe/context blocks
129
+ return unless node.method?(:describe) || node.method?(:context)
63
130
  return unless example_group?(node)
64
131
 
65
132
  # Collect all sibling contexts
@@ -86,8 +153,8 @@ module RuboCop
86
153
 
87
154
  if body.begin_type?
88
155
  # Multiple children wrapped in begin node
89
- body.children.select { |child| child.block_type? && context_block?(child) }
90
- elsif body.block_type? && context_block?(body)
156
+ body.children.select { |child| child.block_type? && context_only?(child) }
157
+ elsif body.block_type? && context_only?(body)
91
158
  # Single context child
92
159
  [body]
93
160
  else
@@ -106,7 +173,7 @@ module RuboCop
106
173
  (block_node.parent.begin_type? && block_node.parent.parent == context_node)
107
174
  next unless is_immediate_child
108
175
 
109
- before_hook?(block_node) do |body|
176
+ before_hook_with_body?(block_node) do |body|
110
177
  befores << {body_source: normalize_source(body.source), node: block_node}
111
178
  end
112
179
  end