rspock 2.3.1 → 2.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0a24add88d2e5100613beaedf3c8b158e3d463a04e17377c99f2a2c98b63157
4
- data.tar.gz: d179439aaf99e0e50a6565cadae7c2541b3ae2e399fb847cc073023ad130e7d3
3
+ metadata.gz: d6c10b52d342827b092af647c5bc144f4e381dff91167eedf93a0f99c9488ee2
4
+ data.tar.gz: 20ea274b4fa0e3a79eefb88f33c53ddf84adfc582fd37fe3e2ee9688a8137747
5
5
  SHA512:
6
- metadata.gz: bfbf1995a238d569be2c5cb598e02808a77847d8bc3a0a785a5448af6bcebfc7d222e746da44892e4fa2e17c2c74153049ed4d1297cc815900c173e385b9ef3e
7
- data.tar.gz: 76b576315949d04b5579ff7ead6b6d85330d0030e3045680a4eb0a369b721d1c40b53d3353d5696f02c182fd0ce4b026ab7ba9fa53dd70569aa2ef33d35bea23
6
+ metadata.gz: '019855311bf85f23d10fb75d3b1c0020b52fbf2ae7083205ca69a3f15b142ee7c2ba24e9be0506c690e65b42302b347ea7dc256c596edf1069ec8a0700aad666'
7
+ data.tar.gz: a559d70b0f3509d361234019935a2105c4a2b96ddac5ce0df7198edff346fe83a5c7a9c889d5152fbf0a856a60edd069017c3b24f88b144eda651ab35a57b47b
data/CHANGELOG.md CHANGED
@@ -1,46 +1,170 @@
1
1
  # Changelog
2
+
2
3
  All notable changes to this project will be documented in this file.
3
4
 
4
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
7
 
7
- ## [UNRELEASED]
8
+ ## [Unreleased]
9
+
10
+ ## [2.5.0] - 2026-02-28
11
+
12
+ ### Added
13
+
14
+ - Exception conditions: `raises ExceptionClass` in Then blocks wraps the preceding When block in an exception assertion.
15
+ - Exception capture: `e = raises ExceptionClass` captures the exception for further assertions in the same Then block.
16
+ - Exception conditions work with data-driven `Where` blocks.
17
+
18
+ ### Changed
19
+
20
+ - Renamed interaction outcome nodes from `rspock_returns` / `rspock_raises` to `rspock_stub_returns` / `rspock_stub_raises` to distinguish them from the new exception condition `rspock_raises`.
21
+
22
+ ## [2.4.0] - 2026-02-28
23
+
24
+ ### Added
25
+
26
+ - Spock-style implicit assertions: every non-assignment statement in Then/Expect blocks is now an assertion — no assertion API needed.
27
+ - Binary operator assertions: `=~`, `!~`, `>`, `<`, `>=`, `<=` with clear error messages.
28
+ - General statement assertions: bare boolean expressions (e.g. `obj.valid?`) with the original source text in the error message.
29
+ - Negation support: `!expr` is detected automatically and produces a clear error message.
30
+ - `>> raises(...)` syntax for exception stubbing in interactions.
31
+
32
+ ### Changed
33
+
34
+ - Renamed `ConditionParser` to `StatementParser` and `ConditionToAssertionTransformation` to `StatementToAssertionTransformation` for consistency with Spock's model.
35
+ - Then and Expect block parsers now use `StatementParser` for statement classification.
36
+
37
+ ### Removed
38
+
39
+ - `ComparisonToAssertionTransformation` — replaced by `StatementToAssertionTransformation`.
40
+
41
+ ## [2.3.1] - 2026-02-27
42
+
43
+ ### Fixed
44
+
45
+ - Require `block_capture` so it is available at runtime.
46
+
47
+ ## [2.3.0] - 2026-02-27
48
+
49
+ ### Added
50
+
51
+ - Interaction transformations and block identity verification via `&` operator.
52
+ - RSpock AST node hierarchy (`Node`, `InteractionNode`, `BodyNode`, etc.) for type-safe AST handling.
53
+ - `TestMethodParser` extracted from `TestMethodTransformation` for separation of parsing and transformation.
54
+
55
+ ### Changed
56
+
57
+ - Restructured block classes into `Parser` namespace and converted `InteractionParser` to a class.
58
+ - Introduced `BodyNode` and removed legacy interaction transformations.
59
+
60
+ ## [2.2.0] - 2026-02-25
61
+
62
+ ### Added
63
+
64
+ - Interaction stubbing with `>>` for return value stubbing in Then block interactions.
65
+
66
+ ### Fixed
67
+
68
+ - Pry and pry-byebug compatibility.
69
+ - Failing test on Ruby 3+.
70
+ - `filter_string` for `ast_transform` 2.1.4 source mapping change.
71
+
72
+ ## [2.1.0] - 2026-02-21
73
+
8
74
  ### Added
