rspock 2.5.0 → 3.0.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/.github/CODEOWNERS +1 -0
- data/.github/workflows/ci.yml +1 -1
- data/.gitignore +4 -1
- data/.ruby-version +1 -0
- data/CHANGELOG.md +29 -2
- data/Gemfile.lock +12 -14
- data/README.md +50 -35
- data/dependencies.rb +11 -0
- data/dev.yml +8 -0
- data/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb +3 -1
- data/lib/rspock/ast/node.rb +19 -40
- data/lib/rspock/ast/parser/block.rb +2 -1
- data/lib/rspock/ast/parser/interaction_parser.rb +11 -4
- data/lib/rspock/ast/parser/statement_parser.rb +13 -5
- data/lib/rspock/ast/parser/test_method_parser.rb +4 -2
- data/lib/rspock/ast/statement_to_assertion_transformation.rb +2 -1
- data/lib/rspock/ast/test_method_def_transformation.rb +9 -8
- data/lib/rspock/ast/test_method_dstr_transformation.rb +5 -7
- data/lib/rspock/ast/test_method_transformation.rb +161 -111
- data/lib/rspock/ast/transformation.rb +1 -27
- data/lib/rspock/version.rb +1 -1
- data/lib/rspock.rb +0 -1
- data/rspock.gemspec +3 -4
- data/skills/rspock/SKILL.md +198 -0
- metadata +10 -39
- data/lib/generators/rspock/install_generator.rb +0 -17
- data/lib/generators/templates/rspock_initializer.rb +0 -2
- data/lib/minitest/rspock_plugin.rb +0 -10
- data/lib/rspock/ast/method_call_to_lvar_transformation.rb +0 -24
- data/lib/rspock/backtrace_filter.rb +0 -53
- data/lib/rspock/minitest/backtrace_filter.rb +0 -16
|
@@ -3,15 +3,13 @@ require 'ast_transform/abstract_transformation'
|
|
|
3
3
|
|
|
4
4
|
module RSpock
|
|
5
5
|
module AST
|
|
6
|
+
# dstr counterpart of TestMethodDefTransformation: appends the row index and source line interpolations to an
|
|
7
|
+
# already-interpolated test name.
|
|
6
8
|
class TestMethodDstrTransformation < ASTTransform::AbstractTransformation
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
LINE_NUMBER_AST = s(:begin,
|
|
11
|
-
s(:lvar, :_line_number_))
|
|
9
|
+
ROW_INDEX_AST = s(:begin, s(:lvar, :__rspock_row_index__))
|
|
10
|
+
ROW_LINE_AST = s(:begin, s(:lvar, :__rspock_row_line__))
|
|
12
11
|
|
|
13
12
|
SPACE_STR_AST = s(:str, " ")
|
|
14
|
-
|
|
15
13
|
LINE_NUMBER_STR_AST = s(:str, " line ")
|
|
16
14
|
|
|
17
15
|
def on_dstr(node)
|
|
@@ -24,7 +22,7 @@ module RSpock
|
|
|
24
22
|
children << SPACE_STR_AST
|
|
25
23
|
end
|
|
26
24
|
|
|
27
|
-
children.push(
|
|
25
|
+
children.push(ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST)
|
|
28
26
|
node.updated(nil, children)
|
|
29
27
|
end
|
|
30
28
|
end
|
|
@@ -5,7 +5,6 @@ require 'rspock/ast/statement_to_assertion_transformation'
|
|
|
5
5
|
require 'rspock/ast/header_nodes_transformation'
|
|
6
6
|
require 'rspock/ast/interaction_to_mocha_mock_transformation'
|
|
7
7
|
require 'rspock/ast/interaction_to_block_identity_assertion_transformation'
|
|
8
|
-
require 'rspock/ast/method_call_to_lvar_transformation'
|
|
9
8
|
require 'rspock/ast/test_method_def_transformation'
|
|
10
9
|
require 'rspock/ast/parser/test_method_parser'
|
|
11
10
|
|
|
@@ -26,158 +25,208 @@ module RSpock
|
|
|
26
25
|
private
|
|
27
26
|
|
|
28
27
|
def transform(rspock_ast)
|
|
29
|
-
hoisted_setups = []
|
|
30
|
-
|
|
31
28
|
method_call = rspock_ast.def_node.method_call
|
|
32
29
|
method_args = rspock_ast.def_node.args
|
|
33
30
|
where = rspock_ast.where_node
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
body = build_test_body(rspock_ast.body_node)
|
|
33
|
+
build_ruby_ast(method_call, method_args, body, where)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# --- Test body assembly ---
|
|
37
|
+
#
|
|
38
|
+
# Statements are assembled in SOURCE order so line-aligned emission keeps each one on its own line.
|
|
39
|
+
# Execution-order requirements that source order cannot express (interaction setups in Then must run before the
|
|
40
|
+
# When body they observe) are carried by ast-transform's thunk facility (run_after / thunk) instead of by
|
|
41
|
+
# textual hoisting.
|
|
42
|
+
def build_test_body(body_node)
|
|
43
|
+
blocks = body_node.children
|
|
44
|
+
sections = blocks.map { |block_node| transform_block(block_node) }
|
|
45
|
+
|
|
46
|
+
when_statements = statements_of_type(blocks, sections, :rspock_when)
|
|
47
|
+
cleanup_statements = statements_of_type(blocks, sections, :rspock_cleanup)
|
|
48
|
+
interaction_setups = sections.flat_map(&:interaction_setups)
|
|
49
|
+
raises_node = blocks.filter_map { |block_node| find_raises(block_node) }.first
|
|
50
|
+
|
|
51
|
+
source_order = blocks.zip(sections).flat_map do |block_node, section|
|
|
52
|
+
next [] if block_node.type == :rspock_cleanup
|
|
53
|
+
|
|
54
|
+
section.statements.reject { |statement| statement.type == :rspock_raises }
|
|
44
55
|
end
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups)
|
|
48
|
-
end
|
|
57
|
+
body_children = order_execution(source_order, when_statements, interaction_setups, raises_node)
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
ast = s(:begin, *body_children)
|
|
60
|
+
cleanup_statements.empty? ? ast : s(:kwbegin, s(:ensure, ast, s(:begin, *cleanup_statements)))
|
|
61
|
+
end
|
|
53
62
|
|
|
54
|
-
|
|
55
|
-
if child.type == :rspock_interaction
|
|
56
|
-
setup = InteractionToMochaMockTransformation.new(idx).run(child)
|
|
57
|
-
assertion = InteractionToBlockIdentityAssertionTransformation.new(idx).run(child)
|
|
63
|
+
Section = Data.define(:statements, :interaction_setups)
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
def transform_block(block_node)
|
|
66
|
+
case block_node.type
|
|
67
|
+
when :rspock_then, :rspock_expect
|
|
68
|
+
transform_assertion_block(block_node)
|
|
69
|
+
else
|
|
70
|
+
Section.new(statements: block_node.children, interaction_setups: [])
|
|
64
71
|
end
|
|
72
|
+
end
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
# Then/Expect children become plain Ruby in place: interactions lower to Mocha setups anchored at the
|
|
75
|
+
# interaction's own source line (plus an identity assertion for &block forwarding), statements become
|
|
76
|
+
# assertions at their own lines.
|
|
77
|
+
#
|
|
78
|
+
# Within the section, ALL setups come before all assertions: the thunked When body executes right after the
|
|
79
|
+
# last setup, and every assertion (identity or otherwise) observes the When body's effects, so none may
|
|
80
|
+
# precede that point. Setups keep source order and their anchors, so alignment holds; assertions after them
|
|
81
|
+
# are either synthetic (identity assertions, loc-less, pack anywhere) or textually below the interactions in
|
|
82
|
+
# the common case.
|
|
83
|
+
def transform_assertion_block(block_node)
|
|
84
|
+
setups = []
|
|
85
|
+
assertions = []
|
|
86
|
+
interaction_index = 0
|
|
87
|
+
|
|
88
|
+
block_node.children.each do |child|
|
|
89
|
+
case child.type
|
|
90
|
+
when :rspock_interaction
|
|
91
|
+
interaction_setups, identity_assertions = lower_interaction(child, interaction_index)
|
|
92
|
+
interaction_index += 1
|
|
93
|
+
setups.concat(interaction_setups)
|
|
94
|
+
assertions.concat(identity_assertions)
|
|
95
|
+
when :rspock_binary_statement, :rspock_statement
|
|
96
|
+
assertions << anchored_at(child, @statement_transformation.run(child))
|
|
97
|
+
else
|
|
98
|
+
assertions << child
|
|
73
99
|
end
|
|
74
100
|
end
|
|
75
101
|
|
|
76
|
-
|
|
102
|
+
Section.new(statements: setups + assertions, interaction_setups: setups)
|
|
77
103
|
end
|
|
78
104
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
105
|
+
# @return [Array(Array, Array)] Mocha setup statements, identity assertions
|
|
106
|
+
def lower_interaction(interaction, index)
|
|
107
|
+
setup = InteractionToMochaMockTransformation.new(index).run(interaction)
|
|
108
|
+
assertion = InteractionToBlockIdentityAssertionTransformation.new(index).run(interaction)
|
|
109
|
+
|
|
110
|
+
setups = setup.type == :begin ? setup.children.dup : [setup]
|
|
111
|
+
setups[0] = anchored_at(interaction, setups[0])
|
|
112
|
+
|
|
113
|
+
[setups, assertion.equal?(interaction) ? [] : [assertion]]
|
|
82
114
|
end
|
|
83
115
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
116
|
+
# Reorders execution (not text) where required:
|
|
117
|
+
# - interactions without raises: run the When body after the last interaction setup (run_after — the paved
|
|
118
|
+
# road).
|
|
119
|
+
# - raises without interactions: the When body inlines directly into assert_raises; no thunk needed.
|
|
120
|
+
# - raises with interactions: the When body is thunked into the assert_raises block inserted after the last
|
|
121
|
+
# setup; the lowering re-emits the body at its own source lines.
|
|
122
|
+
def order_execution(source_order, when_statements, interaction_setups, raises_node)
|
|
123
|
+
if raises_node
|
|
124
|
+
build_raises_body(source_order, when_statements, interaction_setups, raises_node)
|
|
125
|
+
elsif interaction_setups.any? && when_statements.any?
|
|
126
|
+
run_after(source_order, run: when_statements, after: interaction_setups.last)
|
|
88
127
|
else
|
|
89
|
-
|
|
128
|
+
source_order
|
|
90
129
|
end
|
|
91
130
|
end
|
|
92
131
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if where
|
|
97
|
-
test_def = s(:block,
|
|
98
|
-
TestMethodDefTransformation.new.run(method_call),
|
|
99
|
-
method_args,
|
|
100
|
-
build_test_body(body_node, hoisted_setups)
|
|
101
|
-
)
|
|
102
|
-
test_def = HeaderNodesTransformation.new(where.header).run(test_def)
|
|
132
|
+
def build_raises_body(source_order, when_statements, interaction_setups, raises_node)
|
|
133
|
+
if interaction_setups.any?
|
|
134
|
+
assertion = build_assert_raises(raises_node, thunk(*when_statements))
|
|
103
135
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
build_where_args(where.header),
|
|
107
|
-
test_def
|
|
108
|
-
)
|
|
136
|
+
reordered = replace_run(source_order, when_statements, [])
|
|
137
|
+
insert_after(reordered, interaction_setups.last, assertion)
|
|
109
138
|
else
|
|
110
|
-
s(:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
)
|
|
139
|
+
when_body = when_statements.length == 1 ? when_statements[0] : s(:begin, *when_statements)
|
|
140
|
+
assertion = build_assert_raises(raises_node, when_body)
|
|
141
|
+
|
|
142
|
+
replace_run(source_order, when_statements, [assertion])
|
|
115
143
|
end
|
|
116
144
|
end
|
|
117
145
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
def build_assert_raises(raises_node, body)
|
|
147
|
+
assert_raises_call = s(:block,
|
|
148
|
+
s(:send, nil, :assert_raises, raises_node.exception_class),
|
|
149
|
+
s(:args),
|
|
150
|
+
body
|
|
151
|
+
)
|
|
121
152
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
when :rspock_when
|
|
127
|
-
body_children.concat(hoisted_setups)
|
|
128
|
-
raises_node = find_raises_in_next_then(blocks, i)
|
|
129
|
-
|
|
130
|
-
if raises_node
|
|
131
|
-
body_children << build_assert_raises(block_node, raises_node)
|
|
132
|
-
else
|
|
133
|
-
body_children.concat(block_node.children)
|
|
134
|
-
end
|
|
135
|
-
when :rspock_then, :rspock_expect
|
|
136
|
-
block_node.children.each do |child|
|
|
137
|
-
body_children << child unless child.type == :rspock_raises
|
|
138
|
-
end
|
|
139
|
-
when :rspock_cleanup
|
|
140
|
-
# handled below as ensure
|
|
141
|
-
end
|
|
153
|
+
if raises_node.capture_name
|
|
154
|
+
s(:lvasgn, raises_node.capture_name, assert_raises_call)
|
|
155
|
+
else
|
|
156
|
+
assert_raises_call
|
|
142
157
|
end
|
|
158
|
+
end
|
|
143
159
|
|
|
144
|
-
|
|
160
|
+
def find_raises(block_node)
|
|
161
|
+
return nil unless block_node.type == :rspock_then
|
|
145
162
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
ensure_node = s(:begin, *cleanup.children)
|
|
149
|
-
ast = s(:kwbegin, s(:ensure, ast, ensure_node))
|
|
150
|
-
end
|
|
163
|
+
block_node.children.find { |child| child.type == :rspock_raises }
|
|
164
|
+
end
|
|
151
165
|
|
|
152
|
-
|
|
166
|
+
def statements_of_type(blocks, sections, type)
|
|
167
|
+
blocks.zip(sections)
|
|
168
|
+
.select { |block_node, _section| block_node.type == type }
|
|
169
|
+
.flat_map { |_block_node, section| section.statements }
|
|
153
170
|
end
|
|
154
171
|
|
|
155
|
-
# ---
|
|
172
|
+
# --- Identity-based sequence edits (non-thunk counterparts of run_after) ---
|
|
156
173
|
|
|
157
|
-
def
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
def replace_run(statements, run, replacement)
|
|
175
|
+
start = statements.index { |statement| statement.equal?(run.first) }
|
|
176
|
+
raise ArgumentError, "run is not part of statements" unless start
|
|
160
177
|
|
|
161
|
-
|
|
178
|
+
result = statements.dup
|
|
179
|
+
result[start, run.length] = replacement
|
|
180
|
+
result
|
|
162
181
|
end
|
|
163
182
|
|
|
164
|
-
def
|
|
165
|
-
|
|
183
|
+
def insert_after(statements, anchor, insertion)
|
|
184
|
+
index = statements.index { |statement| statement.equal?(anchor) }
|
|
185
|
+
raise ArgumentError, "anchor is not part of statements" unless index
|
|
166
186
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
187
|
+
result = statements.dup
|
|
188
|
+
result.insert(index + 1, insertion)
|
|
189
|
+
result
|
|
190
|
+
end
|
|
172
191
|
|
|
173
|
-
|
|
174
|
-
|
|
192
|
+
# Re-anchors +node+ at +anchor+'s source location so emission places it on the anchor's line.
|
|
193
|
+
# No-op for anchors without locations.
|
|
194
|
+
def anchored_at(anchor, node)
|
|
195
|
+
return node unless anchor.loc&.expression
|
|
196
|
+
|
|
197
|
+
s_at(anchor, node.type, *node.children)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# --- Build final Ruby AST ---
|
|
201
|
+
|
|
202
|
+
def build_ruby_ast(method_call, method_args, body_node, where)
|
|
203
|
+
if where
|
|
204
|
+
test_def = anchored_at(method_call, s(:block,
|
|
205
|
+
TestMethodDefTransformation.new.run(method_call),
|
|
206
|
+
method_args,
|
|
207
|
+
body_node
|
|
208
|
+
))
|
|
209
|
+
test_def = HeaderNodesTransformation.new(where.header).run(test_def)
|
|
210
|
+
|
|
211
|
+
s(:block,
|
|
212
|
+
build_where_iterator(where.data_rows),
|
|
213
|
+
build_where_args(where.header),
|
|
214
|
+
test_def
|
|
215
|
+
)
|
|
175
216
|
else
|
|
176
|
-
|
|
217
|
+
anchored_at(method_call, s(:block,
|
|
218
|
+
method_call,
|
|
219
|
+
method_args,
|
|
220
|
+
body_node
|
|
221
|
+
))
|
|
177
222
|
end
|
|
178
223
|
end
|
|
179
224
|
|
|
180
225
|
# --- Where block helpers ---
|
|
226
|
+
#
|
|
227
|
+
# Each data row carries its source line as a trailing element, surfaced in the generated test NAME only
|
|
228
|
+
# (uniqueness for identical rows + the -n selector target) through internal block parameters. There is no
|
|
229
|
+
# user-facing runtime variable: isolate a row by running its generated test by name, then break normally.
|
|
181
230
|
|
|
182
231
|
def build_where_iterator(data_rows)
|
|
183
232
|
s(:send,
|
|
@@ -192,15 +241,16 @@ module RSpock
|
|
|
192
241
|
def build_where_data_row(row)
|
|
193
242
|
children = row.dup
|
|
194
243
|
children << s(:int, row.first&.loc&.expression&.line)
|
|
195
|
-
|
|
244
|
+
anchor = row.first
|
|
245
|
+
anchor&.loc&.expression ? s_at(anchor, :array, *children) : s(:array, *children)
|
|
196
246
|
end
|
|
197
247
|
|
|
198
248
|
def build_where_args(header)
|
|
199
249
|
injected_args = header.map { |column| s(:arg, column) }
|
|
200
|
-
injected_args << s(:arg,
|
|
250
|
+
injected_args << s(:arg, TestMethodDefTransformation::ROW_LINE_ARG)
|
|
201
251
|
s(:args,
|
|
202
252
|
s(:mlhs, *injected_args),
|
|
203
|
-
s(:arg,
|
|
253
|
+
s(:arg, TestMethodDefTransformation::ROW_INDEX_ARG),
|
|
204
254
|
)
|
|
205
255
|
end
|
|
206
256
|
end
|
|
@@ -77,8 +77,7 @@ module RSpock
|
|
|
77
77
|
|
|
78
78
|
def process_rspock(node)
|
|
79
79
|
processed = process_all(node).compact
|
|
80
|
-
|
|
81
|
-
node.updated(nil, children)
|
|
80
|
+
node.updated(nil, [EXTEND_RSPOCK_DECLARATIVE, *processed])
|
|
82
81
|
end
|
|
83
82
|
|
|
84
83
|
def on_block(node)
|
|
@@ -91,31 +90,6 @@ module RSpock
|
|
|
91
90
|
strict: @strict
|
|
92
91
|
).run(node)
|
|
93
92
|
end
|
|
94
|
-
|
|
95
|
-
def source_map_rescue_wrapper(node)
|
|
96
|
-
s(:kwbegin,
|
|
97
|
-
s(:rescue,
|
|
98
|
-
node,
|
|
99
|
-
s(:resbody,
|
|
100
|
-
s(:array,
|
|
101
|
-
s(:const, nil, :StandardError)
|
|
102
|
-
),
|
|
103
|
-
s(:lvasgn, :e),
|
|
104
|
-
s(:begin,
|
|
105
|
-
s(:send,
|
|
106
|
-
s(:send,
|
|
107
|
-
s(:const,
|
|
108
|
-
s(:const,
|
|
109
|
-
s(:cbase), :RSpock), :BacktraceFilter), :new), :filter_exception,
|
|
110
|
-
s(:lvar, :e)
|
|
111
|
-
),
|
|
112
|
-
s(:send, nil, :raise)
|
|
113
|
-
)
|
|
114
|
-
),
|
|
115
|
-
nil
|
|
116
|
-
)
|
|
117
|
-
)
|
|
118
|
-
end
|
|
119
93
|
end
|
|
120
94
|
end
|
|
121
95
|
end
|
data/lib/rspock/version.rb
CHANGED
data/lib/rspock.rb
CHANGED
data/rspock.gemspec
CHANGED
|
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
|
|
|
19
19
|
spec.bindir = "exe"
|
|
20
20
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
21
21
|
spec.require_paths = ["lib"]
|
|
22
|
-
spec.required_ruby_version = '>= 3.
|
|
22
|
+
spec.required_ruby_version = '>= 3.3'
|
|
23
23
|
|
|
24
24
|
# Development dependencies
|
|
25
25
|
spec.add_development_dependency "bundler", ">= 2.1"
|
|
@@ -31,9 +31,8 @@ Gem::Specification.new do |spec|
|
|
|
31
31
|
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
32
32
|
|
|
33
33
|
# Runtime dependencies
|
|
34
|
-
|
|
34
|
+
# parser and unparser are used only through ast_transform, which owns their floors.
|
|
35
|
+
spec.add_runtime_dependency "ast_transform", "~> 3.0"
|
|
35
36
|
spec.add_runtime_dependency "minitest", "~> 5.0"
|
|
36
37
|
spec.add_runtime_dependency "mocha", ">= 1.0"
|
|
37
|
-
spec.add_runtime_dependency "parser", ">= 3.0"
|
|
38
|
-
spec.add_runtime_dependency "unparser", ">= 0.6"
|
|
39
38
|
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rspock
|
|
3
|
+
description: >-
|
|
4
|
+
MUST be used when writing or modifying Minitest tests in any repo using
|
|
5
|
+
rspock (look for transform!(RSpock::AST::Transformation) in test files or
|
|
6
|
+
rspock in the Gemfile). RSpock rewrites test semantics via AST
|
|
7
|
+
transformation — code that looks like a no-op statement is an assertion,
|
|
8
|
+
and Minitest habits produce silently wrong tests.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# RSpock: writing tests
|
|
12
|
+
|
|
13
|
+
RSpock is a Spock-inspired testing framework on top of Minitest. Tests are
|
|
14
|
+
valid Ruby syntax with **different semantics**, applied by AST
|
|
15
|
+
transformation at load time. Do not reason about these files as plain
|
|
16
|
+
Minitest.
|
|
17
|
+
|
|
18
|
+
## The invariant that must never be violated
|
|
19
|
+
|
|
20
|
+
**Inside a `transform!(RSpock::AST::Transformation)` class, every bare
|
|
21
|
+
statement in a `Then`/`Expect` block IS an assertion. Outside one, it is
|
|
22
|
+
NOT — it evaluates and silently discards.**
|
|
23
|
+
|
|
24
|
+
Consequences:
|
|
25
|
+
|
|
26
|
+
- Inside a `transform!` class, write expression assertions — `a == b` in a
|
|
27
|
+
Then/Expect block. The transform compiles them to assertions with proper
|
|
28
|
+
failure messages; the Minitest assert API is not the dialect here.
|
|
29
|
+
- In a plain Minitest class, use the assert API (`assert_equal` and
|
|
30
|
+
friends). A bare comparison there evaluates and discards — a silently
|
|
31
|
+
green test.
|
|
32
|
+
- When editing a test file, first check for the `transform!` line at each
|
|
33
|
+
class definition; it decides which dialect that class speaks. Both
|
|
34
|
+
styles may legitimately coexist in one file (see `strict: false` below)
|
|
35
|
+
— match the dialect of the class you are in.
|
|
36
|
+
|
|
37
|
+
## Boilerplate
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
require "test_helper"
|
|
41
|
+
|
|
42
|
+
transform!(RSpock::AST::Transformation)
|
|
43
|
+
class MyThingTest < Minitest::Test
|
|
44
|
+
test "descriptive name" do
|
|
45
|
+
# code blocks here
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The application must install the hook once (usually in the test helper):
|
|
51
|
+
`require "ast_transform"; ASTTransform.install`. That is the whole setup —
|
|
52
|
+
Rails apps need nothing extra (backtraces and debuggers are source-true by
|
|
53
|
+
construction; there is no backtrace cleaner to configure). Mixed files can
|
|
54
|
+
use `transform!(RSpock::AST::Transformation.new(strict: false))` to allow
|
|
55
|
+
plain Minitest tests alongside — this exists to ease gradual migration,
|
|
56
|
+
so treat mixed files as normal, not as something to unify.
|
|
57
|
+
|
|
58
|
+
The transform is an abstraction — trust it. If you ever need to see the
|
|
59
|
+
compiled Ruby (debugging only, never as routine verification), the
|
|
60
|
+
transformed files are written under `tmp/ast_transform/<relative path>`;
|
|
61
|
+
they are emitted line-aligned, so their line numbers match your source
|
|
62
|
+
exactly.
|
|
63
|
+
|
|
64
|
+
## Code blocks and their order
|
|
65
|
+
|
|
66
|
+
`Given` (setup) → `When` (stimulus) → `Then` (response), or `Expect`
|
|
67
|
+
(stimulus+response in one), plus `Cleanup` (always runs; code defensively
|
|
68
|
+
with `&.`) and `Where` (data table, last in source but evaluated first).
|
|
69
|
+
Every block takes an optional description string. A `When` is always
|
|
70
|
+
followed by a `Then`. Use When+Then for side-effecting code, Expect for
|
|
71
|
+
pure functions.
|
|
72
|
+
|
|
73
|
+
## Assertion forms (Then/Expect)
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
Then "the walk produced the right state"
|
|
77
|
+
actual == expected # binary operators: == != =~ !~ > < >= <=
|
|
78
|
+
list.include?(x) # bare boolean expression asserts
|
|
79
|
+
!cart.empty? # negation asserts
|
|
80
|
+
name = actual.first # assignments pass through (not assertions)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
LHS is actual, RHS is expected. Exception assertions live in Then, apply
|
|
84
|
+
to the preceding When, one per block:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
Then "a parse error names the token"
|
|
88
|
+
e = raises JSON::ParserError # capture optional
|
|
89
|
+
e.message.include?("unexpected token")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`raises` is not supported in Expect blocks.
|
|
93
|
+
|
|
94
|
+
## Where tables (data-driven)
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
test "adding #{a} and #{b} gives #{c}" do
|
|
98
|
+
Expect
|
|
99
|
+
a + b == c
|
|
100
|
+
|
|
101
|
+
Where
|
|
102
|
+
a | b | c
|
|
103
|
+
-1 | 1 | 0
|
|
104
|
+
0 | 0 | 0
|
|
105
|
+
1 | 2 | 3
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Header names become local variables and interpolate into the test name.
|
|
110
|
+
The table is evaluated in class scope — it cannot see instance methods or
|
|
111
|
+
test-local variables. Order rows like a truth table; rightmost column is
|
|
112
|
+
the expected result.
|
|
113
|
+
|
|
114
|
+
To generate an exhaustive table instead of writing it by hand:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
rake rspock:truth_table -- a=-1,0,1 b=-1,0,1 expected_result="'?'"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It emits the formatted cross-product (fill the `'?'` column manually).
|
|
121
|
+
Escape commas inside a value with `\,` (e.g. `b="gen(1\, 2)","gen(3\, 4)"`).
|
|
122
|
+
Non-Rails projects must load the gem's Rakefile once to get the task —
|
|
123
|
+
see the README's installation section.
|
|
124
|
+
|
|
125
|
+
## Interaction mocking (Then only)
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Then
|
|
129
|
+
1 * subscriber.receive("hello") # exactly one call
|
|
130
|
+
0 * mailer.deliver # must never be called
|
|
131
|
+
(1..3) * poller.tick # between one and three
|
|
132
|
+
(1.._) * poller.tick # at least once
|
|
133
|
+
(_..3) * poller.tick # at most three times
|
|
134
|
+
_ * cache.fetch("key") >> cached # any count, stubbed return
|
|
135
|
+
1 * repo.find(42) >> raises(RecordNotFound) # stubbed exception
|
|
136
|
+
1 * ui.frame("Build", &my_block) # block-identity check
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Declared in Then but installed before When runs — declare naturally,
|
|
140
|
+
RSpock handles ordering. Compiles to Mocha. Inline blocks (`{ }` /
|
|
141
|
+
`do...end`) are not allowed in interactions — use a named proc with `&`.
|
|
142
|
+
Mocks never yield blocks by design: needing that signals the unit under
|
|
143
|
+
test is doing too much — restructure so the mock boundary sits between
|
|
144
|
+
responsibilities.
|
|
145
|
+
|
|
146
|
+
## Debugging failures
|
|
147
|
+
|
|
148
|
+
- Backtraces AND debugger display are source-true: transformed code is
|
|
149
|
+
emitted with every statement on its original source line, so line
|
|
150
|
+
numbers point at the file you wrote with no mapping layer. Trust them;
|
|
151
|
+
don't second-guess against `tmp/ast_transform/`.
|
|
152
|
+
- `break file:line` binds on user statements; interactive debuggers show
|
|
153
|
+
your real source. One documented oddity: interaction setups execute
|
|
154
|
+
before the When body, so stepping through a test with interactions
|
|
155
|
+
jumps from the interaction lines back up to the When line once.
|
|
156
|
+
- To isolate a Where row: the generated test name embeds the row's index
|
|
157
|
+
and source line (e.g. `... 1 line 15`). For a newly failing row, copy
|
|
158
|
+
the rerun command printed with the failure and add a plain
|
|
159
|
+
`binding.pry`. For a chosen row, run `-n /line_15/` with the line from
|
|
160
|
+
the editor gutter, or break conditionally on the column locals
|
|
161
|
+
themselves (`binding.pry if input == "not json"`). A source-line
|
|
162
|
+
breakpoint on a data row cannot isolate its run — the table evaluates
|
|
163
|
+
once, in class scope; name-selection is the mechanism.
|
|
164
|
+
|
|
165
|
+
## Pitfalls (wrong → right)
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
# WRONG: Minitest API inside an RSpock class
|
|
169
|
+
assert_equal 3, add(1, 2)
|
|
170
|
+
# RIGHT
|
|
171
|
+
Expect
|
|
172
|
+
add(1, 2) == 3
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# WRONG: bare comparison in a class without transform! — silently green
|
|
177
|
+
class FooTest < Minitest::Test
|
|
178
|
+
test("x") { compute == 42 }
|
|
179
|
+
end
|
|
180
|
+
# RIGHT: add transform!(RSpock::AST::Transformation) above the class,
|
|
181
|
+
# or use assert_equal in plain Minitest
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# WRONG: Where table using an instance method for column data
|
|
186
|
+
Where
|
|
187
|
+
input | expected
|
|
188
|
+
helper_val | 1 # NameError: class scope
|
|
189
|
+
# RIGHT: use class methods or literals in Where rows
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
# WRONG: expecting a mocked method to yield
|
|
194
|
+
1 * ui.with_spinner("work") { drain(io) }
|
|
195
|
+
# RIGHT: restructure — mock boundary between responsibilities
|
|
196
|
+
success = drain(io) # test with a real StringIO
|
|
197
|
+
1 * ui.ok("work") # simple expectation, no block
|
|
198
|
+
```
|