crspec 0.1.2 → 0.1.3

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.
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crspec
4
+ module Mock
5
+ # RSpec-style argument matchers usable in with(...) and have_received
6
+ # constraints. Each responds to ===(actual).
7
+ module ArgumentMatchers
8
+ class Anything
9
+ def ===(_other) = true
10
+ def ==(other) = true
11
+ def inspect = "anything"
12
+ end
13
+
14
+ class HashIncluding
15
+ def initialize(expected)
16
+ @expected = expected
17
+ end
18
+
19
+ def ===(actual)
20
+ return false unless actual.is_a?(Hash)
21
+
22
+ @expected.all? { |k, v| actual.key?(k) && (v == actual[k] || v === actual[k]) }
23
+ end
24
+ alias == ===
25
+
26
+ def inspect = "hash_including(#{@expected.inspect})"
27
+ end
28
+
29
+ class HashExcluding
30
+ def initialize(expected)
31
+ @expected = expected
32
+ end
33
+
34
+ def ===(actual)
35
+ return false unless actual.is_a?(Hash)
36
+
37
+ @expected.none? { |k, v| actual.key?(k) && (v == actual[k] || v === actual[k]) }
38
+ end
39
+ alias == ===
40
+
41
+ def inspect = "hash_excluding(#{@expected.inspect})"
42
+ end
43
+
44
+ class ArrayIncluding
45
+ def initialize(expected)
46
+ @expected = expected
47
+ end
48
+
49
+ def ===(actual)
50
+ return false unless actual.is_a?(Array)
51
+
52
+ @expected.all? { |item| actual.any? { |el| item == el || item === el } }
53
+ end
54
+ alias == ===
55
+
56
+ def inspect = "array_including(#{@expected.inspect})"
57
+ end
58
+
59
+ class InstanceOf
60
+ def initialize(klass)
61
+ @klass = klass
62
+ end
63
+
64
+ def ===(actual) = actual.instance_of?(@klass)
65
+ alias == ===
66
+
67
+ def inspect = "an_instance_of(#{@klass})"
68
+ end
69
+
70
+ class KindOf
71
+ def initialize(klass)
72
+ @klass = klass
73
+ end
74
+
75
+ def ===(actual) = actual.is_a?(@klass)
76
+ alias == ===
77
+
78
+ def inspect = "kind_of(#{@klass})"
79
+ end
80
+
81
+ class Duck
82
+ def initialize(*methods)
83
+ @methods = methods
84
+ end
85
+
86
+ def ===(actual) = @methods.all? { |m| actual.respond_to?(m) }
87
+ alias == ===
88
+
89
+ def inspect = "duck_type(#{@methods.map(&:inspect).join(", ")})"
90
+ end
91
+
92
+ def anything
93
+ Anything.new
94
+ end
95
+
96
+ def hash_including(expected = {}, **kwargs)
97
+ HashIncluding.new(expected.merge(kwargs))
98
+ end
99
+
100
+ def hash_excluding(expected = {}, **kwargs)
101
+ HashExcluding.new(expected.merge(kwargs))
102
+ end
103
+
104
+ def array_including(*items)
105
+ ArrayIncluding.new(items.flatten(1))
106
+ end
107
+
108
+ def an_instance_of(klass)
109
+ InstanceOf.new(klass)
110
+ end
111
+ alias instance_of an_instance_of
112
+
113
+ def kind_of(klass)
114
+ KindOf.new(klass)
115
+ end
116
+ alias a_kind_of kind_of
117
+
118
+ def duck_type(*methods)
119
+ Duck.new(*methods)
120
+ end
121
+
122
+ def self.args_match?(expected_args, actual_args, actual_kwargs = nil)
123
+ return true if expected_args.nil?
124
+
125
+ # A trailing hash matcher (hash_including etc.) may target the
126
+ # call's keyword arguments.
127
+ if actual_kwargs && !actual_kwargs.empty? &&
128
+ expected_args.size == actual_args.size + 1 &&
129
+ (expected_args.last.is_a?(HashIncluding) || expected_args.last.is_a?(HashExcluding) || expected_args.last.is_a?(Hash))
130
+ positional = expected_args[0...-1]
131
+ return args_match?(positional, actual_args) &&
132
+ (expected_args.last == actual_kwargs || expected_args.last === actual_kwargs)
133
+ end
134
+
135
+ return false unless expected_args.size == actual_args.size
136
+
137
+ expected_args.zip(actual_args).all? do |expected, actual|
138
+ expected == actual || expected === actual
139
+ end
140
+ end
141
+
142
+ def self.kwargs_match?(expected_kwargs, actual_kwargs)
143
+ return true if expected_kwargs.nil? || expected_kwargs.empty?
144
+
145
+ if expected_kwargs.size == 1 && expected_kwargs.values.first.is_a?(HashIncluding)
146
+ return expected_kwargs.values.first === actual_kwargs
147
+ end
148
+
149
+ return false unless expected_kwargs.size == actual_kwargs.size
150
+
151
+ expected_kwargs.all? do |key, expected|
152
+ actual_kwargs.key?(key) && (expected == actual_kwargs[key] || expected === actual_kwargs[key])
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "space"
4
+ require_relative "argument_matchers"
4
5
 
