Skip to content

Commit 890e626

Browse files
authored
fix: cannot watch methods with kwargs in ruby 3 (#72)
* reproduce bug in tests * add MockArguments to support kwargs handling * store kwargs in all mocking styles * drop support for ruby 2.6 * update CHANGELOG and README with changes for new args access pattern
1 parent 96f8445 commit 890e626

File tree

11 files changed

+391
-37
lines changed

11 files changed

+391
-37
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
strategy:
3434
fail-fast: false
3535
matrix:
36-
ruby: ["2.6", "2.7", "3.0", "3.1"]
36+
ruby: ["2.7", "3.0", "3.1"]
3737

3838
steps:
3939
- uses: actions/checkout@v2

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
88

99
### Changed
1010

11-
* Dropped support for Ruby 2.5
11+
* Dropped support for Ruby 2.5 and Ruby 2.6
12+
* To support keyword arguments, records of call arguments are no longer stored in simple arrays but in a custom Enumerable
13+
+ This changes the way that your tests will interact with mock calls
14+
+ When before `calls` returned an array, it returns a `Grift::MockMethod::MockExecutions::MockArguments` object
15+
+ Migrating to maintain previous behavior just requires appending `.args` to `calls`
1216

1317
### Added
1418

1519
* Support for mocking private instance and class methods
20+
* Support for mocking methods that take positional and keyword arguments
1621

1722
### Fixed
1823

README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,22 @@ my_spy.mock_implementation do |arg1, arg2|
7878
end
7979
```
8080

81+
or for a method taking keyword arguments:
82+
83+
```ruby
84+
my_spy = Grift.spy_on(MyClass, :my_method)
85+
my_spy.mock_implementation do |arg1, arg2, **kwargs|
86+
x = do_something(arg1, arg2, kwargs[:arg3], kwargs[:arg4])
87+
do_something_else(x) # the last line will be returned
88+
end
89+
8190
### Chaining
8291

8392
You can chain `mock_return_value` and `mock_implementation` after initializing the mock.
8493

8594
```ruby
86-
my_mock = Grift.spy_on(MyClass, :my_method).mock_implementation do |*args|
87-
do_something(*args)
95+
my_mock = Grift.spy_on(MyClass, :my_method).mock_implementation do |*args, **kwargs|
96+
do_something(*args, **kwargs)
8897
end
8998
#=> Grift::MockMethod object is returned
9099
```
@@ -99,8 +108,12 @@ my_mock.mock.count
99108
#=> 2
100109
101110
# get args for each call to the method while mocked
102-
my_mock.mock.calls
103-
#=> [['first_arg1', 'second_arg1'], ['first_arg2', 'second_arg2']]
111+
my_mock.mock.calls[0].args
112+
#=> ['first_arg1', 'second_arg1']
113+
114+
# get kwargs for each call to the method while mocked
115+
my_mock.mock.calls[0].kwargs
116+
#=> { first_arg1: 'value' }
104117
105118
# get results (return value) for each call to the method while mocked
106119
my_mock.mock.results
@@ -109,7 +122,7 @@ my_mock.mock.results
109122

110123
## Requirements
111124

112-
Grift supports all Ruby versions >= 2.5 (including 3.1).
125+
Grift supports all Ruby versions >= 2.7 (including 3.1).
113126

114127
## Development
115128

@@ -119,7 +132,7 @@ When developing, to install Grift whith your changes onto your local machine, ru
119132

120133
### Docs
121134

122-
The docs are generated using YARD. To build the docs, first `gem install yard`. Then run `yardoc` to build the new docs. This is always done before a release to update the docs that get published and made available with the gem.
135+
The docs are generated using YARD. To build the docs, first `gem install yard` . Then run `yardoc` to build the new docs. This is always done before a release to update the docs that get published and made available with the gem.
123136

124137
## Contributing
125138

grift.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
1212
spec.description = "A gem for simple mocking and spying in Ruby's MiniTest framework."
1313
spec.homepage = 'https://github.com/clarkedb/grift'
1414
spec.license = 'MIT'
15-
spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
15+
spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0')
1616

1717
spec.metadata = {
1818
'bug_tracker_uri' => "#{spec.homepage}/issues",

lib/grift.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require 'grift/minitest_plugin'
66
require 'grift/mock_method'
77
require 'grift/mock_method/mock_executions'
8+
require 'grift/mock_method/mock_executions/mock_arguments'
89
require 'grift/mock_store'
910
require 'grift/version'
1011

lib/grift/mock_method.rb

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -125,24 +125,24 @@ def mock_restore(watch: false)
125125
# #=> '7'
126126
#
127127
# @example
128-
# my_mock = Grift.spy_on(MyClass, :my_method).mock_implementation do |first, second|
129-
# [second, first]
128+
# my_mock = Grift.spy_on(MyClass, :my_method).mock_implementation do |first, second, **kwargs|
129+
# [second, kwargs[:third], first]
130130
# end
131-
# MyClass.my_method(1, 2)
132-
# #=> [2, 1]
131+
# MyClass.my_method(1, 2, third: 3)
132+
# #=> [2, 3, 1]
133133
#
134-
# @return [Grift::MockMethod] the mock itself
134+
# @return [self] the mock itself
135135
#
136136
def mock_implementation(*)
137137
premock_setup
138138
mock_executions = @mock_executions # required to access inside class instance block
139139

140140
class_instance.remove_method(@method_name) if !@inherited && method_defined?
141-
class_instance.define_method @method_name do |*args|
142-
return_value = yield(*args)
141+
class_instance.define_method @method_name do |*args, **kwargs|
142+
return_value = yield(*args, **kwargs)
143143

144144
# record the args passed in the call to the method and the result
145-
mock_executions.store(args, return_value)
145+
mock_executions.store(args: args, result: return_value)
146146
return return_value
147147
end
148148
class_instance.send(@method_access, @method_name)
@@ -163,16 +163,16 @@ def mock_implementation(*)
163163
#
164164
# @param return_value the value to return from the method
165165
#
166-
# @return [Grift::MockMethod] the mock itself
166+
# @return [self] the mock itself
167167
#
168168
def mock_return_value(return_value = nil)
169169
premock_setup
170170
mock_executions = @mock_executions # required to access inside class instance block
171171

172172
class_instance.remove_method(@method_name) if !@inherited && method_defined?
173-
class_instance.define_method @method_name do |*args|
173+
class_instance.define_method @method_name do |*args, **kwargs|
174174
# record the args passed in the call to the method and the result
175-
mock_executions.store(args, return_value)
175+
mock_executions.store(args: args, kwargs: kwargs, result: return_value)
176176
return return_value
177177
end
178178
class_instance.send(@method_access, @method_name)
@@ -212,19 +212,19 @@ def self.hash_key(klass, method_name)
212212
##
213213
# Watches the method without mocking its impelementation or return value.
214214
#
215-
# @return [Grift::MockMethod] the mock itself
215+
# @return [self] the mock itself
216216
#
217217
def watch_method
218218
premock_setup
219219
mock_executions = @mock_executions # required to access inside class instance block
220220
cache_method_name = @cache_method_name
221221

222222
class_instance.remove_method(@method_name) if !@inherited && method_defined?
223-
class_instance.define_method @method_name do |*args|
224-
return_value = send(cache_method_name, *args)
223+
class_instance.define_method @method_name do |*args, **kwargs|
224+
return_value = send(cache_method_name, *args, **kwargs)
225225

226226
# record the args passed in the call to the method and the result
227-
mock_executions.store(args, return_value)
227+
mock_executions.store(args: args, kwargs: kwargs, result: return_value)
228228
return return_value
229229
end
230230
class_instance.send(@method_access, @method_name)

lib/grift/mock_method/mock_executions.rb

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class MockExecutions
99
##
1010
# A new instance of MockExecutions.
1111
#
12-
# @return [Grift::MockMethod::MockExectuions]
12+
# @return [Grift::MockMethod::MockExecutions]
1313
#
1414
def initialize
1515
@executions = []
@@ -21,14 +21,14 @@ def initialize
2121
# @example
2222
# my_mock = Grift.spy_on(Number, :+)
2323
# x = (3 + 4) + 5
24-
# my_mock.mock.calls
24+
# my_mock.mock.calls.map(&:values)
2525
# #=> [[4], [5]]
2626
#
27-
# @return [Array<Array>] an array of arrays of args
27+
# @return [Array<Grift::MockMethod::MockExecutions::MockArguments>] an array of MockArguments
2828
#
2929
def calls
3030
@executions.map do |exec|
31-
exec[:args]
31+
exec[:arguments]
3232
end
3333
end
3434

@@ -89,13 +89,17 @@ def results
8989
# Stores an args and result pair to the executions array.
9090
#
9191
# @example
92-
# mock_store = Grift::MockMethod::MockExecutions.new
93-
# mock_store.store([1, 1], [2])
92+
# mock_executions = Grift::MockMethod::MockExecutions.new
93+
# mock_executions.store(args: [1, 1], kwargs: { test: true }, result: 2)
9494
#
95-
# @return [Array] an array of results
95+
# @param args [Array] the postitional args to store
96+
# @param kwargs [Hash] the keyword args to store
97+
# @param result the method result to store
98+
#
99+
# @return [Array] an updated array of executions
96100
#
97-
def store(args, result)
98-
@executions.push({ args: args, result: result })
101+
def store(args: [], kwargs: {}, result: nil)
102+
@executions.push({ arguments: MockArguments.new(args: args, kwargs: kwargs), result: result })
99103
end
100104
end
101105
end
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# frozen_string_literal: true
2+
3+
module Grift
4+
class MockMethod
5+
class MockExecutions
6+
##
7+
# An immutable Enumerable that stores the arguments used in a call for {Grift::MockMethod::MockExecutions}.
8+
#
9+
class MockArguments
10+
include Enumerable
11+
12+
attr_reader :args, :kwargs
13+
14+
##
15+
# A new instance of MockArguments.
16+
#
17+
# @return [Grift::MockMethod::MockExecutions::MockArguments]
18+
#
19+
def initialize(args: [], kwargs: {})
20+
@args = args.freeze
21+
@kwargs = kwargs.freeze
22+
end
23+
24+
##
25+
# Retrieves a stored argument by an accessor. For positional arguments this would be an integer
26+
# corresponding to the arguments position in the method call signature. For keyword arguments
27+
# this would be the key / parameter name as a symbol or string.
28+
#
29+
# @example
30+
# my_mock = Grift.mock(Request, :new)
31+
# request = Request.new('/users', method: 'GET')
32+
# #=> <Request object>
33+
# my_mock.mock.calls.last[0] # integer access for positional
34+
# #=> '/users'
35+
# my_mock.mock.calls.last[:method] # key access for keyword
36+
# #=> 'GET'
37+
#
38+
# @param index [Integer, Symbol, String] the accessor for the desired argument
39+
#
40+
# @raise [Grift::Error] exception if accessor is not a supported type
41+
#
42+
# @return the specified value
43+
#
44+
def [](index)
45+
if index.instance_of?(Integer)
46+
@args[index]
47+
elsif index.instance_of?(Symbol)
48+
@kwargs[index]
49+
elsif index.instance_of?(String)
50+
@kwargs[index.to_sym]
51+
else
52+
raise(Grift::Error, "Cannot access by type '#{index.class}'. Expected an Integer, Symbol, or String")
53+
end
54+
end
55+
56+
##
57+
# Calls the given block once for each argument value, passing that value
58+
# as a parameter.
59+
#
60+
# @return [void]
61+
#
62+
def each(&block)
63+
@args.each(&block)
64+
@kwargs.each_value(&block)
65+
end
66+
67+
##
68+
# @see Enumerable#first
69+
#
70+
# Returns the last argument passed in the call.
71+
#
72+
# @example
73+
# my_mock = Grift.mock(Request, :new)
74+
# request = Request.new('/users', method: 'GET')
75+
# #=> <Request object>
76+
# my_mock.mock.calls.last.last
77+
# #=> 'GET'
78+
#
79+
# @return [Any]
80+
#
81+
def last
82+
@args.last || @kwargs.values.last
83+
end
84+
85+
##
86+
# Returns the number of arguments passed in the call.
87+
#
88+
# @example
89+
# arr = [1, 2, 3]
90+
# my_mock = Grift.mock(Array, :count)
91+
# arr.count
92+
# #=> 3
93+
# my_mock.mock.calls.last.length
94+
# #=> 0
95+
# arr.count(3)
96+
# #=> 1
97+
# my_mock.mock.calls.last.length
98+
# #=> 1
99+
#
100+
# @return [Integer]
101+
#
102+
def length
103+
@args.length + @kwargs.length
104+
end
105+
106+
##
107+
# Returns true if the call included no arguments.
108+
#
109+
# @example
110+
# arr = [1, 2, 3]
111+
# my_mock = Grift.mock(Array, :count)
112+
# arr.count
113+
# #=> 3
114+
# my_mock.mock.calls.last.empty?
115+
# #=> true
116+
# arr.count(2)
117+
# #=> 1
118+
# my_mock.mock.calls.last.empty?
119+
# #=> false
120+
#
121+
# @return [Boolean] true if the no arguments were used
122+
#
123+
def empty?
124+
@args.empty? && @kwargs.empty?
125+
end
126+
127+
##
128+
# Returns the keyword parameter keys passed in the call.
129+
#
130+
# To get the values, use {Grift::MockMethod::MockExecutions::MockArguments#values}.
131+
#
132+
# @example
133+
# my_mock = Grift.mock(Request, :new)
134+
# request = Request.new('/users', method: 'GET')
135+
# #=> <Request object>
136+
# my_mock.mock.calls.last.keys
137+
# #=> [:method]
138+
#
139+
# @return [Array<Symbol>] the parameter keys
140+
#
141+
def keys
142+
@kwargs.keys
143+
end
144+
145+
##
146+
# Returns the arguments passed in the call.
147+
#
148+
# If a keyword argument was used, then only the value will be returned.
149+
# To get the keys, use {Grift::MockMethod::MockExecutions::MockArguments#keys}.
150+
#
151+
# @example
152+
# my_mock = Grift.mock(Request, :new)
153+
# request = Request.new('/users', method: 'GET')
154+
# #=> <Request object>
155+
# my_mock.mock.calls.last.keys
156+
# #=> [:method]
157+
#
158+
# @return [Array] the argument values
159+
#
160+
def values
161+
@args + @kwargs.values
162+
end
163+
end
164+
end
165+
end
166+
end

0 commit comments

Comments
 (0)