9
- - Interaction-based testing: Mock with expectations in the Then block.
75
+
76
+ - Ruby 4.0 support.
77
+
78
+ ### Fixed
79
+
80
+ - Codecov badge URL to use master branch.
81
+
82
+ ## [2.0.0] - 2026-02-21
10
83
 
11
84
  ### Changed
85
+
86
+ - Minimum Ruby version bumped to 3.2.
87
+ - Upgraded to Ruby 3.x compatibility.
88
+ - Use `ast_transform` 2.0.0 from RubyGems.
89
+ - CI modernization and release workflow improvements.
90
+
91
+ ## [1.0.0] - 2020-07-09
92
+
93
+ ### Added
94
+
95
+ - Interaction-based testing: mock with expectations in the Then block.
96
+ - Travis CI and code coverage.
97
+
98
+ ### Changed
99
+
12
100
  - Test names now have the test index and line number as suffix instead of prefix.
13
- - Cleanup transformed code output.
101
+ - Removed unnecessary ensure block when Cleanup block is empty; moved source map wrapper to class scope.
102
+ - Bump `ast_transform` to release 1.0.0.
14
103
 
15
104
  ### Fixed
16
- - Fixed source mapping for transformed assertion nodes.
17
105
 
18
- ## [0.2.5] 2019-05-28
106
+ - Source mapping for transformed assertion nodes.
107
+ - Truth table generator command with proper escaping.
108
+
109
+ ## [0.2.5] - 2019-05-28
110
+
19
111
  ### Fixed
20
- - Fixed BacktraceFilter so that source mapping works again
21
112
 
22
- ## [0.2.4] 2019-05-27
113
+ - BacktraceFilter so that source mapping works again.
114
+
115
+ ## [0.2.4] - 2019-05-27
116
+
23
117
  ### Changed
24
- - Bump Unparser dependency from ~> 0.2.8 to ~> 0.4
25
118
 
26
- ## [0.2.3] 2018-11-09
119
+ - Bump Unparser dependency from `~> 0.2.8` to `~> 0.4`.
120
+
121
+ ## [0.2.3] - 2018-11-09
122
+
27
123
  ### Fixed
28
- - Cleanup block can now contain more than one node
29
124
 
30
- ## [0.2.2] 2018-11-08
125
+ - Cleanup block can now contain more than one node.
126
+
127
+ ## [0.2.2] - 2018-11-08
128
+
31
129
  ### Changed
32
- - Extracted ASTTransform to its own gem: `ast_transform`
33
130
 
34
- ## [0.2.1] 2018-10-09
131
+ - Extracted ASTTransform to its own gem: `ast_transform`.
132
+
133
+ ## [0.2.1] - 2018-10-09
134
+
35
135
  ### Added
36
- - _line_number_ is now displayed in the test name, and is available in test scope for debugging purposes
136
+
137
+ - `_line_number_` is now displayed in the test name and available in test scope for debugging.
37
138
 
38
139
  ### Changed
39
- - Renamed test_index to _test_index_
40
140
 
41
- ## [0.2.0] 2018-09-21
141
+ - Renamed `test_index` to `_test_index_`.
142
+
143
+ ## [0.2.0] - 2018-09-21
144
+
42
145
  ### Added
146
+
43
147
  - Truth table generator Rake task.
44
148
 
45
- ## [0.1.1] 2018-09-19
46
- ### Initial Release!
149
+ ## [0.1.1] - 2018-09-18
150
+
151
+ ### Added
152
+
153
+ - Initial release.
154
+
155
+ [Unreleased]: https://github.com/rspockframework/rspock/compare/v2.5.0...HEAD
156
+ [2.5.0]: https://github.com/rspockframework/rspock/compare/v2.4.0...v2.5.0
157
+ [2.4.0]: https://github.com/rspockframework/rspock/compare/v2.3.1...v2.4.0
158
+ [2.3.1]: https://github.com/rspockframework/rspock/compare/v2.3.0...v2.3.1
159
+ [2.3.0]: https://github.com/rspockframework/rspock/compare/v2.2.0...v2.3.0
160
+ [2.2.0]: https://github.com/rspockframework/rspock/compare/v2.1.0...v2.2.0
161
+ [2.1.0]: https://github.com/rspockframework/rspock/compare/v2.0.0...v2.1.0
162
+ [2.0.0]: https://github.com/rspockframework/rspock/compare/1.0.0...v2.0.0
163
+ [1.0.0]: https://github.com/rspockframework/rspock/compare/0.2.5...1.0.0
164
+ [0.2.5]: https://github.com/rspockframework/rspock/compare/0.2.4...0.2.5
165
+ [0.2.4]: https://github.com/rspockframework/rspock/compare/0.2.3...0.2.4
166
+ [0.2.3]: https://github.com/rspockframework/rspock/compare/0.2.2...0.2.3
167
+ [0.2.2]: https://github.com/rspockframework/rspock/compare/0.2.1...0.2.2
168
+ [0.2.1]: https://github.com/rspockframework/rspock/compare/0.2.0...0.2.1
169
+ [0.2.0]: https://github.com/rspockframework/rspock/compare/0.1.1...0.2.0
170
+ [0.1.1]: https://github.com/rspockframework/rspock/releases/tag/0.1.1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspock (2.3.1)
4
+ rspock (2.5.0)
5
5
  ast_transform (~> 2.0)