5
6
  module Crspec
6
7
  module Mock
@@ -13,10 +14,15 @@ module Crspec
13
14
  @name = name || "Double"
14
15
  Space.current.register_double(self)
15
16
  stubs.each do |method_name, return_value|
17
+ __crspec_verify_stub!(method_name)
16
18
  Space.current.register_stub(self, method_name, proc { return_value })
17
19
  end
18
20
  end
19
21
 
22
+ def __crspec_verify_stub!(method_name)
23
+ # Plain doubles accept any stub.
24
+ end
25
+
20
26
  def verify_expectations!
21
27
  # Verified via Space
22
28
  end
@@ -26,19 +32,47 @@ module Crspec
26
32
  end
27
33
  end
28
34
 
35
+ # A verifying double: only methods that exist on the doubled class's
36
+ # instance interface may be stubbed or expected.
37
+ class InstanceDouble < Double
38
+ def initialize(doubled_class, name = nil, stubs = {})
39
+ @doubled_class = doubled_class.is_a?(Module) ? doubled_class : nil
40
+ @doubled_class_name = doubled_class.is_a?(Module) ? doubled_class.name : doubled_class.to_s
41
+ super(name || @doubled_class_name, stubs)
42
+ end
43
+
44
+ def __crspec_verify_stub!(method_name)
45
+ return unless @doubled_class
46
+
47
+ method_sym = method_name.to_sym
48
+ return if @doubled_class.method_defined?(method_sym) ||
49
+ @doubled_class.private_method_defined?(method_sym)
50
+
51
+ raise MockError,
52
+ "the #{@doubled_class_name} class does not implement the instance method: #{method_sym}"
53
+ end
54
+
55
+ def inspect
56
+ "#<InstanceDouble(#{@doubled_class_name}) #{@name.inspect}>"
57
+ end
58
+ end
59
+
29
60
  class StubChain
30
61
  attr_reader :target, :method_name
31
62
 
32
63
  def initialize(target, method_name)
33
64
  @target = target
34
65
  @method_name = method_name.to_sym
66
+ target.__crspec_verify_stub!(@method_name) if target.respond_to?(:__crspec_verify_stub!)
35
67
  @expected_args = nil
68
+ @expected_kwargs = nil
36
69
  @return_proc = proc {}
37
70
  register_default_stub
38
71
  end
39
72
 
40
- def with(*args)
73
+ def with(*args, **kwargs)
41
74
  @expected_args = args
75
+ @expected_kwargs = kwargs
42
76
  update_stub
43
77
  self
44
78
  end
@@ -78,6 +112,23 @@ module Crspec
78
112
  self
79
113
  end
80
114
 
115
+ def and_call_original
116
+ target = @target
117
+ method_name = @method_name
118
+ original = begin
119
+ target.method(method_name)
120
+ rescue NameError
121
+ raise MockError, "#{target.inspect} has no original implementation of #{method_name}"
122
+ end
123
+ # Find the implementation beneath the interceptor.
124
+ original = original.super_method while original && original.owner == Interceptor
125
+ raise MockError, "#{target.inspect} has no original implementation of #{method_name}" unless original
126
+
127
+ @return_proc = proc { |*args, **kwargs, &block| original.call(*args, **kwargs, &block) }
128
+ update_stub
129
+ self
130
+ end
131
+
81
132
  private
82
133
 
83
134
  def register_default_stub
@@ -88,13 +139,19 @@ module Crspec
88
139
  target = @target
89
140
  method_name = @method_name
90
141
  expected_args = @expected_args
142
+ expected_kwargs = @expected_kwargs
91
143
  return_proc = @return_proc
92
144
 
93
145
  implementation = proc do |*args, **kwargs, &block|
