spy 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.yardopts +1 -0
- data/Gemfile +2 -0
- data/README.md +90 -44
- data/lib/spy.rb +85 -207
- data/lib/spy/agency.rb +65 -0
- data/lib/spy/constant.rb +67 -0
- data/lib/spy/core_ext/marshal.rb +28 -0
- data/lib/spy/double.rb +1 -1
- data/lib/spy/nest.rb +45 -0
- data/lib/spy/subroutine.rb +239 -0
- data/lib/spy/version.rb +2 -2
- data/spec/spec_helper.rb +4 -2
- data/spec/spy/and_call_original_spec.rb +1 -1
- data/spec/spy/and_yield_spec.rb +23 -14
- data/spec/spy/hash_excluding_matcher_spec.rb +55 -59
- data/spec/spy/hash_including_matcher_spec.rb +69 -73
- data/spec/spy/mock_spec.rb +585 -589
- data/spec/spy/mutate_const_spec.rb +407 -367
- data/spec/spy/partial_mock_spec.rb +106 -162
- data/spec/spy/passing_argument_matchers_spec.rb +134 -136
- data/spec/spy/serialization_spec.rb +80 -74
- data/spec/spy/stub_implementation_spec.rb +14 -12
- data/spec/spy/stub_spec.rb +12 -12
- data/spec/spy/test_double_spec.rb +38 -41
- data/spy.gemspec +2 -1
- data/test/integration/test_constant_spying.rb +58 -0
- data/test/integration/test_subroutine_spying.rb +40 -0
- data/test/spy/test_double.rb +1 -1
- data/test/spy/test_subroutine.rb +191 -0
- data/test/support/pen.rb +50 -0
- data/test/test_helper.rb +4 -0
- metadata +33 -9
- data/TODO.md +0 -8
- data/lib/spy/dsl.rb +0 -7
- data/spec/spy/multiple_return_value_spec.rb +0 -119
- data/test/test_spy.rb +0 -258
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--markup=markdown
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,23 +1,39 @@
|
|
1
1
|
# Spy
|
2
2
|
|
3
|
-
Spy is a lightweight
|
3
|
+
Spy is a lightweight stubbing framework with support for method spies, constant stubs, and object doubles.
|
4
|
+
|
5
|
+
Spy was designed for 1.9.3+.
|
6
|
+
|
7
|
+
Spy features that were completed were tested against the rspec-mocks tests so it covers all cases that rspec-mocks does.
|
4
8
|
|
5
9
|
Inspired by the spy api of the jasmine javascript testing framework.
|
6
10
|
|
7
11
|
## Why use this instead of rspec-mocks, mocha, or etc
|
8
12
|
|
9
|
-
*
|
13
|
+
* Spy will raise error when you try to stub/spy a method that doesn't exist
|
10
14
|
* when you change your method name your unit tests will break
|
15
|
+
* no more fantasy tests
|
11
16
|
* Spy arity matches original method
|
12
17
|
* Your tests will raise an error if you use the wrong arity
|
13
18
|
* Spy visibility matches original method
|
14
19
|
* Your tests will raise an error if you try to call the method incorrectly
|
15
20
|
* Simple call log api
|
16
21
|
* easier to read tests
|
17
|
-
*
|
22
|
+
* use ruby to test ruby instead of a dsl
|
18
23
|
* no expectations
|
19
24
|
* really who thought that was a good idea?
|
20
|
-
* absolutely no polution of global object space
|
25
|
+
* absolutely no polution of global object space
|
26
|
+
* no polution of instance variables for stubbed objects
|
27
|
+
|
28
|
+
Fail faster, code faster.
|
29
|
+
|
30
|
+
## Why not to use this
|
31
|
+
|
32
|
+
* Api is not stable
|
33
|
+
* missing these features
|
34
|
+
* Mocking null objects
|
35
|
+
* argument matchers for Spy::Method#has\_been\_called\_with
|
36
|
+
* watch all calls to an object to check order in which they are called
|
21
37
|
|
22
38
|
## Installation
|
23
39
|
|
@@ -35,63 +51,93 @@ Or install it yourself as:
|
|
35
51
|
|
36
52
|
## Usage
|
37
53
|
|
54
|
+
### Method Stubs
|
55
|
+
|
56
|
+
A method stub overrides a pre-existing method and records all calls to specified method. You can set the spy to return either the original method or your own custom implementation.
|
57
|
+
|
58
|
+
Spy support 2 different ways of spying an existing method on an object.
|
38
59
|
```ruby
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
60
|
+
Spy.on(book, title: "East of Eden")
|
61
|
+
Spy.on(book, :title).and_return("East of Eden")
|
62
|
+
Spy.on(book, :title).and_return { "East of Eden" }
|
63
|
+
```
|
43
64
|
|
44
|
-
|
45
|
-
|
46
|
-
end
|
65
|
+
Spy will raise an error if you try to stub on a method that doesn't exist.
|
66
|
+
You can force the creation of a sstub on method that didn't exist but it really isn't suggested.
|
47
67
|
|
48
|
-
|
49
|
-
|
50
|
-
|
68
|
+
```ruby
|
69
|
+
Spy.new(book, :flamethrower).hook(force:true).and_return("burnninante")
|
70
|
+
```
|
51
71
|
|
52
|
-
|
53
|
-
|
54
|
-
|
72
|
+
### Test Doubles
|
73
|
+
|
74
|
+
A test double is an object that stands in for a real object.
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
Spy.double("book")
|
78
|
+
```
|
79
|
+
|
80
|
+
Spy will let you stub on any method even if it doesn't exist if the object is a double.
|
81
|
+
|
82
|
+
Spy comes with a shortcut to define an object with methods.
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
Spy.double("book", title: "Grapes of Wrath", author: "John Steinbeck")
|
86
|
+
```
|
87
|
+
|
88
|
+
### Arbitrary Handling
|
89
|
+
|
90
|
+
If you need to have a custom method based in the method inputs just send a block to #and\_return
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
Spy.on(book, :read_page).and_return do |page, &block|
|
94
|
+
block.call
|
95
|
+
"awesome " * page
|
55
96
|
end
|
56
97
|
```
|
57
98
|
|
58
|
-
|
99
|
+
An error will raise if the arity of the block is larger than the arity of the original method. However this can be overidden with the force argument.
|
59
100
|
|
60
101
|
```ruby
|
61
|
-
|
102
|
+
Spy.on(book, :read_page).and_return(force: true) do |a, b, c, d|
|
103
|
+
end
|
104
|
+
```
|
62
105
|
|
63
|
-
|
64
|
-
person.first_name #=> nil
|
65
|
-
first_name_spy.called? #=> true
|
106
|
+
### Method Spies
|
66
107
|
|
67
|
-
|
108
|
+
When you stub a method it returns a spy. A spy records what calls have been made to a given method.
|
68
109
|
|
69
|
-
|
70
|
-
|
110
|
+
```ruby
|
111
|
+
validator = Spy.double("validator")
|
112
|
+
validate_spy = Spy.on(validator, :validate)
|
113
|
+
validate_spy.has_been_called? #=> false
|
114
|
+
validator.validate("01234") #=> nil
|
115
|
+
validate_spy.has_been_called? #=> true
|
116
|
+
validate_spy.has_been_called_with?("01234) #=> true
|
117
|
+
```
|
71
118
|
|
72
|
-
|
73
|
-
|
74
|
-
person.first_name #=> "Bob"
|
119
|
+
### Calling through
|
120
|
+
If you just want to make sure if a method is called and not override the output you can just use the and\_call\_through method
|
75
121
|
|
76
|
-
|
77
|
-
|
122
|
+
```ruby
|
123
|
+
Spy.on(book, :read_page).and_call_through
|
124
|
+
```
|
78
125
|
|
79
|
-
|
80
|
-
person.say("hello") {
|
81
|
-
"everything accepts a block in ruby"
|
82
|
-
}
|
83
|
-
say_spy.say("world")
|
126
|
+
### Call Logs
|
84
127
|
|
85
|
-
|
86
|
-
say_spy.calls.count #=> 1
|
87
|
-
say_spy.calls.first.args #=> ["hello"]
|
88
|
-
say_spy.calls.last.args #=> ["world"]
|
128
|
+
When a spy is called on it records a call log. A call log contains the object it was called on, the arguments and block that were sent to method and what it returned.
|
89
129
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
130
|
+
```ruby
|
131
|
+
read_page_spy = Spy.on(book, read_page: "hello world")
|
132
|
+
book.read_page(5) { "this is a block" }
|
133
|
+
book.read_page(3)
|
134
|
+
book.read_page(7)
|
135
|
+
read_page_spy.calls.size #=> 3
|
136
|
+
first_call = read_page_spy.calls.first
|
137
|
+
first_call.object #=> book
|
138
|
+
first_call.args #=> [5]
|
139
|
+
first_call.block #=> Proc.new { "this is a block" }
|
140
|
+
first_call.result #=> "hello world"
|
95
141
|
```
|
96
142
|
|
97
143
|
### MiniTest
|
data/lib/spy.rb
CHANGED
@@ -1,229 +1,119 @@
|
|
1
|
-
require "spy/
|
1
|
+
require "spy/core_ext/marshal"
|
2
|
+
require "spy/agency"
|
3
|
+
require "spy/constant"
|
2
4
|
require "spy/double"
|
3
|
-
require "spy/
|
4
|
-
|
5
|
-
|
6
|
-
CallLog = Struct.new(:object, :args, :block)
|
7
|
-
|
8
|
-
attr_reader :base_object, :method_name, :calls, :original_method
|
9
|
-
def initialize(object, method_name)
|
10
|
-
@base_object, @method_name = object, method_name
|
11
|
-
reset!
|
12
|
-
end
|
13
|
-
|
14
|
-
# hooks the method into the object and stashes original method if it exists
|
15
|
-
# @param opts [Hash{force => false, visibility => nil}] set :force => true if you want it to ignore if the method exists, or visibility to [:public, :protected, :private] to overwride current visibility
|
16
|
-
# @return self
|
17
|
-
def hook(opts = {})
|
18
|
-
raise "#{method_name} method has already been hooked" if hooked?
|
19
|
-
raise "#{base_object} method '#{method_name}' has already been hooked" if self.class.get(base_object, method_name)
|
20
|
-
opts[:force] ||= base_object.is_a?(Double)
|
21
|
-
if base_object.respond_to?(method_name, true) || !opts[:force]
|
22
|
-
@original_method = base_object.method(method_name)
|
23
|
-
end
|
5
|
+
require "spy/nest"
|
6
|
+
require "spy/subroutine"
|
7
|
+
require "spy/version"
|
24
8
|
|
25
|
-
|
9
|
+
module Spy
|
10
|
+
SECRET_SPY_KEY = Object.new
|
11
|
+
class << self
|
12
|
+
# create a spy on given object
|
13
|
+
# @param base_object
|
14
|
+
# @param method_names *[Hash,Symbol] will spy on these methods and also set default return values
|
15
|
+
# @return [Subroutine, Array<Subroutine>]
|
16
|
+
def on(base_object, *method_names)
|
17
|
+
spies = method_names.map do |method_name|
|
18
|
+
create_and_hook_spy(base_object, method_name)
|
19
|
+
end.flatten
|
26
20
|
|
27
|
-
|
28
|
-
base_object.singleton_class.send(:remove_method, method_name)
|
21
|
+
spies.size > 1 ? spies : spies.first
|
29
22
|
end
|
30
23
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
24
|
+
# removes the spy from the from the given object
|
25
|
+
# @param base_object
|
26
|
+
# @param method_names *[Symbol]
|
27
|
+
# @return [Subroutine, Array<Subroutine>]
|
28
|
+
def off(base_object, *method_names)
|
29
|
+
removed_spies = method_names.map do |method_name|
|
30
|
+
spy = Subroutine.get(base_object, method_name)
|
31
|
+
if spy
|
32
|
+
spy.unhook
|
33
|
+
else
|
34
|
+
raise "Spy was not found"
|
35
|
+
end
|
37
36
|
end
|
38
|
-
end
|
39
|
-
|
40
|
-
base_object.singleton_class.send(opts[:visibility], method_name) if opts[:visibility]
|
41
37
|
|
42
|
-
|
43
|
-
self
|
44
|
-
end
|
45
|
-
|
46
|
-
# unhooks method from object
|
47
|
-
# @return self
|
48
|
-
def unhook
|
49
|
-
raise "#{method_name} method has not been hooked" unless hooked?
|
50
|
-
base_object.singleton_class.send(:remove_method, method_name)
|
51
|
-
if original_method && original_method.owner == base_object.singleton_class
|
52
|
-
base_object.define_singleton_method(method_name, original_method)
|
53
|
-
base_object.singleton_class.send(method_visibility, method_name) if method_visibility
|
54
|
-
end
|
55
|
-
clear_method!
|
56
|
-
self
|
57
|
-
end
|
58
|
-
|
59
|
-
# is the spy hooked?
|
60
|
-
# @return Boolean
|
61
|
-
def hooked?
|
62
|
-
@hooked
|
63
|
-
end
|
64
|
-
|
65
|
-
|
66
|
-
# sets the return value of given spied method
|
67
|
-
# @params return value
|
68
|
-
# @params return block
|
69
|
-
# @return self
|
70
|
-
def and_return(value = nil, &block)
|
71
|
-
if block_given?
|
72
|
-
raise ArgumentError.new("value and block conflict. Choose one") if !value.nil?
|
73
|
-
@plan = block
|
74
|
-
else
|
75
|
-
@plan = Proc.new { value }
|
38
|
+
removed_spies.size > 1 ? removed_spies : removed_spies.first
|
76
39
|
end
|
77
|
-
self
|
78
|
-
end
|
79
40
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
base_object.send(:method_missing, method_name, *args, &block)
|
41
|
+
# create a stub for constants on given module
|
42
|
+
# @param base_module [Module]
|
43
|
+
# @param constant_names *[Symbol, Hash]
|
44
|
+
# @return [Constant, Array<Constant>]
|
45
|
+
def on_const(base_module, *constant_names)
|
46
|
+
if base_module.is_a? Symbol
|
47
|
+
constant_names.unshift(base_module)
|
48
|
+
base_module = Object
|
89
49
|
end
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
# check if the method was called with the exact arguments
|
99
|
-
def has_been_called_with?(*args)
|
100
|
-
calls.any? do |call_log|
|
101
|
-
call_log.args == args
|
102
|
-
end
|
103
|
-
end
|
104
|
-
|
105
|
-
# record that the method has been called. You really shouldn't use this
|
106
|
-
# method.
|
107
|
-
def record(object, args, block)
|
108
|
-
check_arity!(args.size)
|
109
|
-
calls << CallLog.new(object, args, block)
|
110
|
-
@plan.call(*args, &block) if @plan
|
111
|
-
end
|
112
|
-
|
113
|
-
# reset the call log
|
114
|
-
def reset!
|
115
|
-
@calls = []
|
116
|
-
clear_method!
|
117
|
-
true
|
118
|
-
end
|
119
|
-
|
120
|
-
private
|
121
|
-
|
122
|
-
def clear_method!
|
123
|
-
@hooked = false
|
124
|
-
@original_method = @arity_range = @method_visibility = nil
|
125
|
-
end
|
126
|
-
|
127
|
-
def method_visibility
|
128
|
-
@method_visibility ||=
|
129
|
-
if base_object.respond_to?(method_name)
|
130
|
-
if original_method && original_method.owner.protected_method_defined?(method_name)
|
131
|
-
:protected
|
132
|
-
else
|
133
|
-
:public
|
50
|
+
spies = constant_names.map do |constant_name|
|
51
|
+
case constant_name
|
52
|
+
when String, Symbol
|
53
|
+
Constant.on(base_module, constant_name)
|
54
|
+
when Hash
|
55
|
+
constant_name.map do |name, result|
|
56
|
+
on_const(base_module, name).and_return(result)
|
134
57
|
end
|
135
|
-
|
136
|
-
|
58
|
+
else
|
59
|
+
raise ArgumentError.new "#{constant_name.class} is an invalid input, #on only accepts String, Symbol, and Hash"
|
137
60
|
end
|
138
|
-
|
61
|
+
end.flatten
|
139
62
|
|
140
|
-
|
141
|
-
return unless arity_range
|
142
|
-
if arity < arity_range.min
|
143
|
-
raise ArgumentError.new("wrong number of arguments (#{arity} for #{arity_range.min})")
|
144
|
-
elsif arity > arity_range.max
|
145
|
-
raise ArgumentError.new("wrong number of arguments (#{arity} for #{arity_range.max})")
|
63
|
+
spies.size > 1 ? spies : spies.first
|
146
64
|
end
|
147
|
-
end
|
148
65
|
|
149
|
-
|
150
|
-
@
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
max = Float::INFINITY
|
66
|
+
# removes stubs from given module
|
67
|
+
# @param base_module [Module]
|
68
|
+
# @param constant_names *[Symbol]
|
69
|
+
# @return [Constant, Array<Constant>]
|
70
|
+
def off_const(base_module, *constant_names)
|
71
|
+
spies = constant_names.map do |constant_name|
|
72
|
+
case constant_name
|
73
|
+
when String, Symbol
|
74
|
+
Constant.off(base_module, constant_name)
|
75
|
+
when Hash
|
76
|
+
constant_name.map do |name, result|
|
77
|
+
off_const(base_module, name).and_return(result)
|
162
78
|
end
|
79
|
+
else
|
80
|
+
raise ArgumentError.new "#{constant_name.class} is an invalid input, #on only accepts String, Symbol, and Hash"
|
163
81
|
end
|
164
|
-
(min..max)
|
165
|
-
end
|
166
|
-
end
|
167
|
-
|
168
|
-
class << self
|
169
|
-
# create a spy on given object
|
170
|
-
# @params base_object
|
171
|
-
# @params method_names *[Symbol] will spy on these methods
|
172
|
-
# @params method_names [Hash] will spy on these methods and also set default return values
|
173
|
-
# @return [Spy, Array<Spy>]
|
174
|
-
def on(base_object, *method_names)
|
175
|
-
spies = method_names.map do |method_name|
|
176
|
-
create_and_hook_spy(base_object, method_name)
|
177
82
|
end.flatten
|
178
83
|
|
179
84
|
spies.size > 1 ? spies : spies.first
|
180
85
|
end
|
181
86
|
|
182
|
-
# removes the spy from the
|
183
|
-
def off(base_object, *method_names)
|
184
|
-
removed_spies = method_names.map do |method_name|
|
185
|
-
unhook_and_remove_spy(base_object, method_name)
|
186
|
-
end.flatten
|
187
|
-
|
188
|
-
raise "No spies found" if removed_spies.empty?
|
189
|
-
removed_spies.size > 1 ? removed_spies : removed_spies.first
|
190
|
-
end
|
191
|
-
|
192
|
-
# get all hooked methods
|
193
|
-
# @return [Array<Spy>]
|
194
|
-
def all
|
195
|
-
@all ||= []
|
196
|
-
end
|
197
|
-
|
198
87
|
# unhook all methods
|
199
88
|
def teardown
|
200
|
-
|
201
|
-
reset
|
202
|
-
end
|
203
|
-
|
204
|
-
# reset all hooked methods
|
205
|
-
def reset
|
206
|
-
@all = nil
|
89
|
+
Agency.instance.dissolve!
|
207
90
|
end
|
208
91
|
|
209
|
-
#
|
92
|
+
# returns a double
|
93
|
+
# (see Double#initizalize)
|
210
94
|
def double(*args)
|
211
95
|
Double.new(*args)
|
212
96
|
end
|
213
97
|
|
214
|
-
# @private
|
215
|
-
def __secret_method_key__
|
216
|
-
@__secret_method_key__ ||= Object.new
|
217
|
-
end
|
218
|
-
|
219
98
|
# retrieve the spy from an object
|
220
|
-
# @
|
221
|
-
# @method_names *[Symbol
|
99
|
+
# @param base_object
|
100
|
+
# @param method_names *[Symbol]
|
101
|
+
# @return [Subroutine, Array<Subroutine>]
|
222
102
|
def get(base_object, *method_names)
|
223
103
|
spies = method_names.map do |method_name|
|
224
|
-
|
225
|
-
|
226
|
-
|
104
|
+
Subroutine.get(base_object, method_name)
|
105
|
+
end
|
106
|
+
|
107
|
+
spies.size > 1 ? spies : spies.first
|
108
|
+
end
|
109
|
+
|
110
|
+
# retrieve the constant spies from an object
|
111
|
+
# @param base_module
|
112
|
+
# @param constant_names *[Symbol]
|
113
|
+
# @return [Constant, Array<Constant>]
|
114
|
+
def get_const(base_module, *constant_names)
|
115
|
+
spies = constant_names.map do |method_name|
|
116
|
+
Constant.get(base_module, constant_name)
|
227
117
|
end
|
228
118
|
|
229
119
|
spies.size > 1 ? spies : spies.first
|
@@ -234,26 +124,14 @@ class Spy
|
|
234
124
|
def create_and_hook_spy(base_object, method_name, opts = {})
|
235
125
|
case method_name
|
236
126
|
when String, Symbol
|
237
|
-
|
238
|
-
all << spy
|
239
|
-
spy
|
127
|
+
Subroutine.new(base_object, method_name).hook(opts)
|
240
128
|
when Hash
|
241
129
|
method_name.map do |name, result|
|
242
130
|
create_and_hook_spy(base_object, name, opts).and_return(result)
|
243
131
|
end
|
244
132
|
else
|
245
|
-
raise ArgumentError.new "#{method_name.class} is an invalid
|
246
|
-
end
|
247
|
-
end
|
248
|
-
|
249
|
-
def unhook_and_remove_spy(base_object, method_name)
|
250
|
-
removed_spies = []
|
251
|
-
all.delete_if do |spy|
|
252
|
-
if spy.base_object == base_object && spy.method_name == method_name
|
253
|
-
removed_spies << spy.unhook
|
254
|
-
end
|
133
|
+
raise ArgumentError.new "#{method_name.class} is an invalid input, #on only accepts String, Symbol, and Hash"
|
255
134
|
end
|
256
|
-
removed_spies
|
257
135
|
end
|
258
136
|
end
|
259
137
|
end
|