6
6
  minitest (~> 5.0)
7
7
  mocha (>= 1.0)
data/README.md CHANGED
@@ -16,8 +16,11 @@ Note: RSpock is heavily inspired by Spock for the Groovy programming language.
16
16
 
17
17
  * BDD-style code blocks: Given, When, Then, Expect, Cleanup, Where
18
18
  * Data-driven testing with incredibly expressive table-based Where blocks
19
- * Expressive assertions: Use familiar comparison operators `==` and `!=` for assertions!
20
- * [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>` and [block forwarding verification](#block-forwarding-verification) via `&block`
19
+ * Spock-style implicit assertions: every statement in Then/Expect blocks is an assertion (no assertion API needed)
20
+ * Binary operator assertions: `==`, `!=`, `=~`, `!~`, `>`, `<`, `>=`, `<=` with clear error messages
21
+ * General statement assertions: bare boolean expressions (e.g. `obj.valid?`, `list.include?(x)`) and negation (`!obj.empty?`) with source-text error messages
22
+ * [Exception conditions](#exception-conditions): `raises ExceptionClass` in Then blocks wraps the When block in an exception assertion, with optional capture for property assertions
23
+ * [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>`, [exception stubbing](#stubbing-exceptions) via `>> raises(...)`, and [block forwarding verification](#block-forwarding-verification) via `&block`
21
24
  * (Planned) BDD-style custom reporter that outputs information from Code Blocks
22
25
  * (Planned) Capture all Then block violations
23
26
 
@@ -150,15 +153,65 @@ The When block describes the stimulus to be applied to the system under test. It
150
153
 
151
154
  ```ruby
152
155
  Then "The product is added to the cart"
156
+ !cart.products.empty?
153
157
  cart.products.size == 1
154
158
  cart.products.first == product
159
+ cart.products.size > 0
155
160
  ```
156
161
 
157
- The Then block describes the response from the stimulus. Any comparison operators used in the Then block (`==` or `!=`) is transformed to assert_equal / refute_equal under the hood. By convention, the __LHS__ operand is considered the __actual__ value, while the __RHS__ operand is considered the __expected__ value.
162
+ The Then block describes the response from the stimulus. Following Spock's core model, **every statement is an assertion** unless it's a variable assignment. No assertion API is needed.
163
+
164
+ **Binary operators** (`==`, `!=`, `=~`, `!~`, `>`, `<`, `>=`, `<=`) produce clear error messages on failure. By convention, the **LHS** operand is the **actual** value and the **RHS** is the **expected** value.
165
+
166
+ **General statements** (bare boolean expressions like `obj.valid?`, `list.include?(x)`) include the original source text in the error message, so you see exactly which expression failed. **Negation** (`!expr`) is detected automatically.
167
+
168
+ **Variable assignments** pass through unchanged and execute in source order after the stimulus.
169
+
170
+ ##### Exception Conditions
171
+
172
+ Use `raises` in a Then block to assert that the preceding When block raises a specific exception:
173
+
174
+ ```ruby
175
+ When "Dividing by zero"
176
+ 1 / 0
177
+
178
+ Then "An error is raised"
179
+ raises ZeroDivisionError
180
+ ```
181
+
182
+ To inspect the exception, capture it into a variable:
183
+
184
+ ```ruby
185
+ When "Parsing bad input"
186
+ JSON.parse("not json")
187
+
188
+ Then "A parse error is raised with a message"
189
+ e = raises JSON::ParserError
190
+ e.message.include?("unexpected token")
191
+ ```
192
+
193
+ The captured variable is available for further assertions in the same Then block. Exception conditions work with data-driven `Where` blocks as well:
194
+
195
+ ```ruby
196
+ When "Parsing invalid input"
197
+ JSON.parse(input)
198
+
199
+ Then "The expected error is raised"
200
+ raises expected_error
201
+
202
+ Where
203
+ input | expected_error
204
+ "not json" | JSON::ParserError
205
+ "{invalid" | JSON::ParserError
206
+ ```
207
+
208
+ Only **one** `raises` condition is allowed per Then block, and `raises` is **not supported** in Expect blocks (use a When + Then block instead).
158
209
 
159
210
  #### Expect Block
160
211
 
161
- The Expect block is useful when expressing the stimulus and the response in one statement is more natural. For example, let's compare two equivalent ways of describing some behaviour:
212
+ The Expect block is useful when expressing the stimulus and the response in one statement is more natural. The same assertion rules apply as in Then blocks every statement is an assertion unless it's a variable assignment.
213
+
214
+ For example, let's compare two equivalent ways of describing some behaviour:
162
215
 
163
216
  ##### When + Then
164
217
  ```ruby
@@ -175,6 +228,17 @@ Expect "absolute of -2 is 2"
175
228
  -2.abs == 2
176
229
  ```
177
230
 
231
+ The Expect block supports the full range of assertion expressions:
232
+
233
+ ```ruby
234
+ Expect "string matching and predicates"
235
+ str =~ /potato/
236
+ str.include?("pot")
237
+ !str.empty?
238
+ str.length > 3
239
+ str.length == 6
240
+ ```
241
+
178
242
  A good rule of thumb is using When + Then blocks to describe methods with side-effects and Expect blocks to describe purely functional methods.
179
243
 
180
244
  #### Cleanup Block
@@ -306,12 +370,12 @@ test "#publish sends a message to all subscribers" do
306
370
  end
307
371
  ```
308
372
 
309
- The above ___Then___ block contains 2 interactions, each of which has 4 parts: the _cardinality_, the _receiver_, the _message_ and its _arguments_. Optionally, a _return value_ can be specified using the `>>` operator, and _block forwarding_ can be verified using the `&` operator.
373
+ The above ___Then___ block contains 2 interactions, each of which has 4 parts: the _cardinality_, the _receiver_, the _message_ and its _arguments_. Optionally, an _outcome_ can be specified using the `>>` operator (either a return value or `raises(...)` for exceptions), and _block forwarding_ can be verified using the `&` operator.
310
374
 
311
375
  ```
312
376
  1 * receiver.message('hello', &blk) >> "result"
313
377
  | | | | | |
314
- | | | | | return value (optional)
378
+ | | | | | outcome (optional): value or raises(...)
315
379
  | | | | block forwarding (optional)
316
380
  | | | argument(s) (optional)
317
381
  | | message
@@ -421,6 +485,39 @@ _ * cache.fetch("key") >> expensive_result # a variable
421
485
 
422
486
  **Note**: Without `>>`, an interaction sets up an expectation only (the method will return `nil` by default). Use `>>` when the code under test depends on the return value.
423
487
 
488
+ #### Stubbing Exceptions
489
+
490
+ When the code under test needs a collaborator to raise an exception, use `>> raises(...)` instead of a return value. This sets up the mock to raise the given exception when called.
491
+
492
+ ```ruby
493
+ test "#fetch raises when the record is not found" do
494
+ Given
495
+ repository = mock
496
+ service = Service.new(repository)
497
+
498
+ When
499
+ service.fetch(42)
500
+
501
+ Then
502
+ 1 * repository.find(42) >> raises(RecordNotFound)
503
+ end
504
+ ```
505
+
506
+ You can also pass a message or an exception instance:
507
+
508
+ ```ruby
509
+ 1 * repository.find(42) >> raises(RecordNotFound, "not found") # class + message
510
+ 1 * repository.find(42) >> raises(RecordNotFound.new("not found")) # instance
511
+ ```
512
+
513
+ This works with all interaction features — cardinality, arguments, and ranges:
514
+
515
+ ```ruby
516
+ (1..3) * service.call(anything) >> raises(TimeoutError)
517
+ ```
518
+
519
+ **Note**: Without `raises(...)`, the `>>` operator stubs a return value. With `raises(...)`, it stubs an exception instead.
520
+
424
521
  #### Block Forwarding Verification
425
522
 
426
523
  When the code under test forwards a block (or proc) to a collaborator, you may want to verify that the _exact_ block was passed through. RSpock supports this with the `&` operator in interactions, performing an identity check on the block reference.
@@ -527,20 +624,24 @@ To install this gem onto your local machine, run `bundle exec rake install`.
527
624
 
528
625
  ## Releasing a New Version
529
626
 
530
- There are two ways to create a release. Both require that `version.rb` has already been updated and merged to main.
627
+ There are two ways to create a release. Both require that `version.rb` has already been updated, `CHANGELOG.md` has been updated, and changes have been merged to main.
531
628
 
532
629
  ### Via GitHub UI
533
630
 
534
- 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
535
- 2. Go to the repo on GitHub **Releases** **Draft a new release**
536
- 3. Enter a new tag (e.g. `v2.0.0`), select `main` as the target branch
537
- 4. Add a title and release notes (GitHub can auto-generate these from merged PRs)
538
- 5. Click **Publish release**
631
+ 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`
632
+ 2. Move the `[Unreleased]` section in `CHANGELOG.md` to a new version heading with today's date (e.g. `## [2.4.0] - 2026-03-01`) and add a fresh empty `[Unreleased]` section above it. Update the comparison links at the bottom of the file.
633
+ 3. Commit, open a PR, and merge to main
634
+ 4. Go to the repo on GitHub **Releases** **Draft a new release**
635
+ 5. Enter a new tag (e.g. `v2.4.0`), select `main` as the target branch
636
+ 6. Add a title and release notes (GitHub can auto-generate these from merged PRs)
637
+ 7. Click **Publish release**
539
638
 
540
639
  ### Via CLI
541
640
 
542
- 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`, commit, open a PR, and merge to main
543
- 2. Tag and push:
641
+ 1. Update `VERSION` in `lib/rspock/version.rb` and run `bundle install` to regenerate `Gemfile.lock`
642
+ 2. Move the `[Unreleased]` section in `CHANGELOG.md` to a new version heading with today's date and add a fresh empty `[Unreleased]` section above it. Update the comparison links at the bottom of the file.
643
+ 3. Commit, open a PR, and merge to main
644
+ 4. Tag and push:
544
645
  ```
545
646
  git checkout main && git pull
546
647
  git tag v2.0.0
@@ -6,11 +6,20 @@ module RSpock
6
6
  module AST
7
7
  # Transforms an :rspock_interaction node into Mocha mock setup code.
8
8
  #
9
- # Input: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass)
9
+ # Input: s(:rspock_interaction, cardinality, receiver, sym, args, outcome, block_pass)
10
10
  # Output: receiver.expects(:message).with(*args).times(n).returns(value)
11
11
  #
12
+ # The outcome node type maps directly to the Mocha chain method:
13
+ # :rspock_stub_returns -> .returns(value)
14
+ # :rspock_stub_raises -> .raises(exception_class, ...)
15
+ #
12
16
  # When block_pass is present, wraps the expects chain with a BlockCapture.capture call.
13
17
  class InteractionToMochaMockTransformation < ASTTransform::AbstractTransformation
18
+ OUTCOME_METHODS = {
19
+ rspock_stub_returns: :returns,
20
+ rspock_stub_raises: :raises,
21
+ }.freeze
22
+
14
23
  def initialize(index = 0)
15
24
  @index = index
16
25
  end
@@ -21,7 +30,7 @@ module RSpock
21
30
  result = chain_call(interaction.receiver, :expects, s(:sym, interaction.message))
22
31
  result = chain_call(result, :with, *interaction.args.children) if interaction.args
23
32
  result = build_cardinality(result, interaction.cardinality)
24
- result = chain_call(result, :returns, interaction.return_value) if interaction.return_value
33
+ result = chain_call(result, OUTCOME_METHODS.fetch(interaction.outcome.type), *interaction.outcome.children) if interaction.outcome
25
34
 
26
35
  if interaction.block_pass
27
36
  build_block_capture_setup(result, interaction.receiver, interaction.message)
@@ -70,6 +70,25 @@ module RSpock
70
70
  end
71
71
  end
72
72
 
73
+ class OutcomeNode < Node
74
+ end
75
+
76
+ class StubReturnsNode < OutcomeNode
77
+ register :rspock_stub_returns
78
+ end
79
+
80
+ class StubRaisesNode < OutcomeNode
81
+ register :rspock_stub_raises
82
+ end
83
+
84
+ class RaisesNode < Node
85
+ register :rspock_raises
86
+
87
+ def exception_class = children[0]
88
+ def capture_var = children[1]
89
+ def capture_name = capture_var&.children&.[](0)
90
+ end
91
+
73
92
  class InteractionNode < Node
74
93
  register :rspock_interaction
75
94
 
@@ -78,10 +97,25 @@ module RSpock
78
97
  def message_sym = children[2]
79
98
  def message = message_sym.children[0]
80
99
  def args = children[3]
81
- def return_value = children[4]
100
+ def outcome = children[4]
82
101
  def block_pass = children[5]
83
102
  end
84
103
 
104
+ class BinaryStatementNode < Node
105
+ register :rspock_binary_statement
106
+
107
+ def lhs = children[0]
108
+ def operator = children[1]
109
+ def rhs = children[2]
110
+ end
111
+
112
+ class StatementNode < Node
113
+ register :rspock_statement
114
+
115
+ def expression = children[0]
116
+ def source = children[1]
117
+ end
118
+
85
119
  module NodeBuilder
86
120
  include ASTTransform::TransformationHelper
87
121
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'rspock/ast/parser/block'
3
+ require 'rspock/ast/parser/statement_parser'
3
4
 
4
5
  module RSpock
5
6
  module AST
@@ -20,6 +21,17 @@ module RSpock
20
21
  def successors
21
22
  @successors ||= [:Cleanup, :Where].freeze
22
23
  end
24
+
25
+ def to_rspock_node
26
+ statement_parser = StatementParser.new
27
+ spock_children = @children.map { |child| statement_parser.parse(child) }
28
+
29
+ if spock_children.any? { |c| c.type == :rspock_raises }
30
+ raise BlockError, "raises() is not supported in Expect blocks @ #{range}. Use a When + Then block instead."
31
+ end
32
+
33
+ s(:rspock_expect, *spock_children)
34
+ end
23
35
  end
24
36
  end
25
37
  end
@@ -7,14 +7,14 @@ module RSpock
7
7
  # Parses raw Ruby AST interaction nodes into structured :rspock_interaction nodes.
8
8
  #
9
9
  # Input: 1 * receiver.message("arg", &blk) >> "result"
10
- # Output: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass)
10
+ # Output: s(:rspock_interaction, cardinality, receiver, sym, args, outcome, block_pass)
11
11
  #
12
12
  # :rspock_interaction children:
13
13
  # [0] cardinality - e.g. s(:int, 1), s(:begin, s(:irange, ...)), s(:send, nil, :_)
14
14
  # [1] receiver - e.g. s(:send, nil, :subscriber)
15
15
  # [2] message - e.g. s(:sym, :receive)
16
16
  # [3] args - nil if no args, s(:array, *arg_nodes) otherwise
17
- # [4] return_value - nil if no >>, otherwise the value node
17
+ # [4] outcome - nil if no >>, otherwise s(:rspock_stub_returns, value) or s(:rspock_stub_raises, *args)
18
18
  # [5] block_pass - nil if no &, otherwise s(:block_pass, ...)
19
19
  class InteractionParser
20
20
  include RSpock::AST::NodeBuilder
@@ -35,7 +35,7 @@ module RSpock
35
35
  return node unless interaction_node?(node)
36
36
 
37
37
  if return_value_node?(node)
38
- return_value = node.children[2]
38
+ outcome = parse_outcome(node.children[2])
39
39
  node = node.children[0]
40
40
  end
41
41
 
@@ -50,7 +50,7 @@ module RSpock
50
50
  receiver,
51
51
  s(:sym, message),
52
52
  args,
53
- return_value,
53
+ outcome,
54
54
  block_pass
55
55
  )
56
56
  end
@@ -61,6 +61,14 @@ module RSpock
61
61
  node.type == :send && node.children[1] == :>> && interaction_node?(node.children[0])
62
62
  end
63
63
 
64
+ def parse_outcome(node)
65
+ if node.type == :send && node.children[0].nil? && node.children[1] == :raises
66
+ s(:rspock_stub_raises, *node.children[2..])
67
+ else
68
+ s(:rspock_stub_returns, node)
69
+ end
70
+ end
71
+
64
72
  def validate_cardinality(node)
65
73
  case node.type
66
74
  when *ALLOWED_CARDINALITY_NODES
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/node'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ # Classifies raw Ruby AST statements into RSpock node types for Then/Expect blocks.
8
+ #
9
+ # - Assignments pass through as raw AST (no wrapping).
10
+ # - Binary operators (==, !=, =~, etc.) become :rspock_binary_statement nodes.
11
+ # - Everything else becomes :rspock_statement nodes with the original source text captured.
12
+ class StatementParser
13
+ include RSpock::AST::NodeBuilder
14
+
15
+ BINARY_OPERATORS = %i[== != =~ !~ > < >= <=].freeze
16
+ ASSIGNMENT_TYPES = %i[lvasgn masgn op_asgn or_asgn and_asgn].freeze
17
+
18
+ def parse(node)
19
+ return build_raises(node) if raises_condition?(node)
20
+ return node if assignment?(node)
21
+ return build_binary_statement(node) if binary_statement?(node)
22
+
23
+ build_statement(node)
24
+ end
25
+
26
+ private
27
+
28
+ def raises_condition?(node)
29
+ direct_raises?(node) || assigned_raises?(node)
30
+ end
31
+
32
+ def direct_raises?(node)
33
+ node.type == :send && node.children[0].nil? && node.children[1] == :raises
34
+ end
35
+
36
+ def assigned_raises?(node)
37
+ node.type == :lvasgn &&
38
+ node.children[1]&.type == :send &&
39
+ node.children[1].children[0].nil? &&
40
+ node.children[1].children[1] == :raises
41
+ end
42
+
43
+ def build_raises(node)
44
+ if node.type == :lvasgn
45
+ variable = s(:sym, node.children[0])
46
+ exception_class = node.children[1].children[2]
47
+ s(:rspock_raises, exception_class, variable)
48
+ else
49
+ exception_class = node.children[2]
50
+ s(:rspock_raises, exception_class)
51
+ end
52
+ end
53
+
54
+ def assignment?(node)
55
+ ASSIGNMENT_TYPES.include?(node.type)
56
+ end
57
+
58
+ def binary_statement?(node)
59
+ node.type == :send &&
60
+ node.children.length == 3 &&
61
+ BINARY_OPERATORS.include?(node.children[1])
62
+ end
63
+
64
+ def build_binary_statement(node)
65
+ s(:rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2])
66
+ end
67
+
68
+ def build_statement(node)
69
+ source = node.loc&.expression&.source || node.inspect
70
+ s(:rspock_statement, node, s(:str, source))
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require 'rspock/ast/parser/block'
3
3
  require 'rspock/ast/parser/interaction_parser'
