diff --git a/CHANGELOG.md b/CHANGELOG.md index 7125e2b..5a6279e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 349b626..399d799 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb index 3f5aae1..d00f37d 100644 --- a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb +++ b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb @@ -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) diff --git a/lib/rspock/ast/node.rb b/lib/rspock/ast/node.rb index c9e6893..6c48881 100644 --- a/lib/rspock/ast/node.rb +++ b/lib/rspock/ast/node.rb @@ -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 diff --git a/lib/rspock/ast/parser/expect_block.rb b/lib/rspock/ast/parser/expect_block.rb index 3eb4e0f..a4c10ed 100644 --- a/lib/rspock/ast/parser/expect_block.rb +++ b/lib/rspock/ast/parser/expect_block.rb @@ -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 diff --git a/lib/rspock/ast/parser/interaction_parser.rb b/lib/rspock/ast/parser/interaction_parser.rb index 9dab10d..e768551 100644 --- a/lib/rspock/ast/parser/interaction_parser.rb +++ b/lib/rspock/ast/parser/interaction_parser.rb @@ -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 @@ -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 diff --git a/lib/rspock/ast/parser/statement_parser.rb b/lib/rspock/ast/parser/statement_parser.rb index a512040..df6035f 100644 --- a/lib/rspock/ast/parser/statement_parser.rb +++ b/lib/rspock/ast/parser/statement_parser.rb @@ -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) @@ -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 diff --git a/lib/rspock/ast/parser/then_block.rb b/lib/rspock/ast/parser/then_block.rb index 2b3b9be..648c232 100644 --- a/lib/rspock/ast/parser/then_block.rb +++ b/lib/rspock/ast/parser/then_block.rb @@ -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 diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index 3564938..79cb75c 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -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 @@ -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) diff --git a/test/example_rspock_test.rb b/test/example_rspock_test.rb index 3ba951e..044ab38 100644 --- a/test/example_rspock_test.rb +++ b/test/example_rspock_test.rb @@ -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 diff --git a/test/rspock/ast/parser/expect_block_test.rb b/test/rspock/ast/parser/expect_block_test.rb index 14f70fc..db254d5 100644 --- a/test/rspock/ast/parser/expect_block_test.rb +++ b/test/rspock/ast/parser/expect_block_test.rb @@ -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) diff --git a/test/rspock/ast/parser/interaction_parser_test.rb b/test/rspock/ast/parser/interaction_parser_test.rb index 1aa160c..93a40dc 100644 --- a/test/rspock/ast/parser/interaction_parser_test.rb +++ b/test/rspock/ast/parser/interaction_parser_test.rb @@ -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] diff --git a/test/rspock/ast/parser/statement_parser_test.rb b/test/rspock/ast/parser/statement_parser_test.rb index baec9ed..14a20f7 100644 --- a/test/rspock/ast/parser/statement_parser_test.rb +++ b/test/rspock/ast/parser/statement_parser_test.rb @@ -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 diff --git a/test/rspock/ast/parser/then_block_test.rb b/test/rspock/ast/parser/then_block_test.rb index 3140a23..15cd2ff 100644 --- a/test/rspock/ast/parser/then_block_test.rb +++ b/test/rspock/ast/parser/then_block_test.rb @@ -122,7 +122,7 @@ def setup outcome = interaction.outcome refute_nil outcome - assert_equal :rspock_returns, outcome.type + assert_equal :rspock_stub_returns, outcome.type end test "#to_rspock_node handles mixed interaction and comparison nodes" do @@ -136,6 +136,25 @@ def setup assert_equal :rspock_interaction, ir.children[1].type end + test "#to_rspock_node converts raises() to :rspock_raises" do + node = @transformer.build_ast('raises EmptyStackError') + @block << node + + ir = @block.to_rspock_node + assert_equal :rspock_then, ir.type + assert_equal 1, ir.children.length + assert_equal :rspock_raises, ir.children[0].type + end + + test "#to_rspock_node raises on multiple raises() conditions" do + @block << @transformer.build_ast('raises EmptyStackError') + @block << @transformer.build_ast('raises AnotherError') + + assert_raises BlockError do + @block.to_rspock_node + end + end + test "#to_rspock_node with multiple interactions has correct indices" do node0 = @transformer.build_ast('1 * receiver.method1(&proc1)') node1 = @transformer.build_ast('1 * receiver.method2(&proc2)')