94
- if expected_args && args != expected_args
146
+ Space.current.record_call(target, method_name, args, kwargs)
147
+ if expected_args && !expected_args.empty? && !ArgumentMatchers.args_match?(expected_args, args, kwargs)
95
148
  raise MockError, "Expected #{method_name} with #{expected_args.inspect}, got #{args.inspect}"
96
149
  end
97
150
 
151
+ if expected_kwargs && !expected_kwargs.empty? && !ArgumentMatchers.kwargs_match?(expected_kwargs, kwargs)
152
+ raise MockError, "Expected #{method_name} with #{expected_kwargs.inspect}, got #{kwargs.inspect}"
153
+ end
154
+
98
155
  return_proc.call(*args, **kwargs, &block)
99
156
  end
100
157
 
@@ -153,14 +210,20 @@ module Crspec
153
210
  target = @target
154
211
  method_name = @method_name
155
212
  expected_args = @expected_args
213
+ expected_kwargs = @expected_kwargs
156
214
  return_proc = @return_proc
157
215
 
158
216
  implementation = proc do |*args, **kwargs, &block|
159
217
  @call_count += 1
160
- if expected_args && args != expected_args
218
+ Space.current.record_call(target, method_name, args, kwargs)
219
+ if expected_args && !expected_args.empty? && !ArgumentMatchers.args_match?(expected_args, args, kwargs)
161
220
  raise MockError, "Expected #{method_name} with #{expected_args.inspect}, got #{args.inspect}"
162
221
  end
163
222
 
223
+ if expected_kwargs && !expected_kwargs.empty? && !ArgumentMatchers.kwargs_match?(expected_kwargs, kwargs)
224
+ raise MockError, "Expected #{method_name} with #{expected_kwargs.inspect}, got #{kwargs.inspect}"
225
+ end
226
+
164
227
  return_proc.call(*args, **kwargs, &block)
165
228
  end
166
229
 
@@ -174,6 +237,7 @@ module Crspec
174
237
  def initialize(method_name)
175
238
  @method_name = method_name.to_sym
176
239
  @expected_args = nil
240
+ @expected_kwargs = nil
177
241
  @return_values = nil
178
242
  @raise_exception = nil
179
243
  @yield_args = nil
@@ -181,8 +245,9 @@ module Crspec
181
245
  @count_constraint = :exact
182
246
  end
183
247
 
184
- def with(*args)
248
+ def with(*args, **kwargs)
185
249
  @expected_args = args
250
+ @expected_kwargs = kwargs
186
251
  self
187
252
  end
188
253
 
@@ -202,6 +267,11 @@ module Crspec
202
267
  self
203
268
  end
204
269
 
270
+ def and_call_original
271
+ @call_original = true
272
+ self
273
+ end
274
+
205
275
  def once
206
276
  @expected_count = 1
207
277
  @count_constraint = :exact
@@ -243,10 +313,32 @@ module Crspec
243
313
  private
244
314
 
245
315
  def apply_chain_options(chain)
246
- chain.with(*@expected_args) if @expected_args
316
+ if @expected_args || @expected_kwargs
317
+ chain.with(*(@expected_args || []), **(@expected_kwargs || {}))
318
+ end
247
319
  chain.and_return(*@return_values) if @return_values
248
320
  chain.and_raise(@raise_exception, @raise_message) if @raise_exception
249
321
  chain.and_yield(*@yield_args) if @yield_args
322
+ chain.and_call_original if @call_original
323
+ end
324
+ end
325
+
326
+ # allow(target).to receive_messages(a: 1, b: 2)
327
+ class ReceiveMessagesMatcher
328
+ def initialize(messages)
329
+ @messages = messages
330
+ end
331
+
332
+ def setup_allow(target)
333
+ @messages.map do |method_name, value|
334
+ StubChain.new(target, method_name).and_return(value)
335
+ end
336
+ end
337
+
338
+ def setup_expect(target)
339
+ @messages.map do |method_name, value|
340
+ ExpectationChain.new(target, method_name).and_return(value)
341
+ end
250
342
  end
251
343
  end
252
344
 
@@ -264,20 +356,131 @@ module Crspec
264
356
  end
265
357
  end
266
358
 
