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
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions lib/rspock/ast/interaction_to_mocha_mock_transformation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion lib/rspock/ast/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
16 changes: 12 additions & 4 deletions lib/rspock/ast/parser/interaction_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -50,7 +50,7 @@ def parse(node)
receiver,
s(:sym, message),
args,
return_value,
outcome,
block_pass
)
end
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 20 additions & 5 deletions test/rspock/ast/parser/interaction_parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
8 changes: 4 additions & 4 deletions test/rspock/ast/parser/then_block_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down