mock-suey 0.0.1 → 0.1.0

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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MockSuey
4
+ module Ext
5
+ module RSpec
6
+ # Provide unified interface to access target class
7
+ # for different double/proxy types
8
+ refine ::RSpec::Mocks::TestDoubleProxy do
9
+ def target_class = nil
10
+ end
11
+
12
+ refine ::RSpec::Mocks::PartialDoubleProxy do
13
+ def target_class = object.class
14
+ end
15
+
16
+ refine ::RSpec::Mocks::VerifyingPartialDoubleProxy do
17
+ def target_class = object.class
18
+ end
19
+
20
+ refine ::RSpec::Mocks::PartialClassDoubleProxy do
21
+ def target_class = object.singleton_class
22
+ end
23
+
24
+ refine ::RSpec::Mocks::VerifyingPartialClassDoubleProxy do
25
+ def target_class = object.singleton_class
26
+ end
27
+
28
+ refine ::RSpec::Mocks::VerifyingProxy do
29
+ def target_class = @doubled_module.target
30
+ end
31
+
32
+ refine ::RSpec::Mocks::Proxy do
33
+ attr_reader :method_doubles
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copied from Test Prof
4
+ module MockSuey
5
+ # Helper for output printing
6
+ module Logging
7
+ COLORS = {
8
+ INFO: "\e[34m", # blue
9
+ WARN: "\e[33m", # yellow
10
+ ERROR: "\e[31m" # red
11
+ }.freeze
12
+
13
+ class Formatter
14
+ def call(severity, _time, progname, msg)
15
+ colorize(severity.to_sym, "[MOCK SUEY #{severity}] #{msg}") + "\n"
16
+ end
17
+
18
+ private
19
+
20
+ def colorize(level, msg)
21
+ return msg unless MockSuey.config.color?
22
+
23
+ return msg unless COLORS.key?(level)
24
+
25
+ "#{COLORS[level]}#{msg}\e[0m"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mock_suey/ext/instance_class"
4
+
5
+ module MockSuey
6
+ using Ext::InstanceClass
7
+
8
+ class MethodCall < Struct.new(
9
+ :receiver_class,
10
+ :method_name,
11
+ :arguments,
12
+ :return_value,
13
+ :has_kwargs,
14
+ :metadata,
15
+ keyword_init: true
16
+ )
17
+ def initialize(**)
18
+ super
19
+ self.metadata = {} unless metadata
20
+ end
21
+
22
+ def pos_args
23
+ return arguments unless has_kwargs
24
+ *positional, _kwarg = arguments
25
+ positional
26
+ end
27
+
28
+ def kwargs
29
+ return {} unless has_kwargs
30
+ arguments.last
31
+ end
32
+
33
+ def has_kwargs
34
+ super.then do |val|
35
+ # Flag hasn't been set explicitly,
36
+ # so we need to derive it from the method data
37
+ return val unless val.nil?
38
+
39
+ kwarg_params = keyword_parameters
40
+ return self.has_kwargs = false if kwarg_params.empty?
41
+
42
+ last_arg = arguments.last
43
+
44
+ unless last_arg.is_a?(::Hash) && last_arg.keys.all? { ::Symbol === _1 }
45
+ return self.has_kwargs = false
46
+ end
47
+
48
+ self.has_kwargs = true
49
+ end
50
+ end
51
+
52
+ def method_desc
53
+ delimeter = receiver_class.singleton_class? ? "." : "#"
54
+
55
+ "#{receiver_class.instance_class_name}#{delimeter}#{method_name}"
56
+ end
57
+
58
+ def inspect
59
+ "#{method_desc}(#{arguments.map(&:inspect).join(", ")}) -> #{return_value.inspect}"
60
+ end
61
+
62
+ private
63
+
64
+ def keyword_parameters
65
+ arg_types = receiver_class.instance_method(method_name).parameters
66
+ return [] if arg_types.any? { _1[0] == :nokey }
67
+
68
+ arg_types.select { _1[0].start_with?("key") }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mock_suey/ext/instance_class"
4
+
5
+ module MockSuey
6
+ class MockContract
7
+ using Ext::InstanceClass
8
+
9
+ class Error < StandardError
10
+ attr_reader :contract
11
+
12
+ def initialize(contract, msg)
13
+ @contract = contract
14
+ super(msg)
15
+ end
16
+
17
+ private
18
+
19
+ def captured_calls_message(calls)
20
+ calls.map do |call|
21
+ contract.args_pattern.map.with_index do |arg, i|
22
+ (ANYTHING == arg) ? "_" : call.arguments[i].inspect
23
+ end.join(", ").then do |args_desc|
24
+ " (#{args_desc}) -> #{call.return_value.class}"
25
+ end
26
+ end.uniq.join("\n")
27
+ end
28
+ end
29
+
30
+ class NoMethodCalls < Error
31
+ def initialize(contract)
32
+ super(
33
+ contract,
34
+ "Mock contract verification failed:\n" \
35
+ " No method calls captured for #{contract.method_desc}"
36
+ )
37
+ end
38
+ end
39
+
40
+ class NoMatchingMethodCalls < Error
41
+ def initialize(contract, real_calls)
42
+ @contract = contract
43
+ super(
44
+ contract,
45
+ "Mock contract verification failed:\n" \
46
+ " No matching calls captured for #{contract.inspect}.\n" \
47
+ " Captured call patterns:\n" \
48
+ "#{captured_calls_message(real_calls)}"
49
+ )
50
+ end
51
+ end
52
+
53
+ class NoMatchingReturnType < Error
54
+ def initialize(contract, real_calls)
55
+ @contract = contract
56
+ super(
57
+ contract,
58
+ "Mock contract verification failed:\n" \
59
+ " No calls with the expected return type captured for #{contract.inspect}.\n" \
60
+ " Captured call patterns:\n" \
61
+ "#{captured_calls_message(real_calls)}"
62
+ )
63
+ end
64
+ end
65
+
66
+ ANYTHING = Object.new.freeze
67
+
68
+ def self.from_stub(call_obj)
69
+ call_obj => {receiver_class:, method_name:, return_value:}
70
+
71
+ args_pattern = call_obj.arguments.map do
72
+ contractable_arg?(_1) ? _1 : ANYTHING
73
+ end
74
+
75
+ new(
76
+ receiver_class:,
77
+ method_name:,
78
+ args_pattern:,
79
+ return_type: return_value.class
80
+ )
81
+ end
82
+
83
+ def self.contractable_arg?(val)
84
+ case val
85
+ when TrueClass, FalseClass, Numeric, String, Regexp, NilClass
86
+ true
87
+ when Array
88
+ val.all? { |v| contractable_arg?(v) }
89
+ when Hash
90
+ contractable_arg?(val.values)
91
+ else
92
+ false
93
+ end
94
+ end
95
+
96
+ attr_reader :receiver_class, :method_name,
97
+ :args_pattern, :return_type
98
+
99
+ def initialize(receiver_class:, method_name:, args_pattern:, return_type:)
100
+ @receiver_class = receiver_class
101
+ @method_name = method_name
102
+ @args_pattern = args_pattern
103
+ @return_type = return_type
104
+ end
105
+
106
+ def verify!(calls)
107
+ return if noop?
108
+
109
+ raise NoMethodCalls.new(self) if calls.nil? || calls.empty?
110
+
111
+ matching_input_calls = calls.select { matching_args?(_1) }
112
+ raise NoMatchingMethodCalls.new(self, calls) if matching_input_calls.empty?
113
+
114
+ matching_input_calls.each do
115
+ return if _1.return_value.class <= return_type
116
+ end
117
+
118
+ raise NoMatchingReturnType.new(self, matching_input_calls)
119
+ end
120
+
121
+ def noop? = args_pattern.all? { _1 == ANYTHING }
122
+
123
+ def method_desc
124
+ delimeter = receiver_class.singleton_class? ? "." : "#"
125
+
126
+ "#{receiver_class.instance_class_name}#{delimeter}#{method_name}"
127
+ end
128
+
129
+ def inspect
130
+ args_pattern.map do
131
+ (_1 == ANYTHING) ? "_" : _1.inspect
132
+ end.join(", ").then do |args_desc|
133
+ "#{method_desc}: (#{args_desc}) -> #{return_type}"
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def matching_args?(call)
140
+ args_pattern.each.with_index do |arg, i|
141
+ next if arg == ANYTHING
142
+ # Use case-eq here to make it possible to use composed
143
+ # matchers in the future
144
+ return false unless arg === call.arguments[i]
145
+ end
146
+
147
+ true
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mock_suey/ext/rspec"
4
+ require "mock_suey/ext/instance_class"
5
+
6
+ module MockSuey
7
+ module RSpec
8
+ # Special type of shared context for mocks.
9
+ # The main difference is that it can track mocked classes and methods.
10
+ module MockContext
11
+ NAMESPACE = "mock::"
12
+
13
+ class << self
14
+ attr_writer :context_namespace
15
+
16
+ def context_namespace = @context_namespace || NAMESPACE
17
+
18
+ def collector = @collector ||= MocksCollector.new
19
+
20
+ def registry = collector.registry
21
+ end
22
+
23
+ class MocksCollector
24
+ using Ext::RSpec
25
+ using Ext::InstanceClass
26
+
27
+ # Registry contains all identified mocks/stubs in a form:
28
+ # Hash[Class: Hash[Symbol method_name, Array[MethodCall]]
29
+ attr_reader :registry
30
+
31
+ def initialize
32
+ @registry = Hash.new { |h, k| h[k] = {} }
33
+ @mocks = {}
34
+ end
35
+
36
+ def watch(context_id)
37
+ return if mocks.key?(context_id)
38
+
39
+ mocks[context_id] = true
40
+
41
+ evaluate_context!(context_id)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :mocks
47
+
48
+ def evaluate_context!(context_id)
49
+ store = registry
50
+
51
+ Class.new(::RSpec::Core::ExampleGroup) do
52
+ def self.metadata = {}
53
+
54
+ def self.filtered_examples = examples
55
+
56
+ ::RSpec::Core::MemoizedHelpers.define_helpers_on(self)
57
+
58
+ include_context(context_id)
59
+
60
+ specify("true") { expect(true).to be(true) }
61
+
62
+ after do
63
+ ::RSpec::Mocks.space.proxies.values.each do |proxy|
64
+ proxy.method_doubles.values.each do |double|
65
+ method_name = double.method_name
66
+ receiver_class = proxy.target_class
67
+
68
+ # Simple doubles don't have targets
69
+ next unless receiver_class
70
+
71
+ # TODO: Make conversion customizable (see proxy_method_invoked)
72
+ if method_name == :new && receiver_class.singleton_class?
73
+ receiver_class, method_name = receiver_class.instance_class, :initialize
74
+ end
75
+
76
+ expected_calls = store[receiver_class][method_name] = []
77
+
78
+ double.stubs.each do |stub|
79
+ arguments =
80
+ if stub.expected_args in [::RSpec::Mocks::ArgumentMatchers::NoArgsMatcher]
81
+ []
82
+ else
83
+ stub.expected_args
84
+ end
85
+
86
+ return_value = stub.implementation.terminal_action.call
87
+
88
+ expected_calls << MethodCall.new(
89
+ receiver_class:,
90
+ method_name:,
91
+ arguments:,
92
+ return_value:
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end.run
99
+ end
100
+ end
101
+
102
+ module DSL
103
+ def mock_context(name, **opts, &block)
104
+ ::RSpec.shared_context("#{MockContext.context_namespace}#{name}", **opts, &block)
105
+ end
106
+ end
107
+
108
+ module ExampleGroup
109
+ def include_mock_context(name)
110
+ context_id = "#{MockContext.context_namespace}#{name}"
111
+
112
+ MockContext.collector.watch(context_id)
113
+ include_context(context_id)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ # Extending RSpec
121
+ RSpec.extend(MockSuey::RSpec::MockContext::DSL)
122
+
123
+ if RSpec.configuration.expose_dsl_globally?
124
+ Object.include(MockSuey::RSpec::MockContext::DSL)
125
+ Module.extend(MockSuey::RSpec::MockContext::DSL)
126
+ end
127
+
128
+ RSpec::Core::ExampleGroup.extend(MockSuey::RSpec::MockContext::ExampleGroup)
129
+ RSpec::Core::ExampleGroup.extend(MockSuey::RSpec::MockContext::DSL)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ RSpec::Mocks::VerifyingMethodDouble
5
+ rescue LoadError
6
+ warn "Couldn't find VerifyingMethodDouble class from rspec-mocks"
7
+ return
8
+ end
9
+
10
+ require "mock_suey/ext/rspec"
11
+ require "mock_suey/ext/instance_class"
12
+
13
+ module MockSuey
14
+ module RSpec
15
+ using Ext::RSpec
16
+ using Ext::InstanceClass
17
+
18
+ module ProxyMethodInvokedHook
19
+ def proxy_method_invoked(obj, *args, &block)
20
+ return super if obj.is_a?(::RSpec::Mocks::TestDouble) && !obj.is_a?(::RSpec::Mocks::VerifyingDouble)
21
+
22
+ receiver_class = @proxy.target_class
23
+ method_name = @method_name
24
+
25
+ # TODO: Make conversion customizable to support .perform_later -> #perform
26
+ # and other similar use-cases
27
+ if method_name == :new && receiver_class.singleton_class?
28
+ receiver_class, method_name = receiver_class.instance_class, :initialize
29
+ end
30
+
31
+ method_call = MockSuey::MethodCall.new(
32
+ receiver_class:,
33
+ method_name:,
34
+ arguments: args,
35
+ metadata: {example: ::RSpec.current_example}
36
+ )
37
+
38
+ super.tap do |ret|
39
+ method_call.return_value = ret
40
+ MockSuey.handle_mocked_call(method_call)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ RSpec::Mocks::MethodDouble.prepend(MockSuey::RSpec::ProxyMethodInvokedHook)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module MockSuey
6
+ module RSpec
7
+ module_function
8
+
9
+ def register_example_failure(example, err)
10
+ example.execution_result.status = :failed
11
+ example.execution_result.exception = err
12
+ example.set_aggregate_failures_exception(err)
13
+ end
14
+
15
+ def report_non_example_failure(err, location = nil)
16
+ err.set_backtrace([location]) if err.backtrace.nil? && location
17
+
18
+ ::RSpec.configuration.reporter.notify_non_example_exception(
19
+ err,
20
+ "An error occurred after suite run."
21
+ )
22
+ end
23
+ end
24
+ end
25
+
26
+ require "mock_suey/rspec/proxy_method_invoked"
27
+ require "mock_suey/rspec/mock_context"
28
+
29
+ RSpec.configure do |config|
30
+ config.before(:suite) do
31
+ MockSuey.cook
32
+ end
33
+
34
+ config.after(:suite) do
35
+ leftovers = MockSuey.eat
36
+
37
+ next if leftovers.empty?
38
+
39
+ failed_examples = Set.new
40
+
41
+ leftovers.each do |call_obj|
42
+ err = call_obj.metadata[:error]
43
+ example = call_obj.metadata[:example]
44
+
45
+ if example
46
+ failed_examples << example
47
+ MockSuey::RSpec.register_example_failure(example, err)
48
+ else
49
+ location = call_obj.metadata[:location]
50
+ MockSuey::RSpec.report_non_example_failure(err, location)
51
+ end
52
+ end
53
+
54
+ failed_examples.each do
55
+ ::RSpec.configuration.reporter.example_failed(_1)
56
+ end
57
+
58
+ exit(RSpec.configuration.failure_exit_code)
59
+ end
60
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MockSuey
4
+ class Tracer
5
+ class PrependCollector
6
+ attr_reader :tracer
7
+
8
+ def initialize(tracer)
9
+ @tracer = tracer
10
+ end
11
+
12
+ def module_for(klass, methods)
13
+ tracer = self.tracer
14
+
15
+ Module.new do
16
+ define_method(:__mock_suey_tracer__) { tracer }
17
+
18
+ methods.each do |mid|
19
+ module_eval <<~RUBY, __FILE__, __LINE__ + 1
20
+ def #{mid}(*args, **kwargs, &block)
21
+ super.tap do |return_value|
22
+ arguments = args
23
+ arguments << kwargs unless kwargs.empty?
24
+
25
+ __mock_suey_tracer__ << MethodCall.new(
26
+ receiver_class: self.class,
27
+ method_name: __method__,
28
+ arguments: arguments,
29
+ has_kwargs: !kwargs.empty?,
30
+ return_value: return_value,
31
+ metadata: {
32
+ location: method(__method__).super_method.source_location.first
33
+ }
34
+ )
35
+ end
36
+ end
37
+ RUBY
38
+ end
39
+ end
40
+ end
41
+
42
+ def setup(targets)
43
+ targets.each do |klass, methods|
44
+ mod = module_for(klass, methods)
45
+ klass.prepend(mod)
46
+ end
47
+ end
48
+
49
+ def stop
50
+ end
51
+ end
52
+
53
+ class TracePointCollector
54
+ attr_reader :tracer
55
+
56
+ def initialize(tracer)
57
+ @tracer = tracer
58
+ end
59
+
60
+ def setup(targets)
61
+ tracer = self.tracer
62
+ calls_stack = []
63
+
64
+ @tp = TracePoint.trace(:call, :return) do |tp|
65
+ methods = targets[tp.defined_class]
66
+ next unless methods
67
+ next unless methods.include?(tp.method_id)
68
+
69
+ receiver_class, method_name = tp.defined_class, tp.method_id
70
+
71
+ if tp.event == :call
72
+ method = tp.self.method(method_name)
73
+ arguments = []
74
+ kwargs = {}
75
+
76
+ method.parameters.each do |(type, name)|
77
+ next if name == :** || name == :* || name == :&
78
+
79
+ val = tp.binding.local_variable_get(name)
80
+
81
+ case type
82
+ when :req, :opt
83
+ arguments << val
84
+ when :keyreq, :key
85
+ kwargs[name] = val
86
+ when :rest
87
+ arguments.concat(val)
88
+ when :keyrest
89
+ kwargs.merge!(val)
90
+ end
91
+ end
92
+
93
+ arguments << kwargs unless kwargs.empty?
94
+
95
+ call_obj = MethodCall.new(
96
+ receiver_class:,
97
+ method_name:,
98
+ arguments:,
99
+ has_kwargs: !kwargs.empty?,
100
+ metadata: {
101
+ location: method.source_location.first
102
+ }
103
+ )
104
+ tracer << call_obj
105
+ calls_stack << call_obj
106
+ elsif tp.event == :return
107
+ call_obj = calls_stack.pop
108
+ call_obj.return_value = tp.return_value
109
+ end
110
+ end
111
+ end
112
+
113
+ def stop
114
+ tp.disable
115
+ end
116
+
117
+ private
118
+
119
+ attr_reader :tp
120
+ end
121
+
122
+ attr_reader :store, :targets, :collector
123
+
124
+ def initialize(via: :prepend)
125
+ @store = []
126
+ @targets = Hash.new { |h, k| h[k] = [] }
127
+ @collector =
128
+ if via == :prepend
129
+ PrependCollector.new(self)
130
+ elsif via == :trace_point
131
+ TracePointCollector.new(self)
132
+ else
133
+ raise ArgumentError, "Unknown tracing method: #{via}"
134
+ end
135
+ end
136
+
137
+ def collect(klass, methods)
138
+ targets[klass].concat(methods)
139
+ targets[klass].uniq!
140
+ end
141
+
142
+ def start!
143
+ collector.setup(targets)
144
+ end
145
+
146
+ def stop
147
+ collector.stop
148
+ total = store.size
149
+ filter_calls!
150
+ MockSuey.logger.debug "Collected #{store.size} real calls (#{total - store.size} were filtered)"
151
+ store
152
+ end
153
+
154
+ def <<(call_obj)
155
+ store << call_obj
156
+ end
157
+
158
+ private
159
+
160
+ def filter_calls!
161
+ store.reject! { mocked?(_1) }
162
+ end
163
+
164
+ def mocked?(call_obj)
165
+ location = call_obj.metadata[:location]
166
+
167
+ location.match?(%r{/lib/rspec/mocks/}) ||
168
+ call_obj.return_value.is_a?(::RSpec::Mocks::Double) ||
169
+ call_obj.arguments.any? { _1.is_a?(::RSpec::Mocks::Double) } ||
170
+ call_obj.kwargs.values.any? { _1.is_a?(::RSpec::Mocks::Double) }
171
+ end
172
+ end
173
+ end