spy 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 @@
1
+ --markup=markdown
data/Gemfile CHANGED
@@ -4,3 +4,5 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
  gem 'pry'
6
6
  gem 'pry-nav'
7
+ gem 'yard'
8
+ gem 'redcarpet'
data/README.md CHANGED
@@ -1,23 +1,39 @@
1
1
  # Spy
2
2
 
3
- Spy is a lightweight doubles framework that won't let your code mock your intelligence.
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
- * Raise error when you try to stub/spy a method that doesn't exist
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
- * less need to look at test framework documentation
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 unless you want to
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
- class Person
40
- def first_name
41
- "John"
42
- end
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
- def last_name
45
- "Smith"
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
- def full_name
49
- "#{first_name} #{last_name}"
50
- end
68
+ ```ruby
69
+ Spy.new(book, :flamethrower).hook(force:true).and_return("burnninante")
70
+ ```
51
71
 
52
- def say(words)
53
- puts words
54
- end
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
- ### Standalone
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
- person = Person.new
102
+ Spy.on(book, :read_page).and_return(force: true) do |a, b, c, d|
103
+ end
104
+ ```
62
105
 
63
- first_name_spy = Spy.on(person, :first_name)
64
- person.first_name #=> nil
65
- first_name_spy.called? #=> true
106
+ ### Method Spies
66
107
 
67
- Spy.get(person, :first_name) #=> first_name_spy
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
- Spy.off(person, :first_name)
70
- person.first_name #=> "John"
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
- first_name_spy.hook #=> first_name_spy
73
- first_name_spy.and_return("Bob")
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
- Spy.teardown
77
- person.first_name #=> "John"
122
+ ```ruby
123
+ Spy.on(book, :read_page).and_call_through
124
+ ```
78
125
 
79
- say_spy = Spy.on(person, :say)
80
- person.say("hello") {
81
- "everything accepts a block in ruby"
82
- }
83
- say_spy.say("world")
126
+ ### Call Logs
84
127
 
85
- say_spy.called_with?("hello") #=> true
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
- call_log = say_spy.calls.first
91
- call_log.object #=> #<Person:0x00000000b2b858>
92
- call_log.args #=> ["hello"]
93
- call_log.block #=> #<Proc:0x00000000b1a9e0>
94
- call_log.block.call #=> "everything accepts a block in ruby"
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/version"
1
+ require "spy/core_ext/marshal"
2
+ require "spy/agency"
3
+ require "spy/constant"
2
4
  require "spy/double"
3
- require "spy/dsl"
4
-
5
- class Spy
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
- opts[:visibility] ||= method_visibility
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
- if original_method && original_method.owner == base_object.singleton_class
28
- base_object.singleton_class.send(:remove_method, method_name)
21
+ spies.size > 1 ? spies : spies.first
29
22
  end
30
23
 
31
- __method_spy__ = self
32
- base_object.define_singleton_method(method_name) do |*__spy_args, &block|
33
- if __spy_args.first === __method_spy__.class.__secret_method_key__
34
- __method_spy__
35
- else
36
- __method_spy__.record(self,__spy_args,block)
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
- @hooked = true
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
- # tells the spy to call the original method
81
- # @return self
82
- def and_call_through
83
- raise "can only call through if original method is set" unless method_visibility
84
- if original_method
85
- @plan = original_method
86
- else
87
- @plan = Proc.new do |*args, &block|
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
- end
91
- self
92
- end
93
-
94
- def has_been_called?
95
- calls.size > 0
96
- end
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
- elsif base_object.respond_to?(method_name, true)
136
- :private
58
+ else
59
+ raise ArgumentError.new "#{constant_name.class} is an invalid input, #on only accepts String, Symbol, and Hash"
137
60
  end
138
- end
61
+ end.flatten
139
62
 
140
- def check_arity!(arity)
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
- def arity_range
150
- @arity_range ||=
151
- if original_method
152
- min = max = 0
153
- original_method.parameters.each do |type,_|
154
- case type
155
- when :req
156
- min += 1
157
- max += 1
158
- when :opt
159
- max += 1
160
- when :rest
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
- all.each(&:unhook)
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
- # (see Double#new)
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
- # @params base_object
221
- # @method_names *[Symbol, Hash]
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
- if base_object.singleton_methods.include?(method_name.to_sym) && base_object.method(method_name).parameters == [[:rest, :__spy_args], [:block, :block]]
225
- base_object.send(method_name, __secret_method_key__)
226
- end
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
- spy = new(base_object, method_name).hook(opts)
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 class, #on only accepts String, Symbol, and Hash"
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