rubocop-rspec-guide 0.2.2 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -6
- data/.yardopts +9 -0
- data/CHANGELOG.md +86 -0
- data/CONTRIBUTING.md +358 -0
- data/INTEGRATION_TESTING.md +324 -0
- data/README.md +443 -16
- data/Rakefile +49 -0
- data/benchmark/README.md +349 -0
- data/benchmark/baseline_v0.3.1.txt +67 -0
- data/benchmark/baseline_v0.4.0.txt +167 -0
- data/benchmark/benchmark_helper.rb +92 -0
- data/benchmark/compare_versions.rb +136 -0
- data/benchmark/cops_benchmark.rb +428 -0
- data/benchmark/cops_performance.rb +109 -0
- data/benchmark/quick_comparison.rb +58 -0
- data/benchmark/quick_invariant_bench.rb +52 -0
- data/benchmark/rspec_base_integration.rb +86 -0
- data/benchmark/save_baseline.rb +18 -0
- data/benchmark/scalability_benchmark.rb +181 -0
- data/config/default.yml +43 -2
- data/config/obsoletion.yml +6 -0
- data/lib/rubocop/cop/factory_bot_guide/dynamic_attribute_evaluation.rb +193 -0
- data/lib/rubocop/cop/factory_bot_guide/dynamic_attributes_for_time_and_random.rb +10 -106
- data/lib/rubocop/cop/rspec_guide/characteristics_and_contexts.rb +13 -78
- data/lib/rubocop/cop/rspec_guide/context_setup.rb +81 -30
- data/lib/rubocop/cop/rspec_guide/duplicate_before_hooks.rb +89 -22
- data/lib/rubocop/cop/rspec_guide/duplicate_let_values.rb +89 -22
- data/lib/rubocop/cop/rspec_guide/happy_path_first.rb +52 -21
- data/lib/rubocop/cop/rspec_guide/invariant_examples.rb +60 -19
- data/lib/rubocop/cop/rspec_guide/minimum_behavioral_coverage.rb +165 -0
- data/lib/rubocop/rspec/guide/inject.rb +26 -0
- data/lib/rubocop/rspec/guide/plugin.rb +45 -0
- data/lib/rubocop/rspec/guide/version.rb +1 -1
- data/lib/rubocop-rspec-guide.rb +4 -0
- metadata +49 -1
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
module RuboCop
|
|
4
4
|
module Cop
|
|
5
5
|
module RSpecGuide
|
|
6
|
-
# Detects duplicate let declarations with identical values across
|
|
7
|
-
# These should be extracted to the parent context.
|
|
6
|
+
# Detects duplicate let declarations with identical values across sibling contexts.
|
|
8
7
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# When the same let with the same value 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 only detects duplicates,
|
|
15
|
+
# not semantic issues.
|
|
16
|
+
#
|
|
17
|
+
# @example ERROR - duplicate in ALL contexts
|
|
18
|
+
# # bad - let(:currency) duplicated in all 2 contexts
|
|
11
19
|
# describe 'Calculator' do
|
|
12
20
|
# context 'with addition' do
|
|
13
21
|
# let(:currency) { :usd }
|
|
@@ -15,14 +23,14 @@ module RuboCop
|
|
|
15
23
|
# end
|
|
16
24
|
#
|
|
17
25
|
# context 'with subtraction' do
|
|
18
|
-
# let(:currency) { :usd } #
|
|
26
|
+
# let(:currency) { :usd } # ERROR: in all contexts!
|
|
19
27
|
# it { expect(result).to eq(5) }
|
|
20
28
|
# end
|
|
21
29
|
# end
|
|
22
30
|
#
|
|
23
|
-
# # good
|
|
31
|
+
# # good - extracted to parent
|
|
24
32
|
# describe 'Calculator' do
|
|
25
|
-
# let(:currency) { :usd } #
|
|
33
|
+
# let(:currency) { :usd } # Moved to parent
|
|
26
34
|
#
|
|
27
35
|
# context 'with addition' do
|
|
28
36
|
# it { expect(result).to eq(10) }
|
|
@@ -33,33 +41,92 @@ module RuboCop
|
|
|
33
41
|
# end
|
|
34
42
|
# end
|
|
35
43
|
#
|
|
36
|
-
|
|
44
|
+
# @example WARNING - duplicate in SOME contexts
|
|
45
|
+
# # bad - let(:mode) duplicated in 2/3 contexts (code smell)
|
|
46
|
+
# describe 'Processor' do
|
|
47
|
+
# context 'scenario A' do
|
|
48
|
+
# let(:mode) { :standard }
|
|
49
|
+
# it { expect(result).to eq('A') }
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# context 'scenario B' do
|
|
53
|
+
# let(:mode) { :standard } # WARNING: duplicated in 2/3
|
|
54
|
+
# it { expect(result).to eq('B') }
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# context 'scenario C' do
|
|
58
|
+
# let(:mode) { :advanced } # Different value
|
|
59
|
+
# it { expect(result).to eq('C') }
|
|
60
|
+
# end
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# # good - refactor hierarchy
|
|
64
|
+
# describe 'Processor' do
|
|
65
|
+
# context 'with standard mode' do
|
|
66
|
+
# let(:mode) { :standard }
|
|
67
|
+
#
|
|
68
|
+
# context 'scenario A' do
|
|
69
|
+
# it { expect(result).to eq('A') }
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# context 'scenario B' do
|
|
73
|
+
# it { expect(result).to eq('B') }
|
|
74
|
+
# end
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# context 'with advanced mode' do
|
|
78
|
+
# let(:mode) { :advanced }
|
|
79
|
+
#
|
|
80
|
+
# context 'scenario C' do
|
|
81
|
+
# it { expect(result).to eq('C') }
|
|
82
|
+
# end
|
|
83
|
+
# end
|
|
84
|
+
# end
|
|
85
|
+
#
|
|
86
|
+
# @example Configuration
|
|
87
|
+
# # To disable warnings for partial duplicates:
|
|
88
|
+
# RSpecGuide/DuplicateLetValues:
|
|
89
|
+
# WarnOnPartialDuplicates: false # Only report full duplicates
|
|
90
|
+
#
|
|
91
|
+
# @example Edge case - different values
|
|
92
|
+
# # good - same let name but different values (no duplicate)
|
|
93
|
+
# describe 'Converter' do
|
|
94
|
+
# context 'to USD' do
|
|
95
|
+
# let(:currency) { :usd }
|
|
96
|
+
# it { expect(convert).to eq(100) }
|
|
97
|
+
# end
|
|
98
|
+
#
|
|
99
|
+
# context 'to EUR' do
|
|
100
|
+
# let(:currency) { :eur } # Different value, OK
|
|
101
|
+
# it { expect(convert).to eq(85) }
|
|
102
|
+
# end
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
class DuplicateLetValues < RuboCop::Cop::RSpec::Base
|
|
37
106
|
MSG_ERROR = "Duplicate `let(:%<name>s)` with same value `%<value>s` " \
|
|
38
107
|
"in ALL sibling contexts. Extract to parent context."
|
|
39
108
|
MSG_WARNING = "Let `:%<name>s` with value `%<value>s` duplicated in %<count>d/%<total>d contexts. " \
|
|
40
109
|
"Consider refactoring test hierarchy - this suggests poor organization."
|
|
41
110
|
|
|
42
|
-
#
|
|
43
|
-
|
|
111
|
+
# Using rubocop-rspec API: example_group?(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
|
|
48
|
-
def_node_matcher :
|
|
119
|
+
# @!method let_with_name_and_value?(node)
|
|
120
|
+
def_node_matcher :let_with_name_and_value?, <<~PATTERN
|
|
49
121
|
(block
|
|
50
122
|
(send nil? {:let :let!} (sym $_name))
|
|
51
123
|
(args)
|
|
52
124
|
$_value)
|
|
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? &&
|
|
90
|
-
elsif body.block_type? &&
|
|
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
|
|
@@ -105,7 +172,7 @@ module RuboCop
|
|
|
105
172
|
(block_node.parent.begin_type? && block_node.parent.parent == context_node)
|
|
106
173
|
next unless is_immediate_child
|
|
107
174
|
|
|
108
|
-
|
|
175
|
+
let_with_name_and_value?(block_node) do |name, value|
|
|
109
176
|
# Only check simple values that can be compared by source
|
|
110
177
|
if simple_value?(value)
|
|
111
178
|
lets[name] = {value: value.source, node: block_node}
|
|
@@ -4,55 +4,88 @@ module RuboCop
|
|
|
4
4
|
module Cop
|
|
5
5
|
module RSpecGuide
|
|
6
6
|
# Checks that corner cases are not the first context in a describe block.
|
|
7
|
-
#
|
|
7
|
+
#
|
|
8
|
+
# Placing happy path scenarios first improves test readability by establishing
|
|
9
|
+
# the expected behavior before diving into edge cases. This makes it easier for
|
|
10
|
+
# readers to understand the primary purpose of the code being tested.
|
|
8
11
|
#
|
|
9
12
|
# The cop allows corner case contexts to appear first if there are
|
|
10
13
|
# example blocks (it/specify) before the first context, as those examples
|
|
11
14
|
# represent the happy path.
|
|
12
15
|
#
|
|
13
|
-
# @
|
|
14
|
-
#
|
|
16
|
+
# @safety
|
|
17
|
+
# This cop is safe to run automatically. It detects corner case indicators
|
|
18
|
+
# like 'but', 'however', 'not', 'without', 'except', etc.
|
|
19
|
+
#
|
|
20
|
+
# @example Bad - corner case first
|
|
21
|
+
# # bad - starts with negative case
|
|
15
22
|
# describe '#process' do
|
|
16
23
|
# context 'but user is blocked' do
|
|
17
|
-
#
|
|
24
|
+
# it { expect { process }.to raise_error }
|
|
18
25
|
# end
|
|
26
|
+
#
|
|
19
27
|
# context 'when user is valid' do
|
|
20
|
-
#
|
|
28
|
+
# it { expect(process).to be_success }
|
|
21
29
|
# end
|
|
22
30
|
# end
|
|
23
31
|
#
|
|
24
|
-
# # bad
|
|
32
|
+
# # bad - starts with NOT condition
|
|
25
33
|
# describe '#activate' do
|
|
26
34
|
# context 'when user does NOT exist' do
|
|
27
|
-
#
|
|
35
|
+
# it { expect { activate }.to raise_error(NotFound) }
|
|
28
36
|
# end
|
|
37
|
+
#
|
|
29
38
|
# context 'when user exists' do
|
|
30
|
-
#
|
|
39
|
+
# it { expect(activate).to be_truthy }
|
|
31
40
|
# end
|
|
32
41
|
# end
|
|
33
42
|
#
|
|
34
|
-
#
|
|
43
|
+
# @example Good - happy path first
|
|
44
|
+
# # good - happy path comes first
|
|
35
45
|
# describe '#subscribe' do
|
|
36
46
|
# context 'with valid card' do
|
|
37
|
-
#
|
|
47
|
+
# it { expect(subscribe).to be_success }
|
|
38
48
|
# end
|
|
49
|
+
#
|
|
39
50
|
# context 'but payment fails' do
|
|
40
|
-
#
|
|
51
|
+
# it { expect(subscribe).to be_failure }
|
|
52
|
+
# end
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# # good - positive case before negative
|
|
56
|
+
# describe '#send_notification' do
|
|
57
|
+
# context 'when user has email' do
|
|
58
|
+
# it { expect(send_notification).to be_sent }
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# context 'without email' do
|
|
62
|
+
# it { expect(send_notification).to be_skipped }
|
|
41
63
|
# end
|
|
42
64
|
# end
|
|
43
65
|
#
|
|
66
|
+
# @example Edge case - it-blocks represent happy path
|
|
44
67
|
# # good - examples before first context represent happy path
|
|
45
68
|
# describe '#add_child' do
|
|
46
69
|
# it 'adds child to children collection' do
|
|
47
|
-
#
|
|
70
|
+
# expect { add_child(child) }.to change(parent.children, :count).by(1)
|
|
48
71
|
# end
|
|
49
72
|
#
|
|
50
73
|
# context 'but child is already in collection' do
|
|
51
|
-
#
|
|
74
|
+
# it { expect { add_child(child) }.not_to change(parent.children, :count) }
|
|
52
75
|
# end
|
|
53
76
|
# end
|
|
54
77
|
#
|
|
55
|
-
|
|
78
|
+
# # good - multiple it-blocks as happy path
|
|
79
|
+
# describe '#calculate' do
|
|
80
|
+
# it { expect(calculate).to be_a(Numeric) }
|
|
81
|
+
# it { expect(calculate).to be_positive }
|
|
82
|
+
#
|
|
83
|
+
# context 'with invalid input' do
|
|
84
|
+
# it { expect { calculate }.to raise_error }
|
|
85
|
+
# end
|
|
86
|
+
# end
|
|
87
|
+
#
|
|
88
|
+
class HappyPathFirst < RuboCop::Cop::RSpec::Base
|
|
56
89
|
MSG = "Place happy path contexts before corner cases. " \
|
|
57
90
|
"First context appears to be a corner case: %<description>s"
|
|
58
91
|
|
|
@@ -62,6 +95,9 @@ module RuboCop
|
|
|
62
95
|
fails missing absent unavailable
|
|
63
96
|
].freeze
|
|
64
97
|
|
|
98
|
+
# Using rubocop-rspec API: example_group?(node) from Base
|
|
99
|
+
# Custom matcher for context with description:
|
|
100
|
+
|
|
65
101
|
# @!method context_with_description?(node)
|
|
66
102
|
def_node_matcher :context_with_description?, <<~PATTERN
|
|
67
103
|
(block
|
|
@@ -69,14 +105,9 @@ module RuboCop
|
|
|
69
105
|
...)
|
|
70
106
|
PATTERN
|
|
71
107
|
|
|
72
|
-
# @!method example_group?(node)
|
|
73
|
-
def_node_matcher :example_group?, <<~PATTERN
|
|
74
|
-
(block
|
|
75
|
-
(send nil? {:describe :context} ...)
|
|
76
|
-
...)
|
|
77
|
-
PATTERN
|
|
78
|
-
|
|
79
108
|
def on_block(node)
|
|
109
|
+
# Fast pre-check: only process describe/context blocks
|
|
110
|
+
return unless node.method?(:describe) || node.method?(:context)
|
|
80
111
|
return unless example_group?(node)
|
|
81
112
|
|
|
82
113
|
contexts = collect_direct_child_contexts(node)
|
|
@@ -4,10 +4,21 @@ module RuboCop
|
|
|
4
4
|
module Cop
|
|
5
5
|
module RSpecGuide
|
|
6
6
|
# Detects examples that repeat in all leaf contexts.
|
|
7
|
-
# These invariants should be extracted to shared_examples.
|
|
8
7
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# When the same test appears in all leaf contexts, it indicates an invariant -
|
|
9
|
+
# a property that holds true regardless of the context. These invariants represent
|
|
10
|
+
# interface contracts and should be extracted to shared_examples for reusability
|
|
11
|
+
# and clarity.
|
|
12
|
+
#
|
|
13
|
+
# The cop only reports when examples appear in MinLeafContexts or more contexts
|
|
14
|
+
# (default: 3) to avoid false positives.
|
|
15
|
+
#
|
|
16
|
+
# @safety
|
|
17
|
+
# This cop is safe to run automatically. It compares example descriptions
|
|
18
|
+
# for exact string matches.
|
|
19
|
+
#
|
|
20
|
+
# @example Bad - repeated invariant
|
|
21
|
+
# # bad - 'responds to valid?' repeated in all 3 contexts
|
|
11
22
|
# describe 'Validator' do
|
|
12
23
|
# context 'with valid data' do
|
|
13
24
|
# it 'responds to valid?' do
|
|
@@ -28,7 +39,8 @@ module RuboCop
|
|
|
28
39
|
# end
|
|
29
40
|
# end
|
|
30
41
|
#
|
|
31
|
-
#
|
|
42
|
+
# @example Good - extracted to shared_examples
|
|
43
|
+
# # good - invariant extracted to shared_examples
|
|
32
44
|
# shared_examples 'a validator' do
|
|
33
45
|
# it 'responds to valid?' do
|
|
34
46
|
# expect(subject).to respond_to(:valid?)
|
|
@@ -38,44 +50,72 @@ module RuboCop
|
|
|
38
50
|
# describe 'Validator' do
|
|
39
51
|
# context 'with valid data' do
|
|
40
52
|
# it_behaves_like 'a validator'
|
|
53
|
+
# it { expect(subject.valid?).to be true }
|
|
41
54
|
# end
|
|
42
55
|
#
|
|
43
56
|
# context 'with invalid data' do
|
|
44
57
|
# it_behaves_like 'a validator'
|
|
58
|
+
# it { expect(subject.valid?).to be false }
|
|
45
59
|
# end
|
|
46
60
|
#
|
|
47
61
|
# context 'with empty data' do
|
|
48
62
|
# it_behaves_like 'a validator'
|
|
63
|
+
# it { expect(subject.valid?).to be false }
|
|
49
64
|
# end
|
|
50
65
|
# end
|
|
51
66
|
#
|
|
52
|
-
|
|
67
|
+
# @example Configuration
|
|
68
|
+
# # Adjust minimum contexts threshold:
|
|
69
|
+
# RSpecGuide/InvariantExamples:
|
|
70
|
+
# MinLeafContexts: 3 # Default: report if in 3+ contexts
|
|
71
|
+
#
|
|
72
|
+
# # For larger test suites, use higher threshold:
|
|
73
|
+
# RSpecGuide/InvariantExamples:
|
|
74
|
+
# MinLeafContexts: 5 # Only report if in 5+ contexts
|
|
75
|
+
#
|
|
76
|
+
# @example Edge case - not in all contexts
|
|
77
|
+
# # good - test only in 2 out of 3 contexts (not invariant)
|
|
78
|
+
# describe 'Calculator' do
|
|
79
|
+
# context 'with addition' do
|
|
80
|
+
# it 'returns numeric' { expect(result).to be_a(Numeric) }
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# context 'with subtraction' do
|
|
84
|
+
# it 'returns numeric' { expect(result).to be_a(Numeric) }
|
|
85
|
+
# end
|
|
86
|
+
#
|
|
87
|
+
# context 'with division by zero' do
|
|
88
|
+
# it 'raises error' { expect { result }.to raise_error }
|
|
89
|
+
# # 'returns numeric' not here - not an invariant
|
|
90
|
+
# end
|
|
91
|
+
# end
|
|
92
|
+
#
|
|
93
|
+
class InvariantExamples < RuboCop::Cop::RSpec::Base
|
|
53
94
|
MSG = "Example `%<description>s` repeats in all %<count>d leaf contexts. " \
|
|
54
95
|
"Consider extracting to shared_examples as an interface invariant."
|
|
55
96
|
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
(block
|
|
59
|
-
(send nil? {:it :specify :example} (str $_description))
|
|
60
|
-
...)
|
|
61
|
-
PATTERN
|
|
97
|
+
# Using rubocop-rspec API: example_group?(node) from Base for top-level check
|
|
98
|
+
# Custom matchers for performance-critical internal checks:
|
|
62
99
|
|
|
63
|
-
#
|
|
64
|
-
|
|
100
|
+
# Fast local matcher for nested context/describe checks (performance-critical)
|
|
101
|
+
# @!method context_or_describe_block?(node)
|
|
102
|
+
def_node_matcher :context_or_describe_block?, <<~PATTERN
|
|
65
103
|
(block
|
|
66
104
|
(send nil? {:describe :context} ...)
|
|
67
105
|
...)
|
|
68
106
|
PATTERN
|
|
69
107
|
|
|
70
|
-
# @!method
|
|
71
|
-
def_node_matcher :
|
|
108
|
+
# @!method example_with_description?(node)
|
|
109
|
+
def_node_matcher :example_with_description?, <<~PATTERN
|
|
72
110
|
(block
|
|
73
|
-
(send nil? :
|
|
111
|
+
(send nil? {:it :specify :example} (str $_description))
|
|
74
112
|
...)
|
|
75
113
|
PATTERN
|
|
76
114
|
|
|
77
115
|
def on_block(node)
|
|
78
|
-
|
|
116
|
+
# Fast pre-check: only process top-level describe (not context)
|
|
117
|
+
return unless node.method?(:describe)
|
|
118
|
+
return unless example_group?(node)
|
|
79
119
|
|
|
80
120
|
# Find all leaf contexts (contexts with no nested contexts)
|
|
81
121
|
leaf_contexts = find_leaf_contexts(node)
|
|
@@ -113,11 +153,12 @@ module RuboCop
|
|
|
113
153
|
leaves = []
|
|
114
154
|
|
|
115
155
|
node.each_descendant(:block) do |child|
|
|
116
|
-
|
|
156
|
+
# Use fast local matcher for performance-critical nested checks
|
|
157
|
+
next unless context_or_describe_block?(child)
|
|
117
158
|
|
|
118
159
|
# Check if this context has nested contexts
|
|
119
160
|
has_nested = child.each_descendant(:block).any? do |nested|
|
|
120
|
-
|
|
161
|
+
context_or_describe_block?(nested) && nested != child
|
|
121
162
|
end
|
|
122
163
|
|
|
123
164
|
leaves << child unless has_nested
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module RSpecGuide
|
|
6
|
+
# Checks that describe blocks test at least 2 behavioral variations.
|
|
7
|
+
#
|
|
8
|
+
# Testing only a single scenario (happy path OR edge case) provides
|
|
9
|
+
# insufficient coverage. Tests should verify both expected behavior
|
|
10
|
+
# and edge case handling to ensure comprehensive validation.
|
|
11
|
+
#
|
|
12
|
+
# This can be achieved in two ways:
|
|
13
|
+
# 1. Use 2+ sibling context blocks (happy path + edge cases)
|
|
14
|
+
# 2. Combine it-blocks (default behavior) with context-blocks (edge cases)
|
|
15
|
+
#
|
|
16
|
+
# @safety
|
|
17
|
+
# This cop is safe to run automatically. For simple methods like getters
|
|
18
|
+
# with no edge cases, use `# rubocop:disable RSpecGuide/MinimumBehavioralCoverage`
|
|
19
|
+
#
|
|
20
|
+
# @example Traditional approach - 2+ sibling contexts
|
|
21
|
+
# # bad - only one scenario (no edge case testing)
|
|
22
|
+
# describe '#calculate' do
|
|
23
|
+
# context 'with valid data' do
|
|
24
|
+
# it { expect(result).to eq(100) }
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# # good - multiple scenarios (happy path + edge cases)
|
|
29
|
+
# describe '#calculate' do
|
|
30
|
+
# context 'with valid data' do
|
|
31
|
+
# it { expect(result).to eq(100) }
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# context 'with invalid data' do
|
|
35
|
+
# it { expect { result }.to raise_error(ValidationError) }
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# # good - more comprehensive coverage
|
|
40
|
+
# describe '#calculate' do
|
|
41
|
+
# context 'with positive numbers' do
|
|
42
|
+
# it { expect(result).to eq(100) }
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# context 'with zero' do
|
|
46
|
+
# it { expect(result).to eq(0) }
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# context 'with negative numbers' do
|
|
50
|
+
# it { expect { result }.to raise_error(ArgumentError) }
|
|
51
|
+
# end
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# @example New pattern - it-blocks + context-blocks
|
|
55
|
+
# # bad - only default behavior, no edge cases
|
|
56
|
+
# describe '#calculate' do
|
|
57
|
+
# it 'calculates sum' { expect(result).to eq(100) }
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# # good - default behavior + edge case
|
|
61
|
+
# describe '#calculate' do
|
|
62
|
+
# it 'calculates sum with defaults' { expect(result).to eq(100) }
|
|
63
|
+
#
|
|
64
|
+
# context 'with invalid input' do
|
|
65
|
+
# it { expect { result }.to raise_error(ValidationError) }
|
|
66
|
+
# end
|
|
67
|
+
# end
|
|
68
|
+
#
|
|
69
|
+
# # good - multiple it-blocks for defaults, context for edge case
|
|
70
|
+
# describe '#calculate' do
|
|
71
|
+
# it 'returns numeric result' { expect(result).to be_a(Numeric) }
|
|
72
|
+
# it 'is positive' { expect(result).to be > 0 }
|
|
73
|
+
#
|
|
74
|
+
# context 'with special conditions' do
|
|
75
|
+
# it { expect(result).to eq(0) }
|
|
76
|
+
# end
|
|
77
|
+
# end
|
|
78
|
+
#
|
|
79
|
+
# @example Edge case - setup before tests (allowed)
|
|
80
|
+
# # good - setup + it-blocks + contexts
|
|
81
|
+
# describe '#calculate' do
|
|
82
|
+
# let(:calculator) { Calculator.new }
|
|
83
|
+
# before { calculator.configure }
|
|
84
|
+
#
|
|
85
|
+
# it 'works with defaults' { expect(result).to eq(100) }
|
|
86
|
+
#
|
|
87
|
+
# context 'with custom config' do
|
|
88
|
+
# it { expect(result).to eq(200) }
|
|
89
|
+
# end
|
|
90
|
+
# end
|
|
91
|
+
#
|
|
92
|
+
# @example When to disable this cop
|
|
93
|
+
# # Simple getter with no edge cases - disable is acceptable
|
|
94
|
+
# describe '#name' do # rubocop:disable RSpecGuide/MinimumBehavioralCoverage
|
|
95
|
+
# it { expect(subject.name).to eq('test') }
|
|
96
|
+
# end
|
|
97
|
+
#
|
|
98
|
+
class MinimumBehavioralCoverage < RuboCop::Cop::RSpec::Base
|
|
99
|
+
MSG = "Describe block should test at least 2 behavioral variations: " \
|
|
100
|
+
"either use 2+ sibling contexts (happy path + edge cases), " \
|
|
101
|
+
"or combine it-blocks for default behavior with context-blocks for edge cases. " \
|
|
102
|
+
"Use `# rubocop:disable RSpecGuide/MinimumBehavioralCoverage` " \
|
|
103
|
+
"for simple cases (e.g., getters) with no edge cases."
|
|
104
|
+
|
|
105
|
+
# Using rubocop-rspec API matchers (inherited from RuboCop::Cop::RSpec::Base):
|
|
106
|
+
# - example_group?(node) - checks describe/context blocks
|
|
107
|
+
# - example?(node) - checks it/specify blocks
|
|
108
|
+
# Custom matcher for context-only:
|
|
109
|
+
|
|
110
|
+
# @!method context_only?(node)
|
|
111
|
+
def_node_matcher :context_only?, <<~PATTERN
|
|
112
|
+
(block (send nil? :context ...) ...)
|
|
113
|
+
PATTERN
|
|
114
|
+
|
|
115
|
+
def on_block(node)
|
|
116
|
+
# Fast pre-check: only process describe blocks (not context)
|
|
117
|
+
return unless node.method?(:describe)
|
|
118
|
+
return unless example_group?(node)
|
|
119
|
+
|
|
120
|
+
children = collect_children(node)
|
|
121
|
+
contexts = children.select { |child| context_only?(child) }
|
|
122
|
+
its = children.select { |child| example?(child) }
|
|
123
|
+
|
|
124
|
+
# Valid if: 2+ contexts OR (1+ it-blocks before contexts AND 1+ contexts)
|
|
125
|
+
return if contexts.size >= 2
|
|
126
|
+
return if valid_it_then_context_pattern?(children, its, contexts)
|
|
127
|
+
|
|
128
|
+
add_offense(node)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def collect_children(node)
|
|
134
|
+
# The body of a describe/context block may be:
|
|
135
|
+
# 1. A single block node (if only one child)
|
|
136
|
+
# 2. A begin node containing multiple children
|
|
137
|
+
body = node.body
|
|
138
|
+
return [] unless body
|
|
139
|
+
|
|
140
|
+
if body.begin_type?
|
|
141
|
+
# Multiple children wrapped in begin node
|
|
142
|
+
body.children.select(&:block_type?)
|
|
143
|
+
elsif body.block_type?
|
|
144
|
+
# Single child
|
|
145
|
+
[body]
|
|
146
|
+
else
|
|
147
|
+
[]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def valid_it_then_context_pattern?(children, its, contexts)
|
|
152
|
+
# Need at least one it-block and at least one context-block
|
|
153
|
+
return false if its.empty? || contexts.empty?
|
|
154
|
+
|
|
155
|
+
# Find positions of first it-block and first context-block
|
|
156
|
+
first_it_index = children.index { |child| example?(child) }
|
|
157
|
+
first_context_index = children.index { |child| context_only?(child) }
|
|
158
|
+
|
|
159
|
+
# All it-blocks must come before all context-blocks
|
|
160
|
+
first_it_index < first_context_index
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Inject our default configuration into RuboCop
|
|
4
|
+
# This ensures config/default.yml is loaded automatically
|
|
5
|
+
module RuboCop
|
|
6
|
+
module RSpec
|
|
7
|
+
module Guide
|
|
8
|
+
# Inject default configuration
|
|
9
|
+
module Inject
|
|
10
|
+
DEFAULT_FILE = File.expand_path("../../../../../config/default.yml", __FILE__)
|
|
11
|
+
|
|
12
|
+
def self.defaults!
|
|
13
|
+
path = DEFAULT_FILE
|
|
14
|
+
hash = ConfigLoader.send(:load_yaml_configuration, path)
|
|
15
|
+
config = Config.new(hash, path).tap(&:make_excludes_absolute)
|
|
16
|
+
puts "configuration from #{path}" if ConfigLoader.debug?
|
|
17
|
+
config = ConfigLoader.merge_with_default(config, path)
|
|
18
|
+
ConfigLoader.instance_variable_set(:@default_configuration, config)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Inject defaults when the gem is loaded
|
|
26
|
+
RuboCop::RSpec::Guide::Inject.defaults!
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module RSpec
|
|
5
|
+
module Guide
|
|
6
|
+
# Plugin integration for RuboCop 1.72+
|
|
7
|
+
#
|
|
8
|
+
# This class provides the modern plugin API for RuboCop.
|
|
9
|
+
# It allows loading the gem via the `plugins:` configuration
|
|
10
|
+
# in .rubocop.yml instead of the legacy `require:` approach.
|
|
11
|
+
#
|
|
12
|
+
# @example Modern approach (RuboCop 1.72+)
|
|
13
|
+
# # .rubocop.yml
|
|
14
|
+
# plugins:
|
|
15
|
+
# - rubocop-rspec-guide
|
|
16
|
+
#
|
|
17
|
+
# @example Legacy approach (still supported)
|
|
18
|
+
# # .rubocop.yml
|
|
19
|
+
# require:
|
|
20
|
+
# - rubocop-rspec-guide
|
|
21
|
+
class Plugin
|
|
22
|
+
# Initialize the plugin
|
|
23
|
+
#
|
|
24
|
+
# @param config [RuboCop::Config, nil] the RuboCop configuration
|
|
25
|
+
def initialize(config = nil)
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Plugin metadata
|
|
30
|
+
#
|
|
31
|
+
# @return [String] the plugin name
|
|
32
|
+
def name
|
|
33
|
+
"rubocop-rspec-guide"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Plugin version
|
|
37
|
+
#
|
|
38
|
+
# @return [String] the plugin version
|
|
39
|
+
def version
|
|
40
|
+
RuboCop::RSpec::Guide::VERSION
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|