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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MockSuey
4
+ class Configuration
5
+ # No freezing this const to allow third-party libraries
6
+ # to integrate with mock_suey
7
+ TYPE_CHECKERS = %w[ruby]
8
+
9
+ attr_accessor :debug,
10
+ :logger,
11
+ :log_level,
12
+ :color,
13
+ :store_mocked_calls,
14
+ :signature_load_dirs,
15
+ :raise_on_missing_types,
16
+ :raise_on_missing_auto_types,
17
+ :trace_real_calls,
18
+ :trace_real_calls_via
19
+
20
+ attr_reader :type_check, :auto_type_check, :verify_mock_contracts
21
+
22
+ def initialize
23
+ @debug = %w[1 y yes true t].include?(ENV["MOCK_SUEY_DEBUG"])
24
+ @log_level = debug ? :debug : :info
25
+ @color = nil
26
+ @store_mocked_calls = false
27
+ @type_check = nil
28
+ @signature_load_dirs = ["sig"]
29
+ @raise_on_missing_types = false
30
+ @raise_on_missing_auto_types = true
31
+ @trace_real_calls = false
32
+ @auto_type_check = false
33
+ @trace_real_calls_via = :prepend
34
+ end
35
+
36
+ def color?
37
+ return color unless color.nil?
38
+
39
+ logdev = logger.instance_variable_get(:@logdev)
40
+ return self.color = false unless logdev
41
+
42
+ output = logdev.instance_variable_get(:@dev)
43
+ return self.color = false unless output
44
+
45
+ self.color = output.is_a?(IO) && output.tty?
46
+ end
47
+
48
+ def type_check=(val)
49
+ if val.nil?
50
+ @type_check = nil
51
+ return
52
+ end
53
+
54
+ val = val.to_s
55
+ raise ArgumentError, "Unsupported type checker: #{val}. Supported: #{TYPE_CHECKERS.join(",")}" unless TYPE_CHECKERS.include?(val)
56
+
57
+ @type_check = val
58
+ end
59
+
60
+ def auto_type_check=(val)
61
+ if val
62
+ @trace_real_calls = true
63
+ @store_mocked_calls = true
64
+ @auto_type_check = true
65
+ else
66
+ @auto_type_check = val
67
+ end
68
+ end
69
+
70
+ def verify_mock_contracts=(val)
71
+ if val
72
+ @trace_real_calls = true
73
+ @verify_mock_contracts = true
74
+ else
75
+ @verify_mock_contracts = val
76
+ end
77
+ end
78
+ end
79
+
80
+ class << self
81
+ attr_reader :stored_mocked_calls, :tracer, :stored_real_calls
82
+ attr_accessor :type_checker
83
+
84
+ def config = @config ||= Configuration.new
85
+
86
+ def configure ; yield config; end
87
+
88
+ def logger = config.logger
89
+
90
+ def on_mocked_call(&block)
91
+ on_mocked_callbacks << block
92
+ end
93
+
94
+ def handle_mocked_call(call_obj)
95
+ on_mocked_callbacks.each { _1.call(call_obj) }
96
+ end
97
+
98
+ def on_mocked_callbacks
99
+ @on_mocked_callbacks ||= []
100
+ end
101
+
102
+ # Load extensions and start tracing if required
103
+ def cook
104
+ setup_logger
105
+ setup_type_checker
106
+ setup_mocked_calls_collection if config.store_mocked_calls
107
+ setup_real_calls_collection if config.trace_real_calls
108
+ end
109
+
110
+ # Run post-suite checks
111
+ def eat
112
+ @stored_real_calls = tracer.stop if config.trace_real_calls
113
+
114
+ offenses = []
115
+
116
+ if config.store_mocked_calls
117
+ logger.debug { "Stored mocked calls:\n#{stored_mocked_calls.map { " #{_1.inspect}" }.join("\n")}" }
118
+ end
119
+
120
+ if config.trace_real_calls
121
+ logger.debug { "Traced real calls:\n#{stored_real_calls.map { " #{_1.inspect}" }.join("\n")}" }
122
+ end
123
+
124
+ if config.auto_type_check
125
+ perform_auto_type_check(offenses)
126
+ end
127
+
128
+ if config.verify_mock_contracts
129
+ perform_contracts_verification(offenses)
130
+ end
131
+
132
+ offenses
133
+ end
134
+
135
+ private
136
+
137
+ def setup_logger
138
+ if !config.logger || config.debug
139
+ config.logger = Logger.new($stdout)
140
+ config.logger.formatter = Logging::Formatter.new
141
+ end
142
+ config.logger.level = config.log_level
143
+ end
144
+
145
+ def setup_type_checker
146
+ return unless config.type_check
147
+
148
+ # Allow configuring type checher manually
149
+ unless type_checker
150
+ require "mock_suey/type_checks/#{config.type_check}"
151
+ const_name = config.type_check.split("_").map(&:capitalize).join
152
+
153
+ self.type_checker = MockSuey::TypeChecks.const_get(const_name)
154
+ .new(load_dirs: config.signature_load_dirs)
155
+
156
+ logger.info "Set up type checker: #{type_checker.class.name} (load_dirs: #{config.signature_load_dirs})"
157
+ end
158
+
159
+ raise_on_missing = config.raise_on_missing_types
160
+
161
+ on_mocked_call do |call_obj|
162
+ type_checker.typecheck!(call_obj, raise_on_missing: raise_on_missing)
163
+ end
164
+ end
165
+
166
+ def setup_mocked_calls_collection
167
+ logger.info "Collect mocked calls (MockSuey.stored_mocked_calls)"
168
+
169
+ @stored_mocked_calls = []
170
+
171
+ on_mocked_call { @stored_mocked_calls << _1 }
172
+ end
173
+
174
+ def setup_real_calls_collection
175
+ logger.info "Collect real calls via #{config.trace_real_calls_via} (MockSuey.stored_real_calls)"
176
+
177
+ @tracer = Tracer.new(via: config.trace_real_calls_via)
178
+
179
+ MockSuey::RSpec::MockContext.registry.each do |klass, methods|
180
+ logger.debug { "Trace #{klass} methods: #{methods.keys.join(", ")}" }
181
+ tracer.collect(klass, methods.keys)
182
+ end
183
+
184
+ tracer.start!
185
+ end
186
+
187
+ def perform_auto_type_check(offenses)
188
+ raise "No type checker configured" unless type_checker
189
+
190
+ # Generate signatures
191
+ type_checker.load_signatures_from_calls(stored_real_calls)
192
+
193
+ logger.info "Type-checking mocked calls against auto-generated signatures..."
194
+
195
+ was_offenses = offenses.size
196
+
197
+ # Verify stored mocked calls
198
+ raise_on_missing = config.raise_on_missing_auto_types
199
+
200
+ stored_mocked_calls.each do |call_obj|
201
+ type_checker.typecheck!(call_obj, raise_on_missing: raise_on_missing)
202
+ rescue RBS::Test::Tester::TypeError, TypeChecks::MissingSignature => err
203
+ call_obj.metadata[:error] = err
204
+ offenses << call_obj
205
+ end
206
+
207
+ failed_count = offenses.size - was_offenses
208
+ failed = failed_count > 0
209
+
210
+ if failed
211
+ logger.error "❌ Type-checking completed. Failed examples: #{failed_count}"
212
+ else
213
+ logger.info "✅ Type-checking completed. All good"
214
+ end
215
+ end
216
+
217
+ def perform_contracts_verification(offenses)
218
+ logger.info "Verifying mock contracts..."
219
+ real_calls_per_class_method = stored_real_calls.group_by(&:receiver_class).tap do |grouped|
220
+ grouped.transform_values! { _1.group_by(&:method_name) }
221
+ end
222
+
223
+ was_offenses = offenses.size
224
+
225
+ MockSuey::RSpec::MockContext.registry.each do |klass, methods|
226
+ methods.values.flatten.each do |stub_call|
227
+ contract = MockContract.from_stub(stub_call)
228
+ logger.debug { "Generated contract:\n #{contract.inspect}\n (from stub: #{stub_call.inspect})" }
229
+ contract.verify!(real_calls_per_class_method.dig(klass, stub_call.method_name))
230
+ rescue MockContract::Error => err
231
+ stub_call.metadata[:error] = err
232
+ offenses << stub_call
233
+ end
234
+ end
235
+
236
+ failed_count = offenses.size - was_offenses
237
+ failed = failed_count > 0
238
+
239
+ if failed
240
+ logger.error "❌ Verifying mock contracts completed. Failed contracts: #{failed_count}"
241
+ else
242
+ logger.info "✅ Verifying mock contracts completed. All good"
243
+ end
244
+ end
245
+ end
246
+ 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: receiver_class,
77
+ method_name: method_name,
78
+ args_pattern: 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: receiver_class,
90
+ method_name: method_name,
91
+ arguments: arguments,
92
+ return_value: 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: receiver_class,
33
+ method_name: 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)