grift 0.1.0 → 1.0.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.
- checksums.yaml +4 -4
- data/.github/CODEOWNERS +1 -0
- data/.github/CONTRIBUTING.md +37 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +46 -0
- data/.gitignore +7 -19
- data/.rubocop.yml +159 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +26 -0
- data/Gemfile +18 -4
- data/Gemfile.lock +50 -3
- data/README.md +76 -4
- data/bin/console +1 -0
- data/grift.gemspec +2 -0
- data/lib/grift/config/restricted.yml +31 -0
- data/lib/grift/config.rb +27 -0
- data/lib/grift/error.rb +8 -0
- data/lib/grift/minitest_plugin.rb +27 -0
- data/lib/grift/mock_method/mock_executions.rb +102 -0
- data/lib/grift/mock_method.rb +284 -0
- data/lib/grift/mock_store.rb +141 -0
- data/lib/grift/version.rb +5 -1
- data/lib/grift.rb +206 -8
- metadata +15 -2
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Grift
|
4
|
+
class MockMethod
|
5
|
+
##
|
6
|
+
# An Array wrapper that tracks the calls and results for a {Grift::MockMethod}.
|
7
|
+
#
|
8
|
+
class MockExecutions
|
9
|
+
##
|
10
|
+
# A new instance of MockExecutions.
|
11
|
+
#
|
12
|
+
# @return [Grift::MockMethod::MockExectuions]
|
13
|
+
#
|
14
|
+
def initialize
|
15
|
+
@executions = []
|
16
|
+
end
|
17
|
+
|
18
|
+
##
|
19
|
+
# Returns an array of the args used in each call to the mocked method
|
20
|
+
#
|
21
|
+
# @example
|
22
|
+
# my_mock = Grift.spy_on(Number, :+)
|
23
|
+
# x = (3 + 4) + 5
|
24
|
+
# my_mock.mock.calls
|
25
|
+
# #=> [[4], [5]]
|
26
|
+
#
|
27
|
+
# @return [Array<Array>] an array of arrays of args
|
28
|
+
#
|
29
|
+
def calls
|
30
|
+
@executions.map do |exec|
|
31
|
+
exec[:args]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
##
|
36
|
+
# Returns true if there have been no calls tracked.
|
37
|
+
#
|
38
|
+
# @example
|
39
|
+
# my_mock = Grift.mock(String, :upcase)
|
40
|
+
# my_mock.mock.empty?
|
41
|
+
# #=> true
|
42
|
+
# "apple".upcase
|
43
|
+
# #=> "APPLE"
|
44
|
+
# my_mock.mock.empty?
|
45
|
+
# #=> false
|
46
|
+
#
|
47
|
+
# @return [Boolean] if the executions are empty
|
48
|
+
#
|
49
|
+
def empty?
|
50
|
+
@executions.empty?
|
51
|
+
end
|
52
|
+
|
53
|
+
##
|
54
|
+
# Returns the count of executions.
|
55
|
+
#
|
56
|
+
# @example
|
57
|
+
# my_mock = Grift.mock(String, :upcase)
|
58
|
+
# my_mock.mock.count
|
59
|
+
# #=> 0
|
60
|
+
# "apple".upcase
|
61
|
+
# #=> "APPLE"
|
62
|
+
# my_mock.mock.count
|
63
|
+
# #=> 1
|
64
|
+
#
|
65
|
+
# @return [Number] the number of executions
|
66
|
+
#
|
67
|
+
def count
|
68
|
+
@executions.count
|
69
|
+
end
|
70
|
+
|
71
|
+
##
|
72
|
+
# Returns an array of the results of each call to the mocked method
|
73
|
+
#
|
74
|
+
# @example
|
75
|
+
# my_mock = Grift.spy_on(Number, :+)
|
76
|
+
# x = (3 + 4) + 5
|
77
|
+
# my_mock.mock.results
|
78
|
+
# #=> [7, 12]
|
79
|
+
#
|
80
|
+
# @return [Array] an array of results
|
81
|
+
#
|
82
|
+
def results
|
83
|
+
@executions.map do |exec|
|
84
|
+
exec[:result]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
##
|
89
|
+
# Stores an args and result pair to the executions array.
|
90
|
+
#
|
91
|
+
# @example
|
92
|
+
# mock_store = Grift::MockMethod::MockExecutions.new
|
93
|
+
# mock_store.store([1, 1], [2])
|
94
|
+
#
|
95
|
+
# @return [Array] an array of results
|
96
|
+
#
|
97
|
+
def store(args, result)
|
98
|
+
@executions.push({ args: args, result: result })
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,284 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Grift
|
4
|
+
##
|
5
|
+
# A mock for a given class and method. This is the core of Grift. Mocking or spying
|
6
|
+
# usually returns a {Grift::MockMethod}.
|
7
|
+
#
|
8
|
+
class MockMethod
|
9
|
+
attr_reader :true_method_cached, :klass, :method_name
|
10
|
+
|
11
|
+
CACHE_METHOD_PREFIX = 'grift_cache'
|
12
|
+
private_constant :CACHE_METHOD_PREFIX
|
13
|
+
|
14
|
+
##
|
15
|
+
# A new instance of MockMethod. Should be initialized via {Grift.mock} or {Grift.spy_on}
|
16
|
+
#
|
17
|
+
# @see Grift.mock
|
18
|
+
# @see Grift.spy_on
|
19
|
+
#
|
20
|
+
# @example
|
21
|
+
# Grift.spy_on(MyClass, :my_method)
|
22
|
+
# #=> MockMethod instance with the method being watched
|
23
|
+
#
|
24
|
+
# @param klass [Class] the class to be mocked
|
25
|
+
# @param method_name [Symbol] the method to be mocked
|
26
|
+
# @param watch [Boolean] whether to start watching the method
|
27
|
+
#
|
28
|
+
# @return [Grift::MockMethod]
|
29
|
+
#
|
30
|
+
def initialize(klass, method_name, watch: true)
|
31
|
+
if Grift.restricted_method?(klass, method_name)
|
32
|
+
raise(Grift::Error, "Cannont mock restricted method #{method_name} for class #{klass}")
|
33
|
+
end
|
34
|
+
|
35
|
+
@klass = klass
|
36
|
+
@method_name = method_name
|
37
|
+
@true_method_cached = false
|
38
|
+
@mock_executions = MockExecutions.new
|
39
|
+
@cache_method_name = "#{CACHE_METHOD_PREFIX}_#{method_name}".to_sym
|
40
|
+
|
41
|
+
# class methods are really instance methods of the singleton class
|
42
|
+
@class_method = klass.singleton_class.instance_methods(true).include?(method_name)
|
43
|
+
|
44
|
+
unless class_instance.instance_methods(true).include?(method_name)
|
45
|
+
raise(Grift::Error, "Cannont mock unknown method #{method_name} for class #{klass}")
|
46
|
+
end
|
47
|
+
|
48
|
+
if class_instance.instance_methods.include?(@cache_method_name)
|
49
|
+
raise(Grift::Error, "Cannot mock already mocked method #{method_name} for class #{klass}")
|
50
|
+
end
|
51
|
+
|
52
|
+
watch_method if watch
|
53
|
+
end
|
54
|
+
|
55
|
+
##
|
56
|
+
# Gets the data for the mock results and calls for this mock.
|
57
|
+
#
|
58
|
+
# @see Grift::MockMethod::MockExecutions#calls
|
59
|
+
# @see Grift::MockMethod::MockExecutions#results
|
60
|
+
#
|
61
|
+
# @example
|
62
|
+
# my_mock = Grift.spy_on(String, :upcase)
|
63
|
+
# "banana".upcase
|
64
|
+
# #=> 'BANANA'
|
65
|
+
# my_mock.mock.calls
|
66
|
+
# #=> [[]]
|
67
|
+
# my_mock.mock.results
|
68
|
+
# #=> [['BANANA']]
|
69
|
+
#
|
70
|
+
# @return [Grift::MockMethod::MockExecutions]
|
71
|
+
#
|
72
|
+
def mock
|
73
|
+
@mock_executions
|
74
|
+
end
|
75
|
+
|
76
|
+
##
|
77
|
+
# Clears the mock execution and calls data for this mock, but
|
78
|
+
# keep the method mocked as before.
|
79
|
+
#
|
80
|
+
# @return [Grift::MockMethod::MockExecutions]
|
81
|
+
#
|
82
|
+
def mock_clear
|
83
|
+
@mock_executions = MockExecutions.new
|
84
|
+
end
|
85
|
+
|
86
|
+
##
|
87
|
+
# Clears the mock execution and calls data for this mock, and
|
88
|
+
# mocks the method to return `nil`.
|
89
|
+
#
|
90
|
+
# @return [Grift::MockMethod::MockExecutions]
|
91
|
+
#
|
92
|
+
def mock_reset
|
93
|
+
executions = mock_clear
|
94
|
+
mock_return_value(nil)
|
95
|
+
executions
|
96
|
+
end
|
97
|
+
|
98
|
+
##
|
99
|
+
# Clears the mock execution and calls data for this mock, and
|
100
|
+
# restores the method to its original behavior. By default it
|
101
|
+
# also stops watching the method. This cleans up the mocking
|
102
|
+
# and restores expected behavior.
|
103
|
+
#
|
104
|
+
# @param watch [Boolean] whether or not to keep watching the method
|
105
|
+
#
|
106
|
+
# @return [Grift::MockMethod::MockExecutions]
|
107
|
+
#
|
108
|
+
def mock_restore(watch: false)
|
109
|
+
executions = mock_clear
|
110
|
+
unmock_method if @true_method_cached
|
111
|
+
watch_method if watch
|
112
|
+
executions
|
113
|
+
end
|
114
|
+
|
115
|
+
##
|
116
|
+
# Accepts a block and mocks the method to execute that block instead
|
117
|
+
# of the original behavior whenever called while mocked.
|
118
|
+
#
|
119
|
+
# @example
|
120
|
+
# my_mock = Grift.spy_on(String, :downcase).mock_implementation do
|
121
|
+
# x = 3 + 4
|
122
|
+
# x.to_s
|
123
|
+
# end
|
124
|
+
# "Banana".downcase
|
125
|
+
# #=> '7'
|
126
|
+
#
|
127
|
+
# @example
|
128
|
+
# my_mock = Grift.spy_on(MyClass, :my_method).mock_implementation do |first, second|
|
129
|
+
# [second, first]
|
130
|
+
# end
|
131
|
+
# MyClass.my_method(1, 2)
|
132
|
+
# #=> [2, 1]
|
133
|
+
#
|
134
|
+
# @return [Grift::MockMethod] the mock itself
|
135
|
+
#
|
136
|
+
def mock_implementation(&block)
|
137
|
+
premock_setup
|
138
|
+
mock_executions = @mock_executions # required to access inside class instance block
|
139
|
+
|
140
|
+
class_instance.remove_method(@method_name)
|
141
|
+
class_instance.define_method @method_name do |*args|
|
142
|
+
return_value = block.call(*args)
|
143
|
+
|
144
|
+
# record the args passed in the call to the method and the result
|
145
|
+
mock_executions.store(args, return_value)
|
146
|
+
return return_value
|
147
|
+
end
|
148
|
+
|
149
|
+
self
|
150
|
+
end
|
151
|
+
|
152
|
+
##
|
153
|
+
# Accepts a value and mocks the method to return that value instead
|
154
|
+
# of executing its original behavior while mocked.
|
155
|
+
#
|
156
|
+
# @see Grift#mock
|
157
|
+
#
|
158
|
+
# @example
|
159
|
+
# my_mock = Grift.spy_on(String, :upcase).mock_return_value('BANANA')
|
160
|
+
# "apple".upcase
|
161
|
+
# #=> 'BANANA'
|
162
|
+
#
|
163
|
+
# @param return_value the value to return from the method
|
164
|
+
#
|
165
|
+
# @return [Grift::MockMethod] the mock itself
|
166
|
+
#
|
167
|
+
def mock_return_value(return_value = nil)
|
168
|
+
premock_setup
|
169
|
+
mock_executions = @mock_executions # required to access inside class instance block
|
170
|
+
|
171
|
+
class_instance.remove_method(@method_name)
|
172
|
+
class_instance.define_method @method_name do |*args|
|
173
|
+
# record the args passed in the call to the method and the result
|
174
|
+
mock_executions.store(args, return_value)
|
175
|
+
return return_value
|
176
|
+
end
|
177
|
+
|
178
|
+
self
|
179
|
+
end
|
180
|
+
|
181
|
+
##
|
182
|
+
# String representation of the MockMethod
|
183
|
+
#
|
184
|
+
# @see Grift::MockMethod.hash_key
|
185
|
+
#
|
186
|
+
# @return [String]
|
187
|
+
#
|
188
|
+
def to_s
|
189
|
+
Grift::MockMethod.hash_key(@klass, @method_name)
|
190
|
+
end
|
191
|
+
|
192
|
+
##
|
193
|
+
# Hashes the class and method for tracking mocks.
|
194
|
+
#
|
195
|
+
# @example
|
196
|
+
# Grift::MockMethod.hash_key(String, :upcase)
|
197
|
+
# #=> 'String#upcase'
|
198
|
+
#
|
199
|
+
# @param klass [Class]
|
200
|
+
# @param method_name [Symbol]
|
201
|
+
#
|
202
|
+
# @return [String] the hash of the class and method
|
203
|
+
#
|
204
|
+
def self.hash_key(klass, method_name)
|
205
|
+
"#{klass}\##{method_name}"
|
206
|
+
end
|
207
|
+
|
208
|
+
private
|
209
|
+
|
210
|
+
##
|
211
|
+
# Watches the method without mocking its impelementation or return value.
|
212
|
+
#
|
213
|
+
# @return [Grift::MockMethod] the mock itself
|
214
|
+
#
|
215
|
+
def watch_method
|
216
|
+
premock_setup
|
217
|
+
mock_executions = @mock_executions # required to access inside class instance block
|
218
|
+
cache_method_name = @cache_method_name
|
219
|
+
|
220
|
+
class_instance.remove_method(@method_name)
|
221
|
+
class_instance.define_method @method_name do |*args|
|
222
|
+
return_value = send(cache_method_name, *args)
|
223
|
+
|
224
|
+
# record the args passed in the call to the method and the result
|
225
|
+
mock_executions.store(args, return_value)
|
226
|
+
return return_value
|
227
|
+
end
|
228
|
+
|
229
|
+
self
|
230
|
+
end
|
231
|
+
|
232
|
+
##
|
233
|
+
# Unmocks the method and restores the true method
|
234
|
+
#
|
235
|
+
# @raise [Grift:Error] if method not mocked
|
236
|
+
#
|
237
|
+
def unmock_method
|
238
|
+
raise(Grift::Error, 'Method is not cached') unless @true_method_cached
|
239
|
+
|
240
|
+
class_instance.remove_method(@method_name)
|
241
|
+
class_instance.alias_method(@method_name, @cache_method_name)
|
242
|
+
class_instance.remove_method(@cache_method_name)
|
243
|
+
|
244
|
+
@true_method_cached = false
|
245
|
+
end
|
246
|
+
|
247
|
+
##
|
248
|
+
# Caches the method
|
249
|
+
#
|
250
|
+
# @raise [Grift::Error] if method already cached
|
251
|
+
#
|
252
|
+
def cache_method
|
253
|
+
raise(Grift::Error, 'Method already cached') if @true_method_cached
|
254
|
+
|
255
|
+
class_instance.alias_method(@cache_method_name, @method_name)
|
256
|
+
@true_method_cached = true
|
257
|
+
end
|
258
|
+
|
259
|
+
##
|
260
|
+
# Sets up mock actions by caching the method and storing it in the
|
261
|
+
# Grift global store.
|
262
|
+
#
|
263
|
+
def premock_setup
|
264
|
+
cache_method unless @true_method_cached
|
265
|
+
send_to_store
|
266
|
+
end
|
267
|
+
|
268
|
+
##
|
269
|
+
# Adds the mock to the global store
|
270
|
+
#
|
271
|
+
def send_to_store
|
272
|
+
Grift.mock_store.store(self) unless Grift.mock_store.include?(self)
|
273
|
+
end
|
274
|
+
|
275
|
+
##
|
276
|
+
# Returns the appropriate class instance
|
277
|
+
#
|
278
|
+
# @return [Class]
|
279
|
+
#
|
280
|
+
def class_instance
|
281
|
+
@class_method ? @klass.singleton_class : @klass
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Grift
|
4
|
+
##
|
5
|
+
# A hash wrapper that tracks what methods are mocked and facilitates cleanup of mocks.
|
6
|
+
# This class is meant for internal use and should generally not be called explcitly.
|
7
|
+
#
|
8
|
+
class MockStore
|
9
|
+
# A new instance of MockStore
|
10
|
+
# @return [Grift::MockStore]
|
11
|
+
def initialize
|
12
|
+
@mocks = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
##
|
16
|
+
# Add the given mock to the mock store.
|
17
|
+
#
|
18
|
+
# @example
|
19
|
+
# mock_store = Grift::MockStore.new
|
20
|
+
# mock_method = Grift.mock(MyClass, :my_method)
|
21
|
+
# mock_store.store(mock_method)
|
22
|
+
#
|
23
|
+
# @param mock_method [Grift::MockMethod] the mock method to add to the store
|
24
|
+
#
|
25
|
+
# @raise [Grift::Error] if mock_method is not of type {Grift::MockMethod}
|
26
|
+
# @raise [Grift::Error] if the store already contains that mock or an equivalent mock
|
27
|
+
#
|
28
|
+
# @return [Grift::MockMethod] the mock method that was added
|
29
|
+
#
|
30
|
+
def store(mock_method)
|
31
|
+
raise(Grift::Error, 'Must only store Grift Mocks') unless mock_method.instance_of?(Grift::MockMethod)
|
32
|
+
raise(Grift::Error, 'Store aready contains that mock') if include?(mock_method)
|
33
|
+
|
34
|
+
@mocks[mock_method.to_s] = mock_method
|
35
|
+
end
|
36
|
+
|
37
|
+
##
|
38
|
+
# Searches for the mock in the store. Optional filtering by class/method.
|
39
|
+
# If no parameters are passed in, this returns all mocks in the store.
|
40
|
+
#
|
41
|
+
# @param klass [Class] the class to filter by, if nil all classes are included
|
42
|
+
# @param method [Symbol] the method to filter by, if nil all symbols are included
|
43
|
+
#
|
44
|
+
# @return [Array] the mocks in the store that match the criteria
|
45
|
+
#
|
46
|
+
def mocks(klass: nil, method: nil)
|
47
|
+
search(klass: klass, method: method).values
|
48
|
+
end
|
49
|
+
|
50
|
+
##
|
51
|
+
# Unmocks and removes mocks in the store. Optional filtering by class/method.
|
52
|
+
# If no parameters are passed in, cleans up all mocks in the store.
|
53
|
+
#
|
54
|
+
# @param klass [Class] the class to filter by, if nil all classes are included
|
55
|
+
# @param method [Symbol] the method to filter by, if nil all symbols are included
|
56
|
+
#
|
57
|
+
# @return [Grift::MockStore] the updated mock store itself
|
58
|
+
#
|
59
|
+
def remove(klass: nil, method: nil)
|
60
|
+
to_remove = search(klass: klass, method: method)
|
61
|
+
to_remove.each do |key, mock|
|
62
|
+
mock.mock_restore(watch: false)
|
63
|
+
@mocks.delete(key)
|
64
|
+
end
|
65
|
+
|
66
|
+
self
|
67
|
+
end
|
68
|
+
|
69
|
+
##
|
70
|
+
# Unmocks and removes the mock in the store. If the mock is not in the store,
|
71
|
+
# nothing will change.
|
72
|
+
#
|
73
|
+
# @param mock_method [Grift::MockMethod, String] the mock to remove or its hash key
|
74
|
+
#
|
75
|
+
# @return [Grift::MockStore] the updated mock store itself
|
76
|
+
#
|
77
|
+
def delete(mock_method)
|
78
|
+
if include?(mock_method)
|
79
|
+
mock_method.mock_restore(watch: false)
|
80
|
+
@mocks.delete(mock_method.to_s)
|
81
|
+
end
|
82
|
+
|
83
|
+
self
|
84
|
+
end
|
85
|
+
|
86
|
+
##
|
87
|
+
# Checks if the mock store includes the given mock method.
|
88
|
+
#
|
89
|
+
# @example
|
90
|
+
# mock_store = Grift::MockStore.new
|
91
|
+
# mock_method = Grift.mock(MyClass, :my_method)
|
92
|
+
# mock_store.include?(mock_method)
|
93
|
+
# #=> false
|
94
|
+
# mock_store.store(mock_method)
|
95
|
+
# mock_store.include?(mock_method)
|
96
|
+
# #=> true
|
97
|
+
#
|
98
|
+
# @param mock_method [Grift::MockMethod, String] the mock to search for or its hash key
|
99
|
+
#
|
100
|
+
# @return [Boolean] if the mock store includes that mock method
|
101
|
+
#
|
102
|
+
def include?(mock_method)
|
103
|
+
@mocks.include?(mock_method.to_s)
|
104
|
+
end
|
105
|
+
|
106
|
+
##
|
107
|
+
# Checks if the mock store is empty.
|
108
|
+
#
|
109
|
+
# @example
|
110
|
+
# mock_store = Grift::MockStore.new
|
111
|
+
# mock_store.empty?
|
112
|
+
# #=> true
|
113
|
+
# mock_method = Grift.mock(MyClass, :my_method)
|
114
|
+
# mock_store.store(mock_method)
|
115
|
+
# mock_store.empty?
|
116
|
+
# #=> false
|
117
|
+
#
|
118
|
+
# @return [Boolean] if the mock store is empty
|
119
|
+
#
|
120
|
+
def empty?
|
121
|
+
@mocks.empty?
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
##
|
127
|
+
# Searches for the mock in the store. Optional filtering by class/method.
|
128
|
+
#
|
129
|
+
# @param klass [Class] the class to filter by, if nil all classes are included
|
130
|
+
# @param method [Symbol] the method to filter by, if nil all symbols are included
|
131
|
+
#
|
132
|
+
# @return [Hash] the key, mock pairs in the store that match the criteria
|
133
|
+
#
|
134
|
+
def search(klass: nil, method: nil)
|
135
|
+
return @mocks unless klass || method
|
136
|
+
|
137
|
+
pattern = Regexp.new(Grift::MockMethod.hash_key(klass, method))
|
138
|
+
@mocks.select { |k| pattern.match?(k) }
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|