359
+ # expect(target).to have_received(:method).with(...).once
360
+ class HaveReceivedMatcher
361
+ def initialize(method_name)
362
+ @method_name = method_name.to_sym
363
+ @expected_args = nil
364
+ @expected_kwargs = nil
365
+ @expected_count = nil
366
+ @count_constraint = :at_least_once
367
+ end
368
+
369
+ def with(*args, **kwargs)
370
+ @expected_args = args
371
+ @expected_kwargs = kwargs
372
+ self
373
+ end
374
+
375
+ def once
376
+ @expected_count = 1
377
+ @count_constraint = :exact
378
+ self
379
+ end
380
+
381
+ def twice
382
+ @expected_count = 2
383
+ @count_constraint = :exact
384
+ self
385
+ end
386
+
387
+ def exactly(n)
388
+ @expected_count = n
389
+ @count_constraint = :exact
390
+ self
391
+ end
392
+
393
+ def times
394
+ self
395
+ end
396
+
397
+ def at_least(n)
398
+ @expected_count = n
399
+ @count_constraint = :at_least
400
+ self
401
+ end
402
+
403
+ def matches?(target)
404
+ @target = target
405
+ calls = Space.current.calls_for(target, @method_name)
406
+ if @expected_args || @expected_kwargs
407
+ calls = calls.select do |args, kwargs|
408
+ ArgumentMatchers.args_match?(@expected_args, args, kwargs) &&
409
+ ArgumentMatchers.kwargs_match?(@expected_kwargs, kwargs)
410
+ end
411
+ end
412
+ @actual_count = calls.size
413
+
414
+ case @count_constraint
415
+ when :exact then @actual_count == @expected_count
416
+ when :at_least then @actual_count >= @expected_count
417
+ else @actual_count.positive?
418
+ end
419
+ end
420
+
421
+ def failure_message
422
+ expectation = case @count_constraint
423
+ when :exact then "exactly #{@expected_count} time(s)"
424
+ when :at_least then "at least #{@expected_count} time(s)"
425
+ else "at least once"
426
+ end
427
+ with_clause = @expected_args ? " with #{@expected_args.inspect}" : ""
428
+ "Expected #{@target.inspect} to have received #{@method_name.inspect}#{with_clause} #{expectation}, " \
429
+ "but received it #{@actual_count} time(s)"
430
+ end
431
+
432
+ def failure_message_when_negated
433
+ "Expected #{@target.inspect} not to have received #{@method_name.inspect}, " \
434
+ "but received it #{@actual_count} time(s)"
435
+ end
436
+ end
437
+
438
+ # A spy: a double that accepts any message and records calls for
439
+ # have_received verification.
440
+ class Spy < Double
441
+ def method_missing(method_name, *args, **kwargs, &block)
442
+ space = Fiber[Space::STORAGE_KEY]
443
+ if space
444
+ if (stub = space.fetch_stub(self, method_name))
445
+ return stub.call(*args, **kwargs, &block)
446
+ end
447
+
448
+ space.record_call(self, method_name, args, kwargs)
449
+ end
450
+ nil
451
+ end
452
+
453
+ def respond_to_missing?(_method_name, _include_private = false)
454
+ true
455
+ end
456
+
457
+ def inspect
458
+ "#<Spy #{@name.inspect}>"
459
+ end
460
+ end
461
+
267
462
  module DSL
463
+ include ArgumentMatchers
464
+
268
465
  def double(name = nil, stubs = {})
269
466
  Double.new(name, stubs)
270
467
  end
271
468
 
272
469
  def instance_double(target_class, name = nil, stubs = {})
273
- double_name = if target_class.is_a?(String) || target_class.is_a?(Symbol)
274
- target_class.to_s
275
- elsif target_class.respond_to?(:name)
276
- target_class.name
277
- else
278
- target_class.to_s
279
- end
280
- Double.new(name || double_name, stubs)
470
+ if name.is_a?(Hash) && stubs.empty?
471
+ stubs = name
472
+ name = nil
473
+ end
474
+ resolved = if target_class.is_a?(String) || target_class.is_a?(Symbol)
475
+ begin
476
+ Object.const_get(target_class.to_s)
477
+ rescue NameError
478
+ target_class.to_s
479
+ end
480
+ else
481
+ target_class
482
+ end
483
+ InstanceDouble.new(resolved, name, stubs)
281
484
  end
282
485
 
283
486
  def allow(target)
@@ -287,6 +490,36 @@ module Crspec
287
490
  def receive(method_name)
288
491
  ReceiveMatcher.new(method_name)
289
492
  end
