Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Exception conditions: `raises ExceptionClass` in Then blocks wraps the preceding When block in an exception assertion.
- Exception capture: `e = raises ExceptionClass` captures the exception for further assertions in the same Then block.
- Exception conditions work with data-driven `Where` blocks.

### Changed

- 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`.

## [2.4.0] - 2026-02-28

### Added
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Note: RSpock is heavily inspired by Spock for the Groovy programming language.
* Spock-style implicit assertions: every statement in Then/Expect blocks is an assertion (no assertion API needed)
* Binary operator assertions: `==`, `!=`, `=~`, `!~`, `>`, `<`, `>=`, `<=` with clear error messages
* General statement assertions: bare boolean expressions (e.g. `obj.valid?`, `list.include?(x)`) and negation (`!obj.empty?`) with source-text error messages
* [Exception conditions](#exception-conditions): `raises ExceptionClass` in Then blocks wraps the When block in an exception assertion, with optional capture for property assertions
* [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`
* (Planned) BDD-style custom reporter that outputs information from Code Blocks
* (Planned) Capture all Then block violations
Expand Down Expand Up @@ -166,6 +167,46 @@ The Then block describes the response from the stimulus. Following Spock's core

**Variable assignments** pass through unchanged and execute in source order after the stimulus.

##### Exception Conditions

Use `raises` in a Then block to assert that the preceding When block raises a specific exception:

```ruby
When "Dividing by zero"
1 / 0

Then "An error is raised"
raises ZeroDivisionError
```

To inspect the exception, capture it into a variable:

```ruby
When "Parsing bad input"
JSON.parse("not json")

Then "A parse error is raised with a message"
e = raises JSON::ParserError
e.message.include?("unexpected token")
```

The captured variable is available for further assertions in the same Then block. Exception conditions work with data-driven `Where` blocks as well:

```ruby
When "Parsing invalid input"
JSON.parse(input)

Then "The expected error is raised"
raises expected_error

Where
input | expected_error
"not json" | JSON::ParserError
"{invalid" | JSON::ParserError
```

Only **one** `raises` condition is allowed per Then block, and `raises` is **not supported** in Expect blocks (use a When + Then block instead).

#### Expect Block

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.
Expand Down
8 changes: 4 additions & 4 deletions lib/rspock/ast/interaction_to_mocha_mock_transformation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ module AST
# Output: receiver.expects(:message).with(*args).times(n).returns(value)
#
# The outcome node type maps directly to the Mocha chain method:
# :rspock_returns -> .returns(value)
# :rspock_raises -> .raises(exception_class, ...)
# :rspock_stub_returns -> .returns(value)
# :rspock_stub_raises -> .raises(exception_class, ...)
#
# When block_pass is present, wraps the expects chain with a BlockCapture.capture call.
class InteractionToMochaMockTransformation < ASTTransform::AbstractTransformation
OUTCOME_METHODS = {
rspock_returns: :returns,
rspock_raises: :raises,
rspock_stub_returns: :returns,
rspock_stub_raises: :raises,
}.freeze

def initialize(index = 0)
Expand Down
14 changes: 11 additions & 3 deletions lib/rspock/ast/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,20 @@ def data_rows
class OutcomeNode < Node
end

class ReturnsNode < OutcomeNode
register :rspock_returns
class StubReturnsNode < OutcomeNode
register :rspock_stub_returns
end

class RaisesNode < OutcomeNode
class StubRaisesNode < OutcomeNode
register :rspock_stub_raises
end

class RaisesNode < Node
register :rspock_raises

def exception_class = children[0]
def capture_var = children[1]
def capture_name = capture_var&.children&.[](0)
end

class InteractionNode < Node
Expand Down
5 changes: 5 additions & 0 deletions lib/rspock/ast/parser/expect_block.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ def successors
def to_rspock_node
statement_parser = StatementParser.new
spock_children = @children.map { |child| statement_parser.parse(child) }

if spock_children.any? { |c| c.type == :rspock_raises }
raise BlockError, "raises() is not supported in Expect blocks @ #{range}. Use a When + Then block instead."
end

