From 8792865079f7fdc4a17b34417790ab0b459b9ca2 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Fri, 27 Feb 2026 14:23:46 -0500 Subject: [PATCH] feat: add >> raises(...) for exception stubbing in interactions Introduces the "outcome" concept to interactions, replacing the raw return_value field. The >> operator now produces a typed outcome node (:rspock_returns or :rspock_raises) at parse time, and the transformation generically forwards the outcome type and args to Mocha. Made-with: Cursor --- README.md | 39 +++++++++++++++++-- ...nteraction_to_mocha_mock_transformation.rb | 13 ++++++- lib/rspock/ast/node.rb | 13 ++++++- lib/rspock/ast/parser/interaction_parser.rb | 16 ++++++-- ...ction_to_mocha_mock_transformation_test.rb | 35 +++++++++++++++++ .../ast/parser/interaction_parser_test.rb | 25 +++++++++--- test/rspock/ast/parser/then_block_test.rb | 8 ++-- 7 files changed, 130 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 72d813a..7eebe95 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Note: RSpock is heavily inspired by Spock for the Groovy programming language. * BDD-style code blocks: Given, When, Then, Expect, Cleanup, Where * Data-driven testing with incredibly expressive table-based Where blocks * Expressive assertions: Use familiar comparison operators `==` and `!=` for 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 `>>` and [block forwarding verification](#block-forwarding-verification) via `&block` +* [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 @@ -306,12 +306,12 @@ test "#publish sends a message to all subscribers" do end ``` -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. +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. ``` 1 * receiver.message('hello', &blk) >> "result" | | | | | | -| | | | | return value (optional) +| | | | | outcome (optional): value or raises(...) | | | | block forwarding (optional) | | | argument(s) (optional) | | message @@ -421,6 +421,39 @@ _ * cache.fetch("key") >> expensive_result # a variable **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. +#### Stubbing Exceptions + +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. + +```ruby +test "#fetch raises when the record is not found" do + Given + repository = mock + service = Service.new(repository) + + When + service.fetch(42) + + Then + 1 * repository.find(42) >> raises(RecordNotFound) +end +``` + +You can also pass a message or an exception instance: + +```ruby +1 * repository.find(42) >> raises(RecordNotFound, "not found") # class + message +1 * repository.find(42) >> raises(RecordNotFound.new("not found")) # instance +``` + +This works with all interaction features — cardinality, arguments, and ranges: + +```ruby +(1..3) * service.call(anything) >> raises(TimeoutError) +``` + +**Note**: Without `raises(...)`, the `>>` operator stubs a return value. With `raises(...)`, it stubs an exception instead. + #### Block Forwarding Verification 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. diff --git a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb index 7f9ba08..3f5aae1 100644 --- a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb +++ b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb @@ -6,11 +6,20 @@ module RSpock module AST # Transforms an :rspock_interaction node into Mocha mock setup code. # - # Input: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass) + # Input: s(:rspock_interaction, cardinality, receiver, sym, args, outcome, block_pass) # 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, ...) + # # 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, + }.freeze + def initialize(index = 0) @index = index end @@ -21,7 +30,7 @@ def run(interaction) result = chain_call(interaction.receiver, :expects, s(:sym, interaction.message)) result = chain_call(result, :with, *interaction.args.children) if interaction.args result = build_cardinality(result, interaction.cardinality) - result = chain_call(result, :returns, interaction.return_value) if interaction.return_value + result = chain_call(result, OUTCOME_METHODS.fetch(interaction.outcome.type), *interaction.outcome.children) if interaction.outcome if interaction.block_pass build_block_capture_setup(result, interaction.receiver, interaction.message) diff --git a/lib/rspock/ast/node.rb b/lib/rspock/ast/node.rb index 68bc060..a9a4fcc 100644 --- a/lib/rspock/ast/node.rb +++ b/lib/rspock/ast/node.rb @@ -70,6 +70,17 @@ def data_rows end end + class OutcomeNode < Node + end + + class ReturnsNode < OutcomeNode + register :rspock_returns + end + + class RaisesNode < OutcomeNode + register :rspock_raises + end + class InteractionNode < Node register :rspock_interaction @@ -78,7 +89,7 @@ def receiver = children[1] def message_sym = children[2] def message = message_sym.children[0] def args = children[3] - def return_value = children[4] + def outcome = children[4] def block_pass = children[5] end diff --git a/lib/rspock/ast/parser/interaction_parser.rb b/lib/rspock/ast/parser/interaction_parser.rb index ea20639..9dab10d 100644 --- a/lib/rspock/ast/parser/interaction_parser.rb +++ b/lib/rspock/ast/parser/interaction_parser.rb @@ -7,14 +7,14 @@ module Parser # Parses raw Ruby AST interaction nodes into structured :rspock_interaction nodes. # # Input: 1 * receiver.message("arg", &blk) >> "result" - # Output: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass) + # Output: s(:rspock_interaction, cardinality, receiver, sym, args, outcome, block_pass) # # :rspock_interaction children: # [0] cardinality - e.g. s(:int, 1), s(:begin, s(:irange, ...)), s(:send, nil, :_) # [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] return_value - nil if no >>, otherwise the value node + # [4] outcome - nil if no >>, otherwise s(:rspock_returns, value) or s(:rspock_raises, *args) # [5] block_pass - nil if no &, otherwise s(:block_pass, ...) class InteractionParser include RSpock::AST::NodeBuilder @@ -35,7 +35,7 @@ def parse(node) return node unless interaction_node?(node) if return_value_node?(node) - return_value = node.children[2] + outcome = parse_outcome(node.children[2]) node = node.children[0] end @@ -50,7 +50,7 @@ def parse(node) receiver, s(:sym, message), args, - return_value, + outcome, block_pass ) end @@ -61,6 +61,14 @@ def return_value_node?(node) node.type == :send && node.children[1] == :>> && interaction_node?(node.children[0]) end + def parse_outcome(node) + if node.type == :send && node.children[0].nil? && node.children[1] == :raises + s(:rspock_raises, *node.children[2..]) + else + s(:rspock_returns, node) + end + end + def validate_cardinality(node) case node.type when *ALLOWED_CARDINALITY_NODES diff --git a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb index a7af222..d19a225 100644 --- a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb +++ b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb @@ -154,6 +154,41 @@ def setup refute_match(/returns/, result) end + test ">> raises(ExClass) stubs exception" do + assert_transforms( + '1 * receiver.message >> raises(SomeError)', + 'receiver.expects(:message).times(1).raises(SomeError)' + ) + end + + test ">> raises(ExClass, message) stubs exception with message" do + assert_transforms( + '1 * receiver.message >> raises(SomeError, "oops")', + 'receiver.expects(:message).times(1).raises(SomeError, "oops")' + ) + end + + test ">> raises(ExClass.new(msg)) stubs exception instance" do + assert_transforms( + '1 * receiver.message >> raises(SomeError.new("oops"))', + 'receiver.expects(:message).times(1).raises(SomeError.new("oops"))' + ) + end + + test ">> raises with params and cardinality" do + assert_transforms( + '1 * receiver.message(param1, param2) >> raises(SomeError)', + 'receiver.expects(:message).with(param1, param2).times(1).raises(SomeError)' + ) + end + + test ">> raises with range cardinality" do + assert_transforms( + '(1..3) * receiver.message >> raises(SomeError)', + 'receiver.expects(:message).at_least(1).at_most(3).raises(SomeError)' + ) + end + test "&block produces setup with expects and capture" do ir_node = parse_to_ir('1 * receiver.message("arg", &my_proc)') result = @transformation.run(ir_node) diff --git a/test/rspock/ast/parser/interaction_parser_test.rb b/test/rspock/ast/parser/interaction_parser_test.rb index ac233d6..1aa160c 100644 --- a/test/rspock/ast/parser/interaction_parser_test.rb +++ b/test/rspock/ast/parser/interaction_parser_test.rb @@ -115,16 +115,31 @@ def setup assert_equal 2, args.children.length end - # --- parse: return value --- + # --- parse: outcome --- - test "#parse sets return_value to nil without >>" do + test "#parse sets outcome to nil without >>" do ir = parse('1 * receiver.message') - assert_nil ir.children[4] + assert_nil ir.outcome end - test "#parse extracts return value from >>" do + test "#parse wraps >> value in :rspock_returns" do ir = parse('1 * receiver.message >> "result"') - assert_equal s(:str, "result"), ir.children[4] + assert_equal :rspock_returns, ir.outcome.type + assert_equal s(:str, "result"), ir.outcome.children[0] + end + + test "#parse wraps >> raises(ExClass) in :rspock_raises" do + ir = parse('1 * receiver.message >> raises(SomeError)') + assert_equal :rspock_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 + ir = parse('1 * receiver.message >> raises(SomeError, "oops")') + assert_equal :rspock_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] end # --- parse: block pass --- diff --git a/test/rspock/ast/parser/then_block_test.rb b/test/rspock/ast/parser/then_block_test.rb index 71cafff..3f86f77 100644 --- a/test/rspock/ast/parser/then_block_test.rb +++ b/test/rspock/ast/parser/then_block_test.rb @@ -108,7 +108,7 @@ def setup assert_equal :block_pass, block_pass.type end - test "#to_rspock_node parses interaction with >> return value" do + test "#to_rspock_node parses interaction with >> outcome" do node = @transformer.build_ast('1 * receiver.message >> "result"') @block << node @@ -117,9 +117,9 @@ def setup assert_equal :rspock_interaction, interaction.type - return_value = interaction.children[4] - refute_nil return_value - assert_equal :str, return_value.type + outcome = interaction.outcome + refute_nil outcome + assert_equal :rspock_returns, outcome.type end test "#to_rspock_node handles mixed interaction and comparison nodes" do