4
+ require 'rspock/ast/parser/statement_parser'
4
5
 
5
6
  module RSpock
6
7
  module AST
@@ -19,8 +20,21 @@ module RSpock
19
20
  end
20
21
 
21
22
  def to_rspock_node
22
- parser = InteractionParser.new
23
- spock_children = @children.map { |child| parser.parse(child) }
23
+ interaction_parser = InteractionParser.new
24
+ statement_parser = StatementParser.new
25
+
26
+ spock_children = @children.map do |child|
27
+ parsed = interaction_parser.parse(child)
28
+ next parsed unless parsed.equal?(child)
29
+
30
+ statement_parser.parse(child)
31
+ end
32
+
33
+ raises_count = spock_children.count { |c| c.type == :rspock_raises }
34
+ if raises_count > 1
35
+ raise BlockError, "Then block @ #{range} may contain at most one raises() condition"
36
+ end
37
+
24
38
  s(:rspock_then, *spock_children)
25
39
  end
26
40
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/node'
3
+
4
+ module RSpock
5
+ module AST
6
+ # Transforms :rspock_binary_statement and :rspock_statement nodes into Minitest assertion calls.
7
+ #
8
+ # Binary statements dispatch to specialized assertions (assert_equal, assert_match, assert_operator).
9
+ # General statements use assert_equal(true/false, expr, source_message) with negation detection.
10
+ class StatementToAssertionTransformation
11
+ include RSpock::AST::NodeBuilder
12
+
13
+ BINARY_DISPATCH = {
14
+ :== => :assert_equal,
15
+ :!= => :refute_equal,
16
+ :=~ => :assert_match,
17
+ :'!~' => :refute_match,
18
+ }.freeze
19
+
20
+ OPERATOR_ASSERTIONS = %i[> < >= <=].freeze
21
+
22
+ def run(node)
23
+ case node.type
24
+ when :rspock_binary_statement
25
+ transform_binary_statement(node)
26
+ when :rspock_statement
27
+ transform_statement(node)
28
+ else
29
+ node
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def transform_binary_statement(node)
36
+ lhs = node.lhs
37
+ op = node.operator.children[0]
38
+ rhs = node.rhs
39
+
40
+ if (assertion = BINARY_DISPATCH[op])
41
+ s(:send, nil, assertion, rhs, lhs)
42
+ elsif OPERATOR_ASSERTIONS.include?(op)
43
+ s(:send, nil, :assert_operator, lhs, s(:sym, op), rhs)
44
+ else
45
+ s(:send, nil, :assert_operator, lhs, s(:sym, op), rhs)
46
+ end
47
+ end
48
+
49
+ def transform_statement(node)
50
+ expr = node.expression
51
+ source_text = node.source.children[0]
52
+
53
+ if negated?(expr)
54
+ inner = expr.children[0]
55
+ message = "Expected \"#{source_text}\" to be false"
56
+ s(:send, nil, :assert_equal, s(:false), inner, s(:str, message))
57
+ else
58
+ message = "Expected \"#{source_text}\" to be true"
59
+ s(:send, nil, :assert_equal, s(:true), expr, s(:str, message))
60
+ end
61
+ end
62
+
63
+ def negated?(node)
64
+ node.type == :send && node.children[1] == :! && node.children.length == 2
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require 'ast_transform/abstract_transformation'
3
3
  require 'rspock/ast/node'
