rspock 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40f95078010f80a7874ef4cd38a286629ea2fa1c6355dd2f8b15f8b1b8f050f6
4
- data.tar.gz: 7b78667195ce9c030bf0f32de406f65ae463d69be810838aff25b525b0f2e755
3
+ metadata.gz: e0a24add88d2e5100613beaedf3c8b158e3d463a04e17377c99f2a2c98b63157
4
+ data.tar.gz: d179439aaf99e0e50a6565cadae7c2541b3ae2e399fb847cc073023ad130e7d3
5
5
  SHA512:
6
- metadata.gz: 301ca7b7cf35271b5b28d79acfdf135d11fd35979e4c5a3497e5d16c8bc314fa8dc9cac12053070577f93373b83ae3c5dc3321f38f57b508ca51ebf8c97f53bc
7
- data.tar.gz: 98777999b2c5f4f028c66fe6e4fc109cbcce0bd024ea6729cd8d0fbe1420e512d706c5482d99c5f09f0a580887ed82ed5f19d2962d258628f7aee94c05f3d1e4
6
+ metadata.gz: bfbf1995a238d569be2c5cb598e02808a77847d8bc3a0a785a5448af6bcebfc7d222e746da44892e4fa2e17c2c74153049ed4d1297cc815900c173e385b9ef3e
7
+ data.tar.gz: 76b576315949d04b5579ff7ead6b6d85330d0030e3045680a4eb0a369b721d1c40b53d3353d5696f02c182fd0ce4b026ab7ba9fa53dd70569aa2ef33d35bea23
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rspock (2.2.0)
4
+ rspock (2.3.1)
5
5
  ast_transform (~> 2.0)
6
6
  minitest (~> 5.0)
7
7
  mocha (>= 1.0)
data/README.md CHANGED
@@ -17,7 +17,7 @@ Note: RSpock is heavily inspired by Spock for the Groovy programming language.
17
17
  * BDD-style code blocks: Given, When, Then, Expect, Cleanup, Where
18
18
  * Data-driven testing with incredibly expressive table-based Where blocks
19
19
  * Expressive assertions: Use familiar comparison operators `==` and `!=` for assertions!
