mock-suey 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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