4
- require 'rspock/ast/comparison_to_assertion_transformation'
4
+ 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'
@@ -14,7 +14,7 @@ module RSpock
14
14
  class TestMethodTransformation < ASTTransform::AbstractTransformation
15
15
  def initialize(block_registry, strict: true)
16
16
  @parser = Parser::TestMethodParser.new(block_registry, strict: strict)
17
- @comparison_transformation = ComparisonToAssertionTransformation.new(:_test_index_, :_line_number_)
17
+ @statement_transformation = StatementToAssertionTransformation.new
18
18
  end
19
19
 
20
20
  def run(node)
@@ -59,7 +59,7 @@ module RSpock
59
59
  interaction_setups << setup
60
60
  then_children << assertion unless assertion.equal?(child)
61
61
  else
62
- then_children << @comparison_transformation.run(child)
62
+ then_children << transform_statement_or_passthrough(child)
63
63
  end
64
64
  end
65
65
 
@@ -77,10 +77,19 @@ module RSpock
77
77
  end
78
78
 
79
79
  def transform_expect_block(expect_node)
80
- new_children = expect_node.children.map { |child| @comparison_transformation.run(child) }
80
+ new_children = expect_node.children.map { |child| transform_statement_or_passthrough(child) }
81
81
  expect_node.updated(nil, new_children)