493
+
494
+ def receive_messages(messages)
495
+ ReceiveMessagesMatcher.new(messages)
496
+ end
497
+
498
+ def spy(name = nil, stubs = {})
499
+ Spy.new(name || "Spy", stubs)
500
+ end
501
+
502
+ def have_received(method_name)
503
+ HaveReceivedMatcher.new(method_name)
504
+ end
505
+
506
+ def allow_any_instance_of(_klass)
507
+ raise Crspec::MigrationError, <<~MSG
508
+ allow_any_instance_of is not supported by Crspec: it mutates shared
509
+ class hierarchies at runtime, which is unsafe under concurrent
510
+ threads/fibers. Inject a double or stub the specific instance.
511
+ Run `crspec-transpile --analyze` to find all occurrences.
512
+ MSG
513
+ end
514
+
515
+ def expect_any_instance_of(_klass)
516
+ raise Crspec::MigrationError, <<~MSG
517
+ expect_any_instance_of is not supported by Crspec: it mutates shared
518
+ class hierarchies at runtime, which is unsafe under concurrent
519
+ threads/fibers. Inject a double or stub the specific instance.
520
+ Run `crspec-transpile --analyze` to find all occurrences.
521
+ MSG
522
+ end
290
523
  end
291
524
  end
292
525
  end
@@ -3,6 +3,9 @@
3
3
  module Crspec
4
4
  module Mock
5
5
  module Interceptor
6
+ @intercepted_methods = {}
7
+ @mutex = Mutex.new
8
+
6
9
  def method_missing(method_name, *args, **kwargs, &block)
7
10
  space = Fiber[Space::STORAGE_KEY]
8
11
  if space && (stub = space.fetch_stub(self, method_name))
@@ -17,16 +20,36 @@ module Crspec
17
20
  space&.fetch_stub(self, method_name) || super
18
21
  end
19
22
 
20
- def self.add_intercept_method(method_name)
21
- method_sym = method_name.to_sym
22
- return if instance_methods(false).include?(method_sym) || private_instance_methods(false).include?(method_sym)
23
+ class << self
24
+ def add_intercept_method(method_name)
25
+ method_sym = method_name.to_sym
26
+ @mutex.synchronize do
27
+ return if @intercepted_methods.key?(method_sym)
28
+ return if instance_methods(false).include?(method_sym) ||
29
+ private_instance_methods(false).include?(method_sym)
30
+
31
+ define_method(method_sym) do |*args, **kwargs, &block|
32
+ space = Fiber[Space::STORAGE_KEY]
33
+ if space && (stub = space.fetch_stub(self, method_sym))
34
+ stub.call(*args, **kwargs, &block)
35
+ else
36
+ super(*args, **kwargs, &block)
37
+ end
38
+ end
39
+ @intercepted_methods[method_sym] = true
40
+ end
41
+ end
23
42
 
24
- define_method(method_sym) do |*args, **kwargs, &block|
25
- space = Fiber[Space::STORAGE_KEY]
26
- if space && (stub = space.fetch_stub(self, method_sym))
27
- stub.call(*args, **kwargs, &block)
28
- else
29
- super(*args, **kwargs, &block)
43
+ # Removes all dynamically-defined intercept methods so they do not
44
+ # accumulate for the lifetime of the process (e.g. across multiple
45
+ # embedded runner invocations). Stub lookups fall back to
46
+ # method_missing until re-added.
47
+ def cleanup!
48
+ @mutex.synchronize do
49
+ @intercepted_methods.each_key do |method_sym|
50
+ remove_method(method_sym) if instance_methods(false).include?(method_sym)
51
+ end
52
+ @intercepted_methods.clear
30
53
  end
31
54
  end
32
55
  end
@@ -27,9 +27,22 @@ module Crspec
27
27
  @doubles = []
28
28
  @stubs = Hash.new { |h, k| h[k] = {} }
29
29
  @expectations = []
30
+ @calls = Hash.new { |h, k| h[k] = [] }
30
31
  @mutex = Mutex.new
31
32
  end
32
33
 
34
+ def record_call(target, method_name, args, kwargs)
35
+ @mutex.synchronize do
36
+ @calls[[target.object_id, method_name.to_sym]] << [args, kwargs]
37
+ end
38
+ end
39
+
40
+ def calls_for(target, method_name)
41
+ @mutex.synchronize do
42
+ @calls[[target.object_id, method_name.to_sym]].dup
43
+ end
44
+ end
45
+
33
46
  def register_stub(target, method_name, implementation)
34
47
  method_sym = method_name.to_sym
35
48
  ensure_interceptor_prepended(target, method_sym)
@@ -66,6 +79,7 @@ module Crspec
66
79
  @stubs.clear
67
80
  @doubles.clear
68
81
  @expectations.clear
82
+ @calls.clear
69
83
  end
70
84
  end
71
85