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
42 changes: 36 additions & 6 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 `>>`
* [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`
* (Planned) BDD-style custom reporter that outputs information from Code Blocks
* (Planned) Capture all Then block violations

Expand Down Expand Up @@ -306,20 +306,31 @@ 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.
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.

```
1 * receiver.message('hello') >> "result"
| | | | |
| | | | return value (optional)
| | | argument(s)
1 * receiver.message('hello', &blk) >> "result"
| | | | | |
| | | | | return value (optional)
| | | | block forwarding (optional)
| | | argument(s) (optional)
| | message
| receiver
cardinality
```

Note: Interactions are supported in the ___Then___ block only.

#### Execution Order

Although interactions are _declared_ in the ___Then___ block, they are effectively active _before_ the ___When___ block executes. RSpock ensures the following order:

1. **Before the stimulus** — mock expectations and block captures are installed on the receiver, so they are ready to intercept calls.
2. **Stimulus** — the ___When___ block runs.
3. **After the stimulus** — assertions such as block identity checks run alongside other ___Then___ assertions. Cardinality is verified at teardown.

Simply declare _what_ should happen in a natural order — RSpock handles the _when_.

#### Cardinality

The cardinality of an interaction describes how often a method is expected to be called. It can be a fixed number of times, or a range.
Expand Down Expand Up @@ -410,6 +421,25 @@ _ * 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.

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

```ruby
test "#frame forwards the block to CLI::UI.frame" do
Given "a block to forward"
my_block = proc { puts "hello" }

When "we call frame with that block"
@ui.frame("Build", &my_block)

Then "the block was forwarded to cli_ui.frame"
1 * @cli_ui.frame("Build", to: @out, &my_block)
end
```

**Note**: Inline blocks (`do...end` or `{ }`) are not supported in interactions and will raise an `InteractionError`. Use a named proc or lambda variable with `&` instead, since block forwarding verification requires a reference to compare against.

## Debugging

### Pry
Expand Down
95 changes: 0 additions & 95 deletions lib/rspock/ast/block.rb

This file was deleted.

16 changes: 0 additions & 16 deletions lib/rspock/ast/cleanup_block.rb

This file was deleted.

2 changes: 1 addition & 1 deletion lib/rspock/ast/comparison_to_assertion_transformation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def on_send(node)
private

def ignored_method_call_node?(node)
return false unless node.is_a?(Parser::AST::Node)
return false unless node.is_a?(::Parser::AST::Node)

!@method_call_transformation.method_call_node?(node.children[0]) &&
!@method_call_transformation.method_call_node?(node.children[2])
Expand Down
17 changes: 0 additions & 17 deletions lib/rspock/ast/end_block.rb

This file was deleted.

21 changes: 0 additions & 21 deletions lib/rspock/ast/expect_block.rb

This file was deleted.

16 changes: 0 additions & 16 deletions lib/rspock/ast/given_block.rb

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'ast_transform/abstract_transformation'
require 'rspock/ast/node'

module RSpock
module AST
# Transforms an :rspock_interaction node into an assert_same assertion
# when a block_pass (&var) is present.
#
# Returns the node unchanged (passthrough) when no block_pass is present.
class InteractionToBlockIdentityAssertionTransformation < ASTTransform::AbstractTransformation
def initialize(index = 0)
@index = index
end

def run(interaction)
return interaction unless interaction.type == :rspock_interaction
return interaction unless interaction.block_pass

capture_var = :"__rspock_blk_#{@index}"
block_var = interaction.block_pass.children[0]

s(:send, nil, :assert_same,
block_var,
s(:send, s(:lvar, capture_var), :call)
)
end
end
end
end
104 changes: 104 additions & 0 deletions lib/rspock/ast/interaction_to_mocha_mock_transformation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# frozen_string_literal: true
require 'ast_transform/abstract_transformation'
require 'rspock/ast/node'

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)
# Output: receiver.expects(:message).with(*args).times(n).returns(value)
#
# When block_pass is present, wraps the expects chain with a BlockCapture.capture call.
class InteractionToMochaMockTransformation < ASTTransform::AbstractTransformation
def initialize(index = 0)
@index = index
end

def run(interaction)
return interaction unless interaction.type == :rspock_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

if interaction.block_pass
build_block_capture_setup(result, interaction.receiver, interaction.message)
else
result
end
end

private

def build_cardinality(result, cardinality)
if any_matcher_node?(cardinality)
chain_call(result, :at_least, s(:int, 0))
elsif [:send, :lvar, :int].include?(cardinality.type)
chain_call(result, :times, cardinality)
elsif cardinality.type == :begin && cardinality.children[0]&.type == :irange
min_node, max_node = cardinality.children[0].children
build_irange(result, min_node, max_node)
elsif cardinality.type == :begin && cardinality.children[0]&.type == :erange
min_node, max_node = cardinality.children[0].children
max_node = chain_call(max_node, :-, s(:int, 1))
build_erange(result, min_node, max_node)
else
raise ArgumentError, "Unrecognized cardinality in :rspock_interaction: #{cardinality.type}"
end
end

def build_irange(result, min_node, max_node)
if any_matcher_node?(min_node) && any_matcher_node?(max_node)
chain_call(result, :at_least, s(:int, 0))
elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node)
chain_call(result, :at_least, min_node)
elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node)
result = chain_call(result, :at_least, s(:int, 0))
chain_call(result, :at_most, max_node)
else
result = chain_call(result, :at_least, min_node)
chain_call(result, :at_most, max_node)
end
end

def build_erange(result, min_node, max_node)
if any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0])
chain_call(result, :at_least, s(:int, 0))
elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0])
chain_call(result, :at_least, min_node)
elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node.children[0])
result = chain_call(result, :at_least, s(:int, 0))
chain_call(result, :at_most, max_node)
else
result = chain_call(result, :at_least, min_node)
chain_call(result, :at_most, max_node)
end
end

def build_block_capture_setup(expects_node, receiver, message)
capture_var = :"__rspock_blk_#{@index}"

capture_call = s(:lvasgn, capture_var,
s(:send,
s(:const, s(:const, s(:const, nil, :RSpock), :Helpers), :BlockCapture),
:capture,
receiver,
s(:sym, message)
)
)

s(:begin, expects_node, capture_call)
end

def chain_call(receiver_node, method_name, *arg_nodes)
s(:send, receiver_node, method_name, *arg_nodes)
end

def any_matcher_node?(node)
node.type == :send && node.children[0].nil? && node.children[1] == :_
end
end
end
end
Loading