82
82
  end
83
83
 
84
+ def transform_statement_or_passthrough(child)
85
+ case child.type
86
+ when :rspock_binary_statement, :rspock_statement
87
+ @statement_transformation.run(child)
88
+ else
89
+ child
90
+ end
91
+ end
92
+
84
93
  # --- Build final Ruby AST ---
85
94
 
86
95
  def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups)
@@ -108,16 +117,25 @@ module RSpock
108
117
 
109
118
  def build_test_body(body_node, hoisted_setups)
110
119
  body_children = []
120
+ blocks = body_node.children
111
121
 
112
- body_node.children.each do |block_node|
122
+ blocks.each_with_index do |block_node, i|
113
123
  case block_node.type
114
124
  when :rspock_given
115
125
  body_children.concat(block_node.children)
116
126
  when :rspock_when
117
127
  body_children.concat(hoisted_setups)
118
- body_children.concat(block_node.children)
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
119
135
  when :rspock_then, :rspock_expect
120
- body_children.concat(block_node.children)
136
+ block_node.children.each do |child|
137
+ body_children << child unless child.type == :rspock_raises
138
+ end
121
139
  when :rspock_cleanup
122
140
  # handled below as ensure
123
141
  end
@@ -134,6 +152,31 @@ module RSpock
134
152
  MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast)