s(:rspock_expect, *spock_children)
end
end
Expand Down
6 changes: 3 additions & 3 deletions lib/rspock/ast/parser/interaction_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ module Parser
# [1] receiver - e.g. s(:send, nil, :subscriber)
# [2] message - e.g. s(:sym, :receive)
# [3] args - nil if no args, s(:array, *arg_nodes) otherwise
# [4] outcome - nil if no >>, otherwise s(:rspock_returns, value) or s(:rspock_raises, *args)
# [4] outcome - nil if no >>, otherwise s(:rspock_stub_returns, value) or s(:rspock_stub_raises, *args)
# [5] block_pass - nil if no &, otherwise s(:block_pass, ...)
class InteractionParser
include RSpock::AST::NodeBuilder
Expand Down Expand Up @@ -63,9 +63,9 @@ def return_value_node?(node)

def parse_outcome(node)
if node.type == :send && node.children[0].nil? && node.children[1] == :raises
s(:rspock_raises, *node.children[2..])
s(:rspock_stub_raises, *node.children[2..])
else
s(:rspock_returns, node)
s(:rspock_stub_returns, node)
end
end

Expand Down
27 changes: 27 additions & 0 deletions lib/rspock/ast/parser/statement_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class StatementParser
ASSIGNMENT_TYPES = %i[lvasgn masgn op_asgn or_asgn and_asgn].freeze

def parse(node)
return build_raises(node) if raises_condition?(node)
return node if assignment?(node)
return build_binary_statement(node) if binary_statement?(node)

Expand All @@ -24,6 +25,32 @@ def parse(node)

private

def raises_condition?(node)
direct_raises?(node) || assigned_raises?(node)
end

def direct_raises?(node)
node.type == :send && node.children[0].nil? && node.children[1] == :raises
end

def assigned_raises?(node)
node.type == :lvasgn &&
node.children[1]&.type == :send &&
node.children[1].children[0].nil? &&
node.children[1].children[1] == :raises
end

def build_raises(node)
if node.type == :lvasgn
variable = s(:sym, node.children[0])
exception_class = node.children[1].children[2]
s(:rspock_raises, exception_class, variable)
else
exception_class = node.children[2]
s(:rspock_raises, exception_class)
end
end

def assignment?(node)
ASSIGNMENT_TYPES.include?(node.type)
end
Expand Down
5 changes: 5 additions & 0 deletions lib/rspock/ast/parser/then_block.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ def to_rspock_node
statement_parser.parse(child)
end

raises_count = spock_children.count { |c| c.type == :rspock_raises }
if raises_count > 1
raise BlockError, "Then block @ #{range} may contain at most one raises() condition"
end

s(:rspock_then, *spock_children)
end
end
Expand Down
40 changes: 37 additions & 3 deletions lib/rspock/ast/test_method_transformation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,25 @@ def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups)

def build_test_body(body_node, hoisted_setups)
body_children = []
blocks = body_node.children

body_node.children.each do |block_node|
blocks.each_with_index do |block_node, i|
case block_node.type
when :rspock_given
body_children.concat(block_node.children)
when :rspock_when
body_children.concat(hoisted_setups)
body_children.concat(block_node.children)
raises_node = find_raises_in_next_then(blocks, i)

if raises_node
body_children << build_assert_raises(block_node, raises_node)
else
body_children.concat(block_node.children)
end
when :rspock_then, :rspock_expect
body_children.concat(block_node.children)
block_node.children.each do |child|
body_children << child unless child.type == :rspock_raises
end
when :rspock_cleanup
# handled below as ensure
end
Expand All @@ -143,6 +152,31 @@ def build_test_body(body_node, hoisted_setups)
MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast)
end

# --- Raises condition helpers ---

def find_raises_in_next_then(blocks, current_index)
next_block = blocks[current_index + 1]
return nil unless next_block&.type == :rspock_then

next_block.children.find { |c| c.type == :rspock_raises }
end

def build_assert_raises(when_node, raises_node)
when_body = when_node.children.length == 1 ? when_node.children[0] : s(:begin, *when_node.children)

assert_raises_call = s(:block,
s(:send, nil, :assert_raises, raises_node.exception_class),
s(:args),
when_body
)