20
- * [Interaction-based testing](#mocking-with-interactions), i.e. `1 * object.receive("message")` in Then blocks, with optional [return value stubbing](#stubbing-return-values) via `>>`
20
+ * [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`
21
21
  * (Planned) BDD-style custom reporter that outputs information from Code Blocks
22
22
  * (Planned) Capture all Then block violations
23
23
 
@@ -306,13 +306,14 @@ test "#publish sends a message to all subscribers" do
306
306
  end
307
307
  ```
308
308
 
309
- 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.
309
+ 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.
310
310
 
311
311
  ```
312
- 1 * receiver.message('hello') >> "result"
313
- | | | | |
314
- | | | | return value (optional)
315
- | | | argument(s)
312
+ 1 * receiver.message('hello', &blk) >> "result"
313
+ | | | | | |
314
+ | | | | | return value (optional)
315
+ | | | | block forwarding (optional)
316
+ | | | argument(s) (optional)
316
317
  | | message
317
318
  | receiver
318
319
  cardinality
@@ -320,6 +321,16 @@ cardinality
320
321
 
321
322
  Note: Interactions are supported in the ___Then___ block only.
322
323
 
324
+ #### Execution Order
325
+
326
+ Although interactions are _declared_ in the ___Then___ block, they are effectively active _before_ the ___When___ block executes. RSpock ensures the following order:
327
+
328
+ 1. **Before the stimulus** — mock expectations and block captures are installed on the receiver, so they are ready to intercept calls.
329
+ 2. **Stimulus** — the ___When___ block runs.
330
+ 3. **After the stimulus** — assertions such as block identity checks run alongside other ___Then___ assertions. Cardinality is verified at teardown.
331
+
332
+ Simply declare _what_ should happen in a natural order — RSpock handles the _when_.
333
+
323
334
  #### Cardinality
324
335
 
325
336
  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.
@@ -410,6 +421,25 @@ _ * cache.fetch("key") >> expensive_result # a variable
410
421
 
411
422
  **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.
412
423
 
424
+ #### Block Forwarding Verification
425
+
426
+ 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.
427
+
428
+ ```ruby
429
+ test "#frame forwards the block to CLI::UI.frame" do
430
+ Given "a block to forward"
431
+ my_block = proc { puts "hello" }
432
+
433
+ When "we call frame with that block"
434
+ @ui.frame("Build", &my_block)
435
+
436
+ Then "the block was forwarded to cli_ui.frame"
437
+ 1 * @cli_ui.frame("Build", to: @out, &my_block)
438
+ end
439
+ ```
440
+
441
+ **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.
442
+
413
443
  ## Debugging
414
444
 
415
445
  ### Pry
@@ -22,7 +22,7 @@ module RSpock
22
22
  private
23
23
 
24
24
  def ignored_method_call_node?(node)
25
- return false unless node.is_a?(Parser::AST::Node)
25
+ return false unless node.is_a?(::Parser::AST::Node)
26
26
 
27
27
  !@method_call_transformation.method_call_node?(node.children[0]) &&
28
28
  !@method_call_transformation.method_call_node?(node.children[2])
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ require 'ast_transform/abstract_transformation'
3
+ require 'rspock/ast/node'
4
+
5
+ module RSpock
6
+ module AST
7
+ # Transforms an :rspock_interaction node into an assert_same assertion
8
+ # when a block_pass (&var) is present.
9
+ #
10
+ # Returns the node unchanged (passthrough) when no block_pass is present.
11
+ class InteractionToBlockIdentityAssertionTransformation < ASTTransform::AbstractTransformation
12
+ def initialize(index = 0)
13
+ @index = index
14
+ end
15
+
16
+ def run(interaction)
17
+ return interaction unless interaction.type == :rspock_interaction
18
+ return interaction unless interaction.block_pass
19
+
20
+ capture_var = :"__rspock_blk_#{@index}"
21
+ block_var = interaction.block_pass.children[0]
22
+
23
+ s(:send, nil, :assert_same,
24
+ block_var,
25
+ s(:send, s(:lvar, capture_var), :call)
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+ require 'ast_transform/abstract_transformation'
3
+ require 'rspock/ast/node'
4
+
5
+ module RSpock
6
+ module AST
7
+ # Transforms an :rspock_interaction node into Mocha mock setup code.
8
+ #
9
+ # Input: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass)
10
+ # Output: receiver.expects(:message).with(*args).times(n).returns(value)
11
+ #
12
+ # When block_pass is present, wraps the expects chain with a BlockCapture.capture call.
13
+ class InteractionToMochaMockTransformation < ASTTransform::AbstractTransformation
14
+ def initialize(index = 0)
15
+ @index = index
16
+ end
17
+
18
+ def run(interaction)
19
+ return interaction unless interaction.type == :rspock_interaction
20
+
21
+ result = chain_call(interaction.receiver, :expects, s(:sym, interaction.message))
22
+ result = chain_call(result, :with, *interaction.args.children) if interaction.args
23
+ result = build_cardinality(result, interaction.cardinality)
24
+ result = chain_call(result, :returns, interaction.return_value) if interaction.return_value
25
+
26
+ if interaction.block_pass
27
+ build_block_capture_setup(result, interaction.receiver, interaction.message)
28
+ else
29
+ result
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def build_cardinality(result, cardinality)
36
+ if any_matcher_node?(cardinality)
37
+ chain_call(result, :at_least, s(:int, 0))
38
+ elsif [:send, :lvar, :int].include?(cardinality.type)
39
+ chain_call(result, :times, cardinality)
40
+ elsif cardinality.type == :begin && cardinality.children[0]&.type == :irange
41
+ min_node, max_node = cardinality.children[0].children
42
+ build_irange(result, min_node, max_node)
43
+ elsif cardinality.type == :begin && cardinality.children[0]&.type == :erange
44
+ min_node, max_node = cardinality.children[0].children
45
+ max_node = chain_call(max_node, :-, s(:int, 1))
46
+ build_erange(result, min_node, max_node)
47
+ else
48
+ raise ArgumentError, "Unrecognized cardinality in :rspock_interaction: #{cardinality.type}"
49
+ end
50
+ end
51
+
52
+ def build_irange(result, min_node, max_node)
53
+ if any_matcher_node?(min_node) && any_matcher_node?(max_node)
54
+ chain_call(result, :at_least, s(:int, 0))
55
+ elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node)
56
+ chain_call(result, :at_least, min_node)
57
+ elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node)
58
+ result = chain_call(result, :at_least, s(:int, 0))
59
+ chain_call(result, :at_most, max_node)
60
+ else
61
+ result = chain_call(result, :at_least, min_node)
62
+ chain_call(result, :at_most, max_node)
63
+ end
64
+ end
65
+
66
+ def build_erange(result, min_node, max_node)
67
+ if any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0])
68
+ chain_call(result, :at_least, s(:int, 0))
69
+ elsif !any_matcher_node?(min_node) && any_matcher_node?(max_node.children[0])
70
+ chain_call(result, :at_least, min_node)
71
+ elsif any_matcher_node?(min_node) && !any_matcher_node?(max_node.children[0])
72
+ result = chain_call(result, :at_least, s(:int, 0))
73
+ chain_call(result, :at_most, max_node)
74
+ else
75
+ result = chain_call(result, :at_least, min_node)
76
+ chain_call(result, :at_most, max_node)
77
+ end
78
+ end
79
+
80
+ def build_block_capture_setup(expects_node, receiver, message)
81
+ capture_var = :"__rspock_blk_#{@index}"
82
+
83
+ capture_call = s(:lvasgn, capture_var,
84
+ s(:send,
85
+ s(:const, s(:const, s(:const, nil, :RSpock), :Helpers), :BlockCapture),
86
+ :capture,
87
+ receiver,
88
+ s(:sym, message)
89
+ )
90
+ )
91
+
92
+ s(:begin, expects_node, capture_call)
93
+ end
94
+
95
+ def chain_call(receiver_node, method_name, *arg_nodes)
96
+ s(:send, receiver_node, method_name, *arg_nodes)
97
+ end
98
+
99
+ def any_matcher_node?(node)
100
+ node.type == :send && node.children[0].nil? && node.children[1] == :_
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+ require 'ast_transform/transformation_helper'
3
+
4
+ module RSpock
5
+ module AST
6
+ class Node < ::Parser::AST::Node
7
+ REGISTRY = {}
8
+
9
+ def self.register(type)
10
+ REGISTRY[type] = self
11
+ end
12
+
13
+ def self.build(type, *children)
14
+ klass = REGISTRY[type] || self
15
+ klass.new(type, children)
16
+ end
17
+ end
18
+
19
+ class TestNode < Node
20
+ register :rspock_test
21
+
22
+ def def_node = children[0]
23
+ def body_node = children[1]
24
+ def where_node = children[2]
25
+ end
26
+
27
+ class BodyNode < Node
28
+ register :rspock_body
29
+ end
30
+
31
+ class DefNode < Node
32
+ register :rspock_def
33
+
34
+ def method_call = children[0]
35
+ def args = children[1]
36
+ end
37
+
38
+ class GivenNode < Node
39
+ register :rspock_given
40
+ end
41
+
42
+ class WhenNode < Node
43
+ register :rspock_when
44
+ end
45
+
46
+ class ThenNode < Node
47
+ register :rspock_then
48
+ end
49
+
50
+ class ExpectNode < Node
51
+ register :rspock_expect
52
+ end
53
+
54
+ class CleanupNode < Node
55
+ register :rspock_cleanup
56
+ end
57
+
58
+ class WhereNode < Node
59
+ register :rspock_where
60
+
61
+ def header
62
+ header_node = children.find { |n| n.type == :rspock_where_header }
63
+ header_node.children.map { |sym_node| sym_node.children[0] }
64
+ end
65
+
66
+ def data_rows
67
+ children
68
+ .select { |n| n.type == :array }
69
+ .map(&:children)
70
+ end
71
+ end
72
+
73
+ class InteractionNode < Node
74
+ register :rspock_interaction
75
+
76
+ def cardinality = children[0]
77
+ def receiver = children[1]
78
+ def message_sym = children[2]
79
+ def message = message_sym.children[0]
80
+ def args = children[3]
81
+ def return_value = children[4]
82
+ def block_pass = children[5]
83
+ end
84
+
85
+ module NodeBuilder
86
+ include ASTTransform::TransformationHelper
87
+
88
+ def s(type, *children)
89
+ if type.to_s.start_with?('rspock_')
90
+ Node.build(type, *children)
91
+ else
92
+ super
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/node'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ class BlockError < StandardError; end
8
+
9
+ class Block
10
+ include RSpock::AST::NodeBuilder
11
+
12
+ # Constructs a new Block.
13
+ #
14
+ # @param type [Symbol] The Block type.
15
+ # @param node [Parser::AST::Node] The node associated to this Block.
16
+ def initialize(type, node)
17
+ @type = type
18
+ @node = node
19
+ @children = []
20
+ end
21
+
22
+ attr_reader :type, :node
23
+
24
+ # Adds the given +child_node+ to this Block.
25
+ #
26
+ # @param child_node [Parser::AST::Node] The node to be added.
27
+ def <<(child_node)
28
+ @children << child_node
29
+ end
30
+
31
+ # Retrieves the Parser::Source::Range for this Block.
32
+ #
33
+ # @return [Parser::Source::Range] The range.
34
+ def range
35
+ node&.loc&.expression || "?"
36
+ end
37
+
38
+ # Whether this block can be the first block in a test method.
39
+ def can_start?
40
+ false
41
+ end
42
+
43
+ # Whether this block can be the last block in a test method.
44
+ def can_end?
45
+ false
46
+ end
47
+
48
+ # Retrieves the valid successors for this Block.
49
+ #
50
+ # @return [Array<Symbol>] This Block's successors.
51
+ def successors
52
+ @successors ||= [].freeze
53
+ end
54
+
55
+ # Retrieves the duped array of children AST nodes for this Block.
56
+ #
57
+ # @return [Array<Parser::AST::Node>] The children nodes.
58
+ def children
59
+ @children.dup
60
+ end
61
+
62
+ # Converts this Block into an RSpock node.
63
+ #
64
+ # @return [Parser::AST::Node] A node with type :rspock_<block_type>.
65
+ def to_rspock_node
66
+ rspock_type = :"rspock_#{type.downcase}"
67
+ s(rspock_type, *@children)
68
+ end
69
+
70
+ # Checks whether or not the given +block+ is a valid successor for this Block.
71
+ #
72
+ # @param block [Block] The candidate successor.
73
+ #
74
+ # @return [Boolean] True if the given block is a valid successor, false otherwise.
75
+ def valid_successor?(block)
76
+ successors.include?(block.type)
77
+ end
78
+
79
+ # Retrieves the error message for succession errors.
80
+ #
81
+ # @return [String] The error message.
82
+ def succession_error_msg
83
+ "Block #{type} @ #{range} must be followed by one of these Blocks: #{successors}"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/parser/block'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ class CleanupBlock < Block
8
+ def initialize(node)
9
+ super(:Cleanup, node)
10
+ end
11
+
12
+ def can_end?
13
+ true
14
+ end
15
+
16
+ def successors
17
+ @successors ||= [:Where].freeze
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/parser/block'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ class ExpectBlock < Block
8
+ def initialize(node)
9
+ super(:Expect, node)
10
+ end
11
+
12
+ def can_start?
13
+ true
14
+ end
15
+
16
+ def can_end?
17
+ true
18
+ end
19
+
20
+ def successors
21
+ @successors ||= [:Cleanup, :Where].freeze
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/parser/block'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ class GivenBlock < Block
8
+ def initialize(node)
9
+ super(:Given, node)
10
+ end
11
+
12
+ def can_start?
13
+ true
14
+ end
15
+
16
+ def successors
17
+ @successors ||= [:When, :Expect].freeze
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ require 'rspock/ast/node'
3
+
4
+ module RSpock
5
+ module AST
6
+ module Parser
7
+ # Parses raw Ruby AST interaction nodes into structured :rspock_interaction nodes.
8
+ #
9
+ # Input: 1 * receiver.message("arg", &blk) >> "result"
10
+ # Output: s(:rspock_interaction, cardinality, receiver, sym, args, return_value, block_pass)
11
+ #
12
+ # :rspock_interaction children:
13
+ # [0] cardinality - e.g. s(:int, 1), s(:begin, s(:irange, ...)), s(:send, nil, :_)
14
+ # [1] receiver - e.g. s(:send, nil, :subscriber)
15
+ # [2] message - e.g. s(:sym, :receive)
16
+ # [3] args - nil if no args, s(:array, *arg_nodes) otherwise
17
+ # [4] return_value - nil if no >>, otherwise the value node
18
+ # [5] block_pass - nil if no &, otherwise s(:block_pass, ...)
19
+ class InteractionParser
20
+ include RSpock::AST::NodeBuilder
21
+
22
+ class InteractionError < RuntimeError; end
23
+
24
+ ALLOWED_CARDINALITY_NODES = [:send, :lvar, :int].freeze
25
+
26
+ def interaction_node?(node)
27
+ return false if node.nil?
28
+ return true if node.type == :send && node.children[1] == :*
29
+ return true if return_value_node?(node)
30
+
31
+ false
32
+ end
33
+
34
+ def parse(node)
35
+ return node unless interaction_node?(node)
36
+
37
+ if return_value_node?(node)
38
+ return_value = node.children[2]
39
+ node = node.children[0]
40
+ end
41
+
42
+ cardinality = node.children[0]
43
+ validate_cardinality(cardinality)
44
+
45
+ rhs = node.children[2]
46
+ receiver, message, args, block_pass = parse_rhs(rhs)
47
+
48
+ s(:rspock_interaction,
49
+ cardinality,
50
+ receiver,
51
+ s(:sym, message),
52
+ args,
53
+ return_value,
54
+ block_pass
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def return_value_node?(node)
61
+ node.type == :send && node.children[1] == :>> && interaction_node?(node.children[0])
62
+ end
63
+
64
+ def validate_cardinality(node)
65
+ case node.type
66
+ when *ALLOWED_CARDINALITY_NODES
67
+ # OK
68
+ when :begin
69
+ if node.children.count > 1
70
+ raise_cardinality_error(node,
71
+ msg_prefix: "Left-hand side of ",
72
+ msg_suffix: " or a range in parentheses")
73
+ end
74
+ case node.children[0].type
75
+ when :irange, :erange
76
+ unless ALLOWED_CARDINALITY_NODES.include?(node.children[0].children[0].type)
77
+ raise_cardinality_error(node.children[0].children[0], msg_prefix: "Minimum range of ")
78
+ end
79
+ unless ALLOWED_CARDINALITY_NODES.include?(node.children[0].children[1].type)
80
+ raise_cardinality_error(node.children[0].children[1], msg_prefix: "Maximum range of ")
81
+ end
82
+ else
83
+ raise_cardinality_error(node,
84
+ msg_prefix: "Left-hand side of ",
85
+ msg_suffix: " or a range in parentheses")
86
+ end
87
+ else
88
+ raise_cardinality_error(node,
89
+ msg_prefix: "Left-hand side of ",
90
+ msg_suffix: " or a range in parentheses")
91
+ end
92
+ end
93
+
94
+ def parse_rhs(node)
95
+ if node.type == :block
96
+ raise InteractionError, "Inline blocks (do...end / { }) are not supported in interactions @ #{range(node)}. " \
97
+ "Use &var for block forwarding verification, or << for method body override (future)."
98
+ end
99
+
100
+ if node.type != :send
101
+ raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must be a :send node."
102
+ end
103
+
104
+ receiver, message, *arg_nodes = node.children
105
+
106
+ if receiver.nil?
107
+ raise InteractionError, "Right-hand side of Interaction @ #{range(node)} must have a receiver."
108
+ end
109
+
110
+ block_pass = arg_nodes.find { |n| n.type == :block_pass }
111
+ if block_pass
112
+ arg_nodes = arg_nodes.reject { |n| n.equal?(block_pass) }
113
+ end
114
+
115
+ args = arg_nodes.empty? ? nil : s(:array, *arg_nodes)
116
+
117
+ [receiver, message, args, block_pass]
118
+ end
119
+
120
+ def range(node)
121
+ node&.loc&.expression || "?"
122
+ end
123
+
124
+ def raise_cardinality_error(node, msg_prefix: "", msg_suffix: "")
125
+ raise InteractionError, "#{msg_prefix}Interaction @ #{range(node)} must be one of " \
126
+ "#{ALLOWED_CARDINALITY_NODES}#{msg_suffix}."
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end