135
153
  end
136
154
 
155
+ # --- Raises condition helpers ---
156
+
157
+ def find_raises_in_next_then(blocks, current_index)
158
+ next_block = blocks[current_index + 1]
159
+ return nil unless next_block&.type == :rspock_then
160
+
161
+ next_block.children.find { |c| c.type == :rspock_raises }
162
+ end
163
+
164
+ def build_assert_raises(when_node, raises_node)
165
+ when_body = when_node.children.length == 1 ? when_node.children[0] : s(:begin, *when_node.children)
166
+
167
+ assert_raises_call = s(:block,
168
+ s(:send, nil, :assert_raises, raises_node.exception_class),
169
+ s(:args),
170
+ when_body
171
+ )
172
+
173
+ if raises_node.capture_name
174
+ s(:lvasgn, raises_node.capture_name, assert_raises_call)
175
+ else
176
+ assert_raises_call
177
+ end
178
+ end
179
+
137
180
  # --- Where block helpers ---
138
181
 
139
182
  def build_where_iterator(data_rows)
@@ -1,3 +1,3 @@
1
1
  module RSpock
2
- VERSION = "2.3.1"
2
+ VERSION = "2.5.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspock
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.1
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean-Philippe Duchesne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-27 00:00:00.000000000 Z
11
+ date: 2026-03-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -207,7 +207,6 @@ files:
207
207
  - lib/generators/templates/rspock_initializer.rb