if raises_node.capture_name
s(:lvasgn, raises_node.capture_name, assert_raises_call)
else
assert_raises_call
end
end

# --- Where block helpers ---

def build_where_iterator(data_rows)
Expand Down
38 changes: 38 additions & 0 deletions test/example_rspock_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,44 @@ def mul(a, b)
result == "item"
end

# --- Raises conditions ---

test "raises catches expected exception" do
Given
stack = []

When
stack.fetch(99)

Then
raises IndexError
end

test "raises with capture allows property assertions" do
Given
stack = []

When
stack.fetch(99)

Then
e = raises IndexError
e.message =~ /index 99/
end

test "raises with Where block for #{error_class}" do
When
Integer(input)

Then
raises error_class

Where
input | error_class
"abc" | ArgumentError
"hello" | ArgumentError
end

test "interactions" do
Given
dep = mock
Expand Down
9 changes: 9 additions & 0 deletions test/rspock/ast/parser/expect_block_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ def setup
assert_equal [comparison], actual
end

test "#to_rspock_node raises error when raises() is used" do
@transformer = ASTTransform::Transformer.new
@block << @transformer.build_ast('raises EmptyStackError')

assert_raises BlockError do
@block.to_rspock_node
end
end

test "#to_rspock_node returns :rspock_expect node with statement children" do
@block << s(:send, 1, :==, 2)
@block << s(:send, 1, :!=, 2)
Expand Down
12 changes: 6 additions & 6 deletions test/rspock/ast/parser/interaction_parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,21 +122,21 @@ def setup
assert_nil ir.outcome
end

test "#parse wraps >> value in :rspock_returns" do
test "#parse wraps >> value in :rspock_stub_returns" do
ir = parse('1 * receiver.message >> "result"')
assert_equal :rspock_returns, ir.outcome.type
assert_equal :rspock_stub_returns, ir.outcome.type
assert_equal s(:str, "result"), ir.outcome.children[0]
end

test "#parse wraps >> raises(ExClass) in :rspock_raises" do
test "#parse wraps >> raises(ExClass) in :rspock_stub_raises" do
ir = parse('1 * receiver.message >> raises(SomeError)')
assert_equal :rspock_raises, ir.outcome.type
assert_equal :rspock_stub_raises, ir.outcome.type
assert_equal :const, ir.outcome.children[0].type
end

test "#parse wraps >> raises(ExClass, msg) in :rspock_raises with two children" do
test "#parse wraps >> raises(ExClass, msg) in :rspock_stub_raises with two children" do
ir = parse('1 * receiver.message >> raises(SomeError, "oops")')
assert_equal :rspock_raises, ir.outcome.type
assert_equal :rspock_stub_raises, ir.outcome.type
assert_equal 2, ir.outcome.children.length
assert_equal :const, ir.outcome.children[0].type
assert_equal s(:str, "oops"), ir.outcome.children[1]
Expand Down
35 changes: 35 additions & 0 deletions test/rspock/ast/parser/statement_parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,41 @@ def setup
assert_equal :rspock_statement, result.type
end

# --- Raises condition ---

test "#parse wraps raises(ExClass) in :rspock_raises" do
ast = build_ast('raises EmptyStackError')
result = @parser.parse(ast)

assert_equal :rspock_raises, result.type
assert_equal s(:const, nil, :EmptyStackError), result.exception_class
assert_nil result.capture_var
end

test "#parse wraps e = raises(ExClass) in :rspock_raises with capture_var" do
ast = build_ast('e = raises EmptyStackError')
result = @parser.parse(ast)

assert_equal :rspock_raises, result.type
assert_equal s(:const, nil, :EmptyStackError), result.exception_class
assert_equal s(:sym, :e), result.capture_var
assert_equal :e, result.capture_name
end

test "#parse does not treat regular assignment as raises condition" do
ast = build_ast('x = 1')
result = @parser.parse(ast)

assert_equal :lvasgn, result.type
end

test "#parse does not treat receiver.raises as raises condition" do
ast = build_ast('obj.raises')
result = @parser.parse(ast)

assert_equal :rspock_statement, result.type
end

# --- Does not classify non-binary sends as binary ---

test "#parse does not treat method call with args as binary statement" do
Expand Down
Loading