scully 0.0.1 → 0.1.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/CHANGELOG.md +33 -0
- data/README.md +272 -9
- data/Rakefile +5 -1
- data/exe/scully +6 -0
- data/lib/scully/analyzer.rb +90 -0
- data/lib/scully/cli.rb +126 -0
- data/lib/scully/discovery.rb +53 -0
- data/lib/scully/frameworks/detector.rb +86 -0
- data/lib/scully/frameworks/helpers.rb +215 -0
- data/lib/scully/frameworks/minitest.rb +305 -0
- data/lib/scully/frameworks/rspec.rb +386 -0
- data/lib/scully/model.rb +48 -0
- data/lib/scully/render/report.rb +79 -0
- data/lib/scully/render/snippet.rb +92 -0
- data/lib/scully/render/terminal.rb +97 -0
- data/lib/scully/render/why.rb +146 -0
- data/lib/scully/rule.rb +85 -0
- data/lib/scully/rules/no_assertion.rb +51 -0
- data/lib/scully/rules/parse.rb +34 -0
- data/lib/scully/rules/tautology.rb +101 -0
- data/lib/scully/source_file.rb +53 -0
- data/lib/scully/version.rb +1 -1
- data/lib/scully.rb +18 -1
- metadata +39 -6
- data/sig/test_scully.rbs +0 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Scully
|
|
6
|
+
module Frameworks
|
|
7
|
+
# Decides whether a file holds RSpec or Minitest tests: file content first, then its path.
|
|
8
|
+
module Detector
|
|
9
|
+
SKIP_REASON = 'no RSpec or Minitest test structure found'
|
|
10
|
+
|
|
11
|
+
RSPEC_HELPERS = %w[spec_helper rails_helper].freeze
|
|
12
|
+
MINITEST_HELPERS = %w[test_helper].freeze
|
|
13
|
+
# Every example group name the RSpec normalizer reads, so a file it can analyze is not skipped.
|
|
14
|
+
RSPEC_GROUPS = %i[describe context feature example_group shared_examples shared_examples_for shared_context
|
|
15
|
+
fdescribe fcontext ffeature xdescribe xcontext xfeature].freeze
|
|
16
|
+
MINITEST_BASE = /(Test|TestCase)\z/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Returns :rspec, :minitest, or nil when the file shows no test structure.
|
|
21
|
+
# +program+ is the parsed ProgramNode, or nil when the file could not be parsed.
|
|
22
|
+
def detect(path, program)
|
|
23
|
+
from_content(program) || from_path(path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def from_content(program)
|
|
27
|
+
return unless program
|
|
28
|
+
|
|
29
|
+
statements = program.statements.body
|
|
30
|
+
requires = statements.filter_map { required_feature(it) }
|
|
31
|
+
return :rspec if rspec_describe?(statements) || requires.any? { RSPEC_HELPERS.include?(File.basename(it)) }
|
|
32
|
+
return :minitest if minitest_require?(requires) || minitest_class?(program)
|
|
33
|
+
|
|
34
|
+
:rspec if statements.any? { group_call?(it) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def from_path(path)
|
|
38
|
+
segments = File.dirname(path).split('/')
|
|
39
|
+
return :rspec if segments.include?('spec')
|
|
40
|
+
|
|
41
|
+
:minitest if segments.include?('test')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def required_feature(node)
|
|
45
|
+
return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
46
|
+
return unless %i[require require_relative].include?(node.name)
|
|
47
|
+
|
|
48
|
+
argument = node.arguments&.arguments&.first
|
|
49
|
+
argument.unescaped if argument.is_a?(Prism::StringNode)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# RSpec.describe and friends name RSpec explicitly; a bare describe may be minitest/spec.
|
|
53
|
+
def rspec_describe?(statements)
|
|
54
|
+
statements.any? { group_call?(it) && rspec_receiver?(it.receiver) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def rspec_receiver?(receiver)
|
|
58
|
+
receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def minitest_require?(requires)
|
|
62
|
+
requires.any? do
|
|
63
|
+
it == 'minitest' || it.start_with?('minitest/') || MINITEST_HELPERS.include?(File.basename(it))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def group_call?(node)
|
|
68
|
+
node.is_a?(Prism::CallNode) && RSPEC_GROUPS.include?(node.name) &&
|
|
69
|
+
(node.receiver.nil? || rspec_receiver?(node.receiver))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def minitest_class?(node)
|
|
73
|
+
return true if node.is_a?(Prism::ClassNode) && minitest_base?(node.superclass)
|
|
74
|
+
|
|
75
|
+
node.compact_child_nodes.any? { minitest_class?(it) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def minitest_base?(superclass)
|
|
79
|
+
case superclass
|
|
80
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then MINITEST_BASE.match?(superclass.name.to_s)
|
|
81
|
+
else false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Scully
|
|
6
|
+
module Frameworks
|
|
7
|
+
# Resolves calls to helper methods against the methods defined in the same file.
|
|
8
|
+
#
|
|
9
|
+
# A helper counts as an assertion when its name starts with `assert_` or `expect_`, or when
|
|
10
|
+
# it is defined in the file and its body makes an assertion, directly or through other
|
|
11
|
+
# helpers defined in the file.
|
|
12
|
+
class Helpers
|
|
13
|
+
CONVENTION = /\A(assert|expect)_/
|
|
14
|
+
|
|
15
|
+
# +program+ is the file's ProgramNode; +assertion+ is called with each CallNode and says
|
|
16
|
+
# whether it is an assertion in the file's framework.
|
|
17
|
+
def initialize(program, assertion:)
|
|
18
|
+
@assertion = assertion
|
|
19
|
+
@methods = {}
|
|
20
|
+
collect_methods(program)
|
|
21
|
+
@asserting = asserting_methods
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# :convention, :same_file_with_assertion, :same_file_without_assertion or :unresolved.
|
|
25
|
+
def resolve(name)
|
|
26
|
+
return :convention if CONVENTION.match?(name)
|
|
27
|
+
return :unresolved unless @methods.key?(name)
|
|
28
|
+
|
|
29
|
+
@asserting.include?(name) ? :same_file_with_assertion : :same_file_without_assertion
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def assertion?(name) = %i[convention same_file_with_assertion].include?(resolve(name))
|
|
33
|
+
|
|
34
|
+
def defined?(name) = @methods.key?(name)
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Later definitions replace earlier ones, as they do when Ruby loads the file.
|
|
39
|
+
def collect_methods(node)
|
|
40
|
+
@methods[node.name] = node if node.is_a?(Prism::DefNode)
|
|
41
|
+
node.compact_child_nodes.each { collect_methods(it) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fixed point: start from methods that assert directly, then add methods that call one of
|
|
45
|
+
# them until nothing changes. Terminates on recursive helpers.
|
|
46
|
+
def asserting_methods
|
|
47
|
+
calls = @methods.transform_values { helper_calls_in(it) }
|
|
48
|
+
asserting = @methods.select { |_, node| asserts_directly?(node) }.keys.to_set
|
|
49
|
+
loop do
|
|
50
|
+
added = calls.select { |name, called| !asserting.include?(name) && called.intersect?(asserting) }.keys
|
|
51
|
+
return asserting if added.empty?
|
|
52
|
+
|
|
53
|
+
asserting.merge(added)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def asserts_directly?(node) = calls_in(node).any? { @assertion.call(it) || convention_call?(it) }
|
|
58
|
+
|
|
59
|
+
def helper_calls_in(node) = calls_in(node).select { it.receiver.nil? }.to_set(&:name)
|
|
60
|
+
|
|
61
|
+
def convention_call?(call) = call.receiver.nil? && CONVENTION.match?(call.name)
|
|
62
|
+
|
|
63
|
+
def calls_in(node, found = [])
|
|
64
|
+
node.compact_child_nodes.each do |child|
|
|
65
|
+
found << child if child.is_a?(Prism::CallNode)
|
|
66
|
+
calls_in(child, found) unless child.is_a?(Prism::DefNode)
|
|
67
|
+
end
|
|
68
|
+
found
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reading of DSL calls shared by both normalizers.
|
|
73
|
+
module Syntax
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
def statements(node) = node.is_a?(Prism::StatementsNode) ? node.body : [node].compact
|
|
77
|
+
|
|
78
|
+
# `it "..."` up to its block: the call name and its arguments, with parentheses if any.
|
|
79
|
+
def definition(call)
|
|
80
|
+
call.message_loc.join(call.closing_loc || call.arguments&.location || call.message_loc)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The description a group or test is written with; symbols and hashes are metadata.
|
|
84
|
+
def description(call)
|
|
85
|
+
case (argument = call.arguments&.arguments&.first)
|
|
86
|
+
when nil, Prism::KeywordHashNode, Prism::SymbolNode then nil
|
|
87
|
+
when Prism::StringNode then argument.unescaped
|
|
88
|
+
when Prism::InterpolatedStringNode then argument.parts.map(&:slice).join
|
|
89
|
+
else argument.slice
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The name a `let(:name)` or `subject(:name)` binds; a bare `subject` binds `subject`.
|
|
94
|
+
def binding_name(call)
|
|
95
|
+
argument = call.arguments&.arguments&.first
|
|
96
|
+
argument.is_a?(Prism::SymbolNode) ? argument.unescaped.to_sym : call.name
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The value a `let` or `subject` block returns: its last expression.
|
|
100
|
+
def block_value(call) = call.block.is_a?(Prism::BlockNode) ? statements(call.block.body).last : nil
|
|
101
|
+
|
|
102
|
+
# An expression that reads +name+, standing for the object a let or variable is bound to.
|
|
103
|
+
def identifier(name) = Prism.parse(name.to_s).value.statements.body.first
|
|
104
|
+
|
|
105
|
+
def arguments(call) = call.arguments&.arguments || []
|
|
106
|
+
|
|
107
|
+
# [method name, value] pairs from `name: value` arguments with symbol keys.
|
|
108
|
+
def keyword_pairs(call)
|
|
109
|
+
arguments(call).grep(Prism::KeywordHashNode).flat_map(&:elements).filter_map do |element|
|
|
110
|
+
next unless element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
|
|
111
|
+
|
|
112
|
+
[element.key.unescaped.to_sym, element.value]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# [call, its receiver, ...] while the receivers are calls: `a.b(1).c` gives [c, b, a].
|
|
117
|
+
def chain(node)
|
|
118
|
+
calls = []
|
|
119
|
+
while node.is_a?(Prism::CallNode)
|
|
120
|
+
calls << node
|
|
121
|
+
node = node.receiver
|
|
122
|
+
end
|
|
123
|
+
calls
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The method name a stub names with its first argument (`receive(:m)`, `stubs(:m)`), or nil.
|
|
127
|
+
def stubbed_name(call)
|
|
128
|
+
argument = arguments(call).first
|
|
129
|
+
argument.is_a?(Prism::SymbolNode) ? argument.unescaped.to_sym : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Local and instance variables assigned in +node+, in source order: [[name, value], ...].
|
|
133
|
+
def assignments(node, found = [])
|
|
134
|
+
node&.compact_child_nodes&.each do |child|
|
|
135
|
+
next if child.is_a?(Prism::DefNode)
|
|
136
|
+
|
|
137
|
+
found << [child.name, child.value] if assignment?(child)
|
|
138
|
+
assignments(child, found)
|
|
139
|
+
end
|
|
140
|
+
found
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def assignment?(node) = node.is_a?(Prism::LocalVariableWriteNode) || node.is_a?(Prism::InstanceVariableWriteNode)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Builds TestDoubles for one file and one origin (:setup or :body).
|
|
147
|
+
class Doubles
|
|
148
|
+
def initialize(source, origin)
|
|
149
|
+
@source = source
|
|
150
|
+
@origin = origin
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# +location+ is the stub's Prism location; +scope+ is the location of the block a stub is
|
|
154
|
+
# limited to, if any.
|
|
155
|
+
# +fields+ are the TestDouble's receiver, method_name and returns.
|
|
156
|
+
def build(location, stub_source: true, scope: nil, **fields)
|
|
157
|
+
TestDouble.new(span: @source.span(location), origin: @origin, scope: scope && @source.span(scope),
|
|
158
|
+
stub_source:, **fields)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Collects the assertions and candidate helper calls in a test body, in source order.
|
|
163
|
+
#
|
|
164
|
+
# +assertion+ is called with each CallNode and returns the Assertion fields other than the
|
|
165
|
+
# span (kind, comparison, subject, expected) when the call is an assertion, or nil. The
|
|
166
|
+
# assertion's subtree is not searched further. +ignored+ says whether a receiverless call
|
|
167
|
+
# name is framework vocabulary or a binding rather than a possible helper.
|
|
168
|
+
class Inventory
|
|
169
|
+
def initialize(source:, helpers:, assertion:, ignored:)
|
|
170
|
+
@source = source
|
|
171
|
+
@helpers = helpers
|
|
172
|
+
@assertion = assertion
|
|
173
|
+
@ignored = ignored
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Returns [assertions, helper_calls] for +body+ (a node or nil).
|
|
177
|
+
def scan(body)
|
|
178
|
+
@assertions = []
|
|
179
|
+
@helper_calls = []
|
|
180
|
+
visit(body) if body
|
|
181
|
+
[@assertions, @helper_calls]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def visit(node)
|
|
187
|
+
node.compact_child_nodes.each do |child|
|
|
188
|
+
next if child.is_a?(Prism::DefNode)
|
|
189
|
+
next if child.is_a?(Prism::CallNode) && assertion(child)
|
|
190
|
+
|
|
191
|
+
helper(child) if child.is_a?(Prism::CallNode)
|
|
192
|
+
visit(child)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def assertion(call)
|
|
197
|
+
fields = @assertion.call(call) or return false
|
|
198
|
+
@assertions << Assertion.new(span: @source.span(call.location), **fields)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def helper(call)
|
|
202
|
+
return unless call.receiver.nil? && !@ignored.call(call.name)
|
|
203
|
+
|
|
204
|
+
@helper_calls << HelperCall.new(name: call.name, span: @source.span(call.message_loc),
|
|
205
|
+
resolution: @helpers.resolve(call.name))
|
|
206
|
+
helper_assertion(call) if @helpers.assertion?(call.name)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def helper_assertion(call)
|
|
210
|
+
@assertions << Assertion.new(span: @source.span(call.location), kind: :helper, comparison: nil,
|
|
211
|
+
subject: nil, expected: nil)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Scully
|
|
6
|
+
module Frameworks
|
|
7
|
+
# Turns a Minitest file into TestCases: `def test_*` methods and Rails-style `test "..." do`
|
|
8
|
+
# blocks in test classes, and minitest/spec `it` blocks inside `describe`.
|
|
9
|
+
class Minitest
|
|
10
|
+
TEST_METHOD = /\Atest_/
|
|
11
|
+
SETUP_METHODS = { before_setup: :setup, setup: :setup, after_setup: :setup,
|
|
12
|
+
before_teardown: :teardown, teardown: :teardown, after_teardown: :teardown }.freeze
|
|
13
|
+
SETUP_CALLS = { setup: :setup, teardown: :teardown, before: :before, after: :teardown, let: :let,
|
|
14
|
+
subject: :subject }.freeze
|
|
15
|
+
GROUPS = %i[describe].freeze
|
|
16
|
+
EXAMPLES = %i[it specify].freeze
|
|
17
|
+
|
|
18
|
+
# The class, group names, setup, bindings and setup doubles visible at a point in the file.
|
|
19
|
+
Context = Data.define(:class_name, :names, :setup, :bindings, :doubles)
|
|
20
|
+
# Setup kinds whose code runs before the test body.
|
|
21
|
+
BEFORE = %i[setup before].freeze
|
|
22
|
+
|
|
23
|
+
include Syntax
|
|
24
|
+
|
|
25
|
+
def self.call(source) = new(source).test_cases
|
|
26
|
+
|
|
27
|
+
def initialize(source)
|
|
28
|
+
@source = source
|
|
29
|
+
@helpers = Helpers.new(source.result.value, assertion: ->(call) { !Assertions.fields(call).nil? })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_cases
|
|
33
|
+
@tests = []
|
|
34
|
+
walk(@source.result.value.statements,
|
|
35
|
+
Context.new(class_name: nil, names: [], setup: [], bindings: {}, doubles: []))
|
|
36
|
+
@tests
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def walk(node, context)
|
|
42
|
+
statements(node).each { statement(it, context) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def statement(node, context)
|
|
46
|
+
case node
|
|
47
|
+
when Prism::ClassNode then class_statement(node, context)
|
|
48
|
+
when Prism::ModuleNode then walk(node.body, context)
|
|
49
|
+
when Prism::DefNode then test_method(node, context) if context.class_name && TEST_METHOD.match?(node.name)
|
|
50
|
+
when Prism::CallNode then call_statement(node, context)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def class_statement(node, context)
|
|
55
|
+
return walk(node.body, context) unless Detector.minitest_base?(node.superclass)
|
|
56
|
+
|
|
57
|
+
name = node.constant_path.slice
|
|
58
|
+
walk(node.body, scope(node.body, context.with(class_name: name, names: [name])))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def call_statement(call, context)
|
|
62
|
+
return unless call.receiver.nil?
|
|
63
|
+
|
|
64
|
+
if GROUPS.include?(call.name) && call.block then group(call, context)
|
|
65
|
+
elsif EXAMPLES.include?(call.name) then spec_example(call, context)
|
|
66
|
+
elsif call.name == :test && context.class_name then rails_test(call, context)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def group(call, context)
|
|
71
|
+
walk(call.block.body, scope(call.block.body, context.with(names: [*context.names, *description(call)])))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Adds the setup, bindings and setup doubles declared directly in a class or group body.
|
|
75
|
+
def scope(body, context)
|
|
76
|
+
own = statements(body).filter_map { [it, Scope.kind(it)] if Scope.kind(it) }
|
|
77
|
+
Scope.apply(context, own, Doubles.new(@source, :setup))
|
|
78
|
+
.then { it.with(setup: it.setup + own.map { |node, kind| setup_step(node, kind) }) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def setup_step(node, kind) = SetupStep.new(kind:, span: span(node.location))
|
|
82
|
+
|
|
83
|
+
def span(location) = @source.span(location)
|
|
84
|
+
|
|
85
|
+
def test_method(node, context)
|
|
86
|
+
add(context, node.body, name: "#{context.class_name}##{node.name}",
|
|
87
|
+
definition: node.def_keyword_loc.join(node.name_loc), extent: node.location,
|
|
88
|
+
status: skip_call?(node.body) ? :skipped : :active)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def rails_test(call, context)
|
|
92
|
+
add(context, call.block&.body, name: "#{context.class_name}#test #{call.arguments&.arguments&.first&.slice}",
|
|
93
|
+
definition: definition(call), extent: call.location,
|
|
94
|
+
status: block_status(call, :pending))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A minitest/spec `it` without a block is skipped by Minitest.
|
|
98
|
+
def spec_example(call, context)
|
|
99
|
+
description = description(call) || "example at line #{call.location.start_line}"
|
|
100
|
+
add(context, call.block&.body, name: [*context.names, description].join(' '),
|
|
101
|
+
definition: definition(call), extent: call.location,
|
|
102
|
+
status: block_status(call, :skipped))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def block_status(call, without_block)
|
|
106
|
+
return without_block unless call.block
|
|
107
|
+
|
|
108
|
+
skip_call?(call.block.body) ? :skipped : :active
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def add(context, body, definition:, extent:, **fields)
|
|
112
|
+
assertions, helper_calls = inventory(context).scan(body)
|
|
113
|
+
@tests << TestCase.new(span: span(definition), extent: span(extent), framework: :minitest, setup: context.setup,
|
|
114
|
+
assertions:, helper_calls:, bindings: context.bindings.merge(assignments(body).to_h),
|
|
115
|
+
doubles: context.doubles + Stubs.scan(body, Doubles.new(@source, :body)), **fields)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A `skip` call anywhere in the body skips the test.
|
|
119
|
+
def skip_call?(node)
|
|
120
|
+
return false unless node
|
|
121
|
+
|
|
122
|
+
node.compact_child_nodes.any? do |child|
|
|
123
|
+
next false if child.is_a?(Prism::DefNode)
|
|
124
|
+
|
|
125
|
+
(child.is_a?(Prism::CallNode) && child.receiver.nil? && child.name == :skip) || skip_call?(child)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inventory(context)
|
|
130
|
+
Inventory.new(source: @source, helpers: @helpers, assertion: Assertions.method(:fields),
|
|
131
|
+
ignored: ->(name) { Assertions::VOCABULARY.include?(name) || context.bindings.include?(name) })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# What a class or group's setup gives its tests: bindings and doubles.
|
|
135
|
+
module Scope
|
|
136
|
+
module_function
|
|
137
|
+
|
|
138
|
+
# [node, kind] pairs => +context+ with their bindings and doubles added.
|
|
139
|
+
def apply(context, own, doubles)
|
|
140
|
+
context.with(bindings: context.bindings.merge(*own.map { |node, kind| bindings(node, kind) }),
|
|
141
|
+
doubles: context.doubles + own.flat_map { |node, kind| doubles(node, kind, doubles) })
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The setup kind a `def setup` or `setup do` declares, or nil.
|
|
145
|
+
def kind(node)
|
|
146
|
+
case node
|
|
147
|
+
when Prism::DefNode then SETUP_METHODS[node.name]
|
|
148
|
+
when Prism::CallNode then node.receiver.nil? ? SETUP_CALLS[node.name] : nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# `let(:order) { ... }` binds `order`; setup binds the variables it assigns, such as `@order`.
|
|
153
|
+
def bindings(node, kind)
|
|
154
|
+
return { Syntax.binding_name(node) => Syntax.block_value(node) } if %i[let subject].include?(kind)
|
|
155
|
+
|
|
156
|
+
BEFORE.include?(kind) ? Syntax.assignments(body(node)).to_h : {}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def doubles(node, kind, doubles) = BEFORE.include?(kind) ? Stubs.scan(body(node), doubles) : []
|
|
160
|
+
|
|
161
|
+
def body(node) = node.is_a?(Prism::DefNode) ? node.body : node.block&.body
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Finds Minitest block stubs (`obj.stub(:m, v) do ... end`), mocha stubs and expectations
|
|
165
|
+
# (`obj.stubs(:m).returns(v)`, `obj.expects(:m)`), and `Minitest::Mock#expect`.
|
|
166
|
+
module Stubs
|
|
167
|
+
# mocha method => whether it is a stub source. `expects` can fail on its own.
|
|
168
|
+
MOCHA = { stubs: true, expects: false }.freeze
|
|
169
|
+
|
|
170
|
+
module_function
|
|
171
|
+
|
|
172
|
+
# The TestDoubles in +node+, in source order. +doubles+ is a Frameworks::Doubles.
|
|
173
|
+
def scan(node, doubles, found = [])
|
|
174
|
+
node&.compact_child_nodes&.each do |child|
|
|
175
|
+
next if child.is_a?(Prism::DefNode)
|
|
176
|
+
|
|
177
|
+
matched = mocha(child, doubles) || mock_expect(child, doubles)
|
|
178
|
+
next found.concat(matched) if matched
|
|
179
|
+
|
|
180
|
+
found.concat(block_stub(child, doubles))
|
|
181
|
+
scan(child, doubles, found)
|
|
182
|
+
end
|
|
183
|
+
found
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# `obj.stubs(:m).with(1).returns(v)`: the chain is inside-out, so `stubs` sits under any
|
|
187
|
+
# `with` and `returns` calls. `stubs(m: v)` stubs several methods.
|
|
188
|
+
def mocha(node, doubles)
|
|
189
|
+
chain = Syntax.chain(node)
|
|
190
|
+
index = chain.index { MOCHA.key?(it.name) && it.receiver } or return
|
|
191
|
+
fields = fields(chain[index])
|
|
192
|
+
name = Syntax.stubbed_name(chain[index]) or return pairs(chain[index], node.location, doubles, fields)
|
|
193
|
+
|
|
194
|
+
[doubles.build(node.location, method_name: name, returns: returned(chain[...index]), **fields)]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def fields(call) = { receiver: call.receiver, stub_source: MOCHA[call.name] }
|
|
198
|
+
|
|
199
|
+
def pairs(call, location, doubles, fields)
|
|
200
|
+
Syntax.keyword_pairs(call).map do |name, value|
|
|
201
|
+
doubles.build(location, method_name: name, returns: value, **fields)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The single value given to `returns`; several values are a sequence, not one value.
|
|
206
|
+
def returned(outer)
|
|
207
|
+
returns = outer.find { it.name == :returns } or return
|
|
208
|
+
values = Syntax.arguments(returns)
|
|
209
|
+
values.first if values.size == 1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# `mock.expect(:m, v)` is a message expectation, verified later.
|
|
213
|
+
def mock_expect(node, doubles)
|
|
214
|
+
return unless node.is_a?(Prism::CallNode) && node.name == :expect && node.receiver
|
|
215
|
+
|
|
216
|
+
name = Syntax.stubbed_name(node) or return
|
|
217
|
+
[doubles.build(node.location, receiver: node.receiver, method_name: name,
|
|
218
|
+
returns: Syntax.arguments(node)[1], stub_source: false)]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# `obj.stub(:m, v) do ... end` stubs only inside its block; the block itself is still scanned.
|
|
222
|
+
def block_stub(node, doubles)
|
|
223
|
+
name = block_stub?(node) && Syntax.stubbed_name(node) or return []
|
|
224
|
+
|
|
225
|
+
location = node.receiver.location.join(node.closing_loc || node.arguments.location)
|
|
226
|
+
[doubles.build(location, receiver: node.receiver, method_name: name, returns: Syntax.arguments(node)[1],
|
|
227
|
+
scope: node.block.location)]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def block_stub?(node)
|
|
231
|
+
node.is_a?(Prism::CallNode) && node.name == :stub && node.receiver && node.block.is_a?(Prism::BlockNode)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Recognises Minitest assertions, expectations, mocha expectations and mock verification.
|
|
236
|
+
module Assertions
|
|
237
|
+
COMPARISONS = { assert_equal: :equality, assert_same: :identity, must_equal: :equality,
|
|
238
|
+
must_be_same_as: :identity }.freeze
|
|
239
|
+
RAISE_CHECKS = %i[assert_raises must_raise].freeze
|
|
240
|
+
# `_(x)`, `value(x)` and `expect(x)` wrap the value a minitest/spec expectation checks.
|
|
241
|
+
WRAPPERS = %i[_ value expect].freeze
|
|
242
|
+
|
|
243
|
+
# Receiverless calls that are Minitest or Ruby vocabulary, never assertion helpers.
|
|
244
|
+
VOCABULARY = Set[
|
|
245
|
+
*GROUPS, *EXAMPLES, *SETUP_CALLS.keys, :test, :skip, *WRAPPERS, :puts, :p, :pp, :print, :raise, :fail,
|
|
246
|
+
:lambda, :proc, :loop, :sleep, :require, :require_relative, :format
|
|
247
|
+
].freeze
|
|
248
|
+
|
|
249
|
+
module_function
|
|
250
|
+
|
|
251
|
+
# Assertion fields for an assertion, or nil when +call+ is not one.
|
|
252
|
+
def fields(call)
|
|
253
|
+
name = call.name.to_s
|
|
254
|
+
if call.receiver.nil? && (name.start_with?('assert', 'refute') || name == 'flunk')
|
|
255
|
+
assertion(call)
|
|
256
|
+
elsif name.start_with?('must_', 'wont_')
|
|
257
|
+
expectation(call)
|
|
258
|
+
elsif message_expectation?(call)
|
|
259
|
+
{ kind: :message_expectation, comparison: nil, subject: nil, expected: nil }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# `assert_equal expected, actual`
|
|
264
|
+
def assertion(call)
|
|
265
|
+
arguments = call.arguments&.arguments || []
|
|
266
|
+
kind, comparison = classify(call.name)
|
|
267
|
+
{ kind:, comparison:, subject: comparison && arguments[1], expected: comparison && arguments[0] }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# `_(actual).must_equal expected`, `actual.must_equal expected`
|
|
271
|
+
def expectation(call)
|
|
272
|
+
kind, comparison = classify(call.name)
|
|
273
|
+
{ kind:, comparison:, subject: comparison && wrapped(call.receiver),
|
|
274
|
+
expected: comparison && call.arguments&.arguments&.first }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def wrapped(receiver)
|
|
278
|
+
return receiver unless receiver.is_a?(Prism::CallNode) && receiver.receiver.nil? &&
|
|
279
|
+
WRAPPERS.include?(receiver.name)
|
|
280
|
+
|
|
281
|
+
receiver.arguments&.arguments&.first
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# mocha `obj.expects(:m)`, `mock.expect(:m, value)` and `mock.verify`
|
|
285
|
+
def message_expectation?(call)
|
|
286
|
+
return false if call.receiver.nil?
|
|
287
|
+
|
|
288
|
+
case call.name
|
|
289
|
+
when :expects then true
|
|
290
|
+
when :expect then call.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
|
|
291
|
+
when :verify then call.arguments.nil?
|
|
292
|
+
else false
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def classify(name)
|
|
297
|
+
return [:comparison, COMPARISONS[name]] if COMPARISONS.key?(name)
|
|
298
|
+
return [:raise_check, nil] if RAISE_CHECKS.include?(name)
|
|
299
|
+
|
|
300
|
+
[:other, nil]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|