208
208
  - lib/minitest/rspock_plugin.rb
209
209
  - lib/rspock.rb
210
- - lib/rspock/ast/comparison_to_assertion_transformation.rb
211
210
  - lib/rspock/ast/header_nodes_transformation.rb
212
211
  - lib/rspock/ast/interaction_to_block_identity_assertion_transformation.rb
213
212
  - lib/rspock/ast/interaction_to_mocha_mock_transformation.rb
@@ -218,10 +217,12 @@ files:
218
217
  - lib/rspock/ast/parser/expect_block.rb
219
218
  - lib/rspock/ast/parser/given_block.rb
220
219
  - lib/rspock/ast/parser/interaction_parser.rb
220
+ - lib/rspock/ast/parser/statement_parser.rb
221
221
  - lib/rspock/ast/parser/test_method_parser.rb
222
222
  - lib/rspock/ast/parser/then_block.rb
223
223
  - lib/rspock/ast/parser/when_block.rb
224
224
  - lib/rspock/ast/parser/where_block.rb
225
+ - lib/rspock/ast/statement_to_assertion_transformation.rb
225
226
  - lib/rspock/ast/test_method_def_transformation.rb
226
227
  - lib/rspock/ast/test_method_dstr_transformation.rb
227
228
  - lib/rspock/ast/test_method_transformation.rb
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'ast_transform/abstract_transformation'
3
- require 'rspock/ast/method_call_to_lvar_transformation'
4
-
5
- module RSpock
6
- module AST
7
- class ComparisonToAssertionTransformation < ASTTransform::AbstractTransformation
8
- def initialize(*ignored_method_call_symbols)
9
- @method_call_transformation = RSpock::AST::MethodCallToLVarTransformation.new(*ignored_method_call_symbols)
10
- end
11
-
12
- def on_send(node)
13
- if node.children.count == 3 && node.children[1] == :== && ignored_method_call_node?(node)
14
- transform_to_assert_equal(node)
15
- elsif node.children.count == 3 && node.children[1] == :!= && ignored_method_call_node?(node)
16
- transform_to_refute_equal(node)
17
- else
18
- node.updated(nil, process_all(node))
19
- end
20
- end
21
-
22
- private
23
-
24
- def ignored_method_call_node?(node)
25
- return false unless node.is_a?(::Parser::AST::Node)
26
-
27
- !@method_call_transformation.method_call_node?(node.children[0]) &&
28
- !@method_call_transformation.method_call_node?(node.children[2])
29
- end
30
-
31
- def transform_to_assert_equal(node)
32
- node.updated(nil, [nil, :assert_equal, node.children[2], node.children[0]])
33
- end
34
-
35
- def transform_to_refute_equal(node)
36
- node.updated(nil, [nil, :refute_equal, node.children[2], node.children[0]])
37
- end
38
- end
39
- end
40
- end