├── .gitignore ├── .travis.yml ├── Gemfile ├── Guardfile ├── README.md ├── Rakefile ├── lib ├── spy.rb └── spy │ ├── api.rb │ ├── blueprint.rb │ ├── core.rb │ ├── errors.rb │ ├── fake_method.rb │ ├── instance.rb │ ├── method_call.rb │ ├── multi.rb │ ├── registry.rb │ ├── replace_method.rb │ ├── strategy │ ├── intercept.rb │ └── wrap.rb │ └── version.rb ├── spy_rb.gemspec └── test ├── before_after_test.rb ├── block_test.rb ├── call_history_test.rb ├── called_test.rb ├── class_test.rb ├── dynamic_delegation_test.rb ├── instead_test.rb ├── multi_test.rb ├── name_test.rb ├── object_test.rb ├── replay_test.rb ├── smoke_test.rb ├── spy_test.rb ├── test_helper.rb └── wrap_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .ruby-version 2 | Gemfile.lock 3 | *.gem 4 | coverage 5 | .bundle/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - "2.5.1" 5 | - "2.4.4" 6 | - "2.3.7" 7 | - "2.2.10" 8 | - "2.2.0" 9 | - "2.1.10" 10 | - "2.1.5" 11 | - "2.1.3" 12 | - "2.0.0" 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'coveralls', require: false 6 | gem 'minitest-tagz', require: false 7 | gem 'pry-byebug', require: false 8 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :minitest do 2 | watch(%r{^test/(.*)_test.rb$}) { |m| "test/#{m[1]}_test.rb" } 3 | watch(%r{^lib/.*\.rb$}) { 'test' } 4 | watch(%r{^test/test_helper\.rb$}) { 'test' } 5 | end 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spy 2 | 3 | [![Travis Status](https://travis-ci.org/jbodah/spy_rb.svg?branch=master)](https://travis-ci.org/jbodah/spy_rb) 4 | [![Coverage Status](https://coveralls.io/repos/jbodah/spy_rb/badge.svg?branch=master)](https://coveralls.io/r/jbodah/spy_rb?branch=master) 5 | [![Code Climate](https://codeclimate.com/github/jbodah/spy_rb/badges/gpa.svg)](https://codeclimate.com/github/jbodah/spy_rb) 6 | [![Gem Version](https://badge.fury.io/rb/spy_rb.svg)](http://badge.fury.io/rb/spy_rb) 7 | 8 | Transparent Test Spies for Ruby 9 | 10 | ## Description 11 | 12 | Mocking frameworks work by stubbing out functionality. Spy works by listening in on functionality and allowing it to run in the background. Spy is designed to be lightweight and work alongside Mocking frameworks instead of trying to replace them entirely. 13 | 14 | ## Why Spy? 15 | 16 | * Less intrusive than mocking 17 | * Allows you to test message passing without relying on stubbing 18 | * Great for testing recursive methods or methods with side effects (e.g. test that something is cached and only hits the database once on the intitial call) 19 | * Works for Ruby 2.x 20 | * Small and simple 21 | * Strong test coverage 22 | * No `alias_method` pollution 23 | * No dependencies! 24 | 25 | ## Install 26 | 27 | ``` 28 | gem install spy_rb 29 | ``` 30 | 31 | ## Usage 32 | 33 | ### Spy::API 34 | 35 | [Spy::API](https://github.com/jbodah/spy_rb/blob/master/lib/spy/api.rb) defines the top-level interface for creating spies and for interacting with them globally. 36 | 37 | You can use it to create spies in a variety of ways. For these example we'll use the `Fruit` class because, seriously, who doesn't love fruit: 38 | 39 | ```rb 40 | class Fruit 41 | def eat(adj) 42 | puts "you take a bite #{adj}" 43 | end 44 | end 45 | 46 | require 'spy' 47 | 48 | # Spy on singleton or bound methods 49 | fruit = Fruit.new 50 | s = Spy.on(fruit, :to_s) 51 | fruit.to_s 52 | s.call_count 53 | #=> 1 54 | 55 | s = Spy.on(Fruit, :to_s) 56 | Fruit.to_s 57 | s.call_count 58 | #=> 1 59 | 60 | # Spy on instance methods 61 | s = Spy.on_any_instance(Fruit, :to_s) 62 | apple = Fruit.new 63 | apple.to_s 64 | orange = Fruit.new 65 | orange.to_s 66 | s.call_count 67 | #=> 2 68 | 69 | # Spied methods respect visibility 70 | Object.private_methods.include?(:fork) 71 | #=> true 72 | Spy.on(Object, :fork) 73 | Object.fork 74 | #=> NoMethodError: private method `fork' called for Object:Class 75 | 76 | # Spy will let you know if you're doing something wrong too 77 | Spy.on(Object, :doesnt_exist) 78 | #=> NameError: undefined method `doesnt_exist' for class `Class' 79 | 80 | Spy.on(Fruit, :to_s) 81 | => #, @visibility=:public, @conditional_filters=[], @before_callbacks=[], @after_callbacks=[], @around_procs=[], @call_history=[], @strategy=#, @intercept_target=#>> 82 | 83 | Spy.on(Fruit, :to_s) 84 | #=> Spy::Errors::AlreadySpiedError: Spy::Errors::AlreadySpiedError 85 | 86 | # Spy on all of the methods of an object (also see Spy.on_class) 87 | s = Spy.on_object(fruit) 88 | fruit.to_s 89 | s.call_count 90 | #=> 1 91 | s[:to_s].call_count 92 | #=> 1 93 | ``` 94 | 95 | When you're all finished you'll want to restore your methods to clean up the spies: 96 | 97 | ```rb 98 | # Restore singleton/bound method 99 | s = Spy.on(Object, :to_s) 100 | Spy.restore(Object, :to_s) 101 | 102 | # Restore instance method 103 | s = Spy.on_any_instance(Object, :to_s) 104 | Spy.restore(Object, :to_s, :instance_method) 105 | 106 | # Restore method_missing-style delegation 107 | Spy.restore(Object, :message, :dynamic_delegation) 108 | 109 | # Global restore 110 | s = Spy.on(Object, :to_s) 111 | Spy.restore(:all) 112 | ``` 113 | 114 | If you're using spy_rb in the context of a test suite, you may want to patch a `Spy.restore(:all)` into your teardowns: 115 | 116 | ```rb 117 | class ActiveSupport::TestCase 118 | teardown do 119 | Spy.restore(:all) 120 | end 121 | end 122 | ``` 123 | 124 | ### Spy::Instance 125 | 126 | Once you've created a spy instance, then there are a variety of ways to interact with that spy. 127 | See [Spy::Instance](https://github.com/jbodah/spy_rb/tree/master/lib/spy/instance.rb) for the full list of supported methods. 128 | 129 | `Spy::Instance#call_count` will tell you how many times the spied method was called: 130 | 131 | ```rb 132 | fruit = Fruit.new 133 | spy = Spy.on(fruit, :eat) 134 | fruit.eat(:slowly) 135 | spy.call_count 136 | #=> 1 137 | 138 | fruit.eat(:quickly) 139 | spy.call_count 140 | #=> 2 141 | ``` 142 | 143 | `Spy::Instance#when` lets you specify conditions as to when the spy should track calls: 144 | 145 | ```rb 146 | fruit = Fruit.new 147 | spy = Spy.on(fruit, :eat) 148 | spy.when {|method_call| method_call.args.first == :quickly} 149 | fruit.eat(:slowly) 150 | spy.call_count 151 | #=> 0 152 | 153 | fruit.eat(:quickly) 154 | spy.call_count 155 | #=> 1 156 | ``` 157 | 158 | `Spy::Instance#before` and `Spy::Instance#after` give you callbacks for your spy: 159 | 160 | ```rb 161 | fruit = Fruit.new 162 | spy = Spy.on(fruit, :eat) 163 | spy.before { puts 'you wash your hands' } 164 | spy.after { puts 'you rejoice in your triumph' } 165 | fruit.eat(:happily) 166 | #=> you wash your hands 167 | #=> you take a bite happily 168 | #=> you rejoice in your triumph 169 | 170 | # #before and #after can both accept arguments just like #when 171 | ``` 172 | 173 | `Spy::Instance#wrap` allows you to do so more complex things. Be sure to call the original block though! You don't have to worry about passing args to the original. 174 | Those are wrapped up for you; you just need to `#call` it. 175 | 176 | ```rb 177 | require 'benchmark' 178 | fruit = Fruit.new 179 | spy = Spy.on(fruit, :eat) 180 | spy.wrap do |method_call, &original| 181 | puts Benchmark.measure { original.call } 182 | end 183 | fruit.eat(:hungrily) 184 | #=> you take a bite hungrily 185 | #=> 0.000000 0.000000 0.000000 ( 0.000039) 186 | ``` 187 | 188 | `Spy::Instance#instead` lets you emulate stubbing: 189 | 190 | ```rb 191 | fruit = Fruit.new 192 | spy = Spy.on(fruit, :eat) 193 | spy.instead { puts "taking a nap" } 194 | fruit.eat(:hungrily) 195 | #=> taking a nap 196 | ``` 197 | 198 | `Spy::Instance#call_history` keeps track of all of your calls for you. It returns a list of `Spy::MethodCall` objects which give you even more rich features: 199 | 200 | ```rb 201 | fruit = Fruit.new 202 | spy = Spy.on(fruit, :eat) 203 | fruit.eat(:like_a_boss) 204 | fruit.eat(:on_a_boat) 205 | spy.call_history 206 | #=> [ 207 | #, @name=:eat, @receiver=#, @args=[:like_a_boss], @result=nil>, 208 | #, @name=:eat, @receiver=#, @args=[:on_a_boat], @result=nil> 209 | ] 210 | ``` 211 | 212 | ### Spy::MethodCall 213 | 214 | `Spy::MethodCall` has a bunch of useful attributes like `#receiver`, `#args`, `#caller`, `#block`, `#name`, and `#result`. 215 | Right now `Spy::MethodCall` does not deep copy args or results, so be careful! 216 | 217 | `Spy::MethodCall` also has the experimental feature `#replay` which can be used interactively for debugging: 218 | 219 | ```rb 220 | fruit = Fruit.new 221 | spy = Spy.on(fruit, :eat) 222 | fruit.eat(:quickly) 223 | #=> you take a bite quickly 224 | 225 | spy.call_history[0].replay 226 | #=> you take a bite quickly 227 | 228 | spy.call_count 229 | #=> 1 230 | ``` 231 | 232 | Additionally, if you're adventurous you can give `Spy::Instance#replay_all` a shot: 233 | 234 | ```rb 235 | fruit = Fruit.new 236 | spy = Spy.on(fruit, :eat) 237 | fruit.eat(:quickly) 238 | #=> you take a bite quickly 239 | 240 | fruit.eat(:slowly) 241 | #=> you take a bite slowly 242 | 243 | spy.call_count 244 | #=> 2 245 | 246 | spy.replay_all 247 | #=> you take a bite quickly 248 | #=> you take a bite slowly 249 | 250 | spy.call_count 251 | #=> 2 252 | ``` 253 | 254 | ## Deploying (note to self) 255 | 256 | ```sh 257 | rake full_deploy TO=0.2.1 258 | ``` 259 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.libs << 'test' 5 | t.test_files = FileList['test/*test.rb'] 6 | t.verbose = true 7 | end 8 | 9 | task :default => [:test] 10 | 11 | desc 'rm all *.gem files' 12 | task :clean do 13 | require 'fileutils' 14 | FileUtils.rm Dir.glob('*.gem') 15 | end 16 | 17 | desc 'build gem' 18 | task :build do 19 | gemspec = Dir.glob('*.gemspec').first 20 | system "gem build #{gemspec}" 21 | end 22 | 23 | desc 'install local gem' 24 | task :install_local do 25 | gem = Dir.glob('*.gem').first 26 | system "gem install #{gem} --local" 27 | end 28 | 29 | desc 'build gem and push it to rubygems' 30 | task :deploy => [:clean, :build] do 31 | gem = Dir.glob('*.gem').first 32 | system "gem push #{gem}" 33 | end 34 | 35 | desc 'runs through entire deploy process' 36 | task :full_deploy => [:test, :change_version] do 37 | system 'git push && git push --tags' 38 | Rake::Task['deploy'].invoke 39 | end 40 | 41 | task :change_version do 42 | raise "Version required: ENV['TO']" unless ENV['TO'] 43 | 44 | puts 'Checking for existing tag' 45 | raise "Tag '#{ENV['TO']}' already exists!" unless `git tag -l $TO`.empty? 46 | 47 | puts "Updating version.rb to '#{ENV['TO']}'" 48 | version_file = 'lib/spy/version.rb' 49 | before_text = File.read(version_file) 50 | text = before_text.gsub(/[\d\.]+/, ENV['TO']) 51 | raise "Aborting: Version didn't change" if text == before_text 52 | File.open(version_file, 'w') { |f| f.puts text } 53 | 54 | puts 'Committing version.rb' 55 | exit(1) unless system 'git add lib/spy/version.rb' 56 | exit(1) unless system "git commit -m 'bump to version #{ENV['TO']}'" 57 | exit(1) unless system "git tag #{ENV['TO']}" 58 | 59 | puts "Tag '#{ENV['TO']}' generated. Don't forget to push --tags! :)" 60 | end 61 | -------------------------------------------------------------------------------- /lib/spy.rb: -------------------------------------------------------------------------------- 1 | require 'spy/version' 2 | require 'spy/api' 3 | 4 | # Top-level module that implements the Spy::API 5 | # 6 | # Spy::API was pulled out to make it easy to create multiple 7 | # different modules that implement Spy::API (which effectively 8 | # namespaces the spies) 9 | module Spy 10 | extend API 11 | end 12 | -------------------------------------------------------------------------------- /lib/spy/api.rb: -------------------------------------------------------------------------------- 1 | require 'spy/core' 2 | require 'spy/blueprint' 3 | 4 | module Spy 5 | # The core module that users will interface. `Spy::API` is implemented 6 | # in a module via `::extend`: 7 | # 8 | # MySpy.exted Spy::API 9 | # spy = MySpy.on(Object, :name) 10 | # 11 | # By default `Spy` implements `Spy::API` 12 | # 13 | # `Spy::API` is primarily responsible for maps user arguments into 14 | # a format that `Spy::Core` can understand 15 | # 16 | # See `Spy::Instance` for the API for interacting with individual spies 17 | module API 18 | # Spies on calls to a method made on a target object 19 | # 20 | # @param [Object] target - the object you want to spy on 21 | # @param [Symbol] msg - the name of the method to spy on 22 | # @returns [Spy::Instance] 23 | def on(target, msg) 24 | if target.methods.include?(msg) 25 | core.add_spy(Blueprint.new(target, msg, :method)) 26 | elsif target.respond_to?(msg) 27 | core.add_spy(Blueprint.new(target, msg, :dynamic_delegation)) 28 | else 29 | raise ArgumentError 30 | end 31 | end 32 | 33 | # Spies on calls to a method made on any instance of some class or module 34 | # 35 | # @param target - class or module to spy on 36 | # @param msg - name of the method to spy on 37 | # @returns [Spy::Instance] 38 | def on_any_instance(target, msg) 39 | raise ArgumentError unless target.respond_to?(:instance_method) 40 | core.add_spy(Blueprint.new(target, msg, :instance_method)) 41 | end 42 | 43 | # Spies on all of the calls made to the given object 44 | # 45 | # @param object - the thing to spy on 46 | # @returns [Spy::Multi] 47 | def on_object(object) 48 | core.add_multi_spy(Blueprint.new(object, :all, :methods)) 49 | end 50 | 51 | # Spies on all of the calls made to the given class or module 52 | # 53 | # @param klass - the thing to spy on 54 | # @returns [Spy::Multi] 55 | def on_class(klass) 56 | core.add_multi_spy(Blueprint.new(klass, :all, :instance_methods)) 57 | end 58 | 59 | # Stops spying on the method and restores its original functionality 60 | # 61 | # @example stop spying on every spied message 62 | # 63 | # Spy.restore(:all) 64 | # 65 | # @example stop spying on the given receiver and message 66 | # 67 | # Spy.restore(receiver, msg) 68 | # 69 | # @example stop spying on the given object, message, and type (e.g. :method, :instance_method, :dynamic_delegation) 70 | # 71 | # Spy.restore(object, msg, type) 72 | # 73 | # @param args - supports multiple signatures 74 | def restore(*args) 75 | case args.length 76 | when 1 77 | core.remove_all_spies if args.first == :all 78 | when 2 79 | target, msg = *args 80 | core.remove_spy(Blueprint.new(target, msg, :method)) 81 | when 3 82 | target, msg, type = *args 83 | core.remove_spy(Blueprint.new(target, msg, type)) 84 | else 85 | raise ArgumentError 86 | end 87 | end 88 | 89 | private 90 | 91 | def core 92 | @core ||= Core.new 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/spy/blueprint.rb: -------------------------------------------------------------------------------- 1 | module Spy 2 | class Blueprint 3 | attr_reader :target, :msg, :type 4 | 5 | def initialize(target, msg, type) 6 | @target = target 7 | @msg = msg 8 | @type = type 9 | @caller = _caller 10 | end 11 | 12 | alias :_caller :caller 13 | 14 | def caller 15 | @caller 16 | end 17 | 18 | def to_s 19 | [@target.object_id, @msg, @type].join("|") 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/spy/core.rb: -------------------------------------------------------------------------------- 1 | require 'spy/instance' 2 | require 'spy/registry' 3 | require 'spy/multi' 4 | require 'spy/errors' 5 | 6 | module Spy 7 | # The main internal API. This is used directly by `Spy::API` and 8 | # is the primary control center for creating and removing spies. 9 | # 10 | # Syntactic sugar (like `Spy.restore(object, msg)` vs `Spy.restore(:all)`) 11 | # should be handled in `Spy::API` and utilize `Spy::Core` 12 | class Core 13 | UNSAFE_METHODS = [:object_id, :__send__, :__id__, :method, :singleton_class] 14 | 15 | def initialize 16 | @registry = Registry.new 17 | end 18 | 19 | # Start spying on the given object and method 20 | # 21 | # @param [Spy::Blueprint] blueprint - data for building the spy 22 | # @returns [Spy::Instance] 23 | # @raises [Spy::Errors::AlreadySpiedError] if the method is already 24 | # being spied on 25 | def add_spy(blueprint) 26 | if prev = @registry.get(blueprint) 27 | raise Errors::AlreadySpiedError.new("Already spied on #{blueprint} here:\n\t#{prev[0].caller.join("\n\t")}") 28 | end 29 | spy = Instance.new(blueprint) 30 | @registry.insert(blueprint, spy) 31 | spy.start 32 | end 33 | 34 | # Start spying on all of the given objects and methods 35 | # 36 | # @param [Spy::Blueprint] blueprint - data for building the spy 37 | # @returns [Spy::Multi] 38 | def add_multi_spy(multi_blueprint) 39 | target = multi_blueprint.target 40 | type = multi_blueprint.type 41 | methods = target.public_send(type).reject(&method(:unsafe_method?)) 42 | spies = methods.map do |method_name| 43 | singular_type = type.to_s.sub(/s$/, '').to_sym 44 | add_spy(Blueprint.new(multi_blueprint.target, method_name, singular_type)) 45 | end 46 | Multi.new(spies) 47 | end 48 | 49 | # Stop spying on the given object and method 50 | # 51 | # @raises [Spy::Errors::MethodNotSpiedError] if the method is not already 52 | # being spied on 53 | def remove_spy(blueprint) 54 | spy = @registry.remove(blueprint) 55 | spy.stop 56 | end 57 | 58 | # Stops spying on all objects and methods 59 | def remove_all_spies 60 | @registry.remove_all.each(&:stop) 61 | end 62 | 63 | private 64 | 65 | def unsafe_method?(name) 66 | UNSAFE_METHODS.include?(name) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/spy/errors.rb: -------------------------------------------------------------------------------- 1 | module Spy 2 | module Errors 3 | Error = Class.new(StandardError) 4 | MethodNotSpiedError = Class.new(Spy::Errors::Error) 5 | AlreadySpiedError = Class.new(Spy::Errors::Error) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/spy/fake_method.rb: -------------------------------------------------------------------------------- 1 | module Spy 2 | class FakeMethod 3 | attr_reader :name 4 | 5 | def initialize(name, &block) 6 | @name = name 7 | @block = block 8 | end 9 | 10 | def call(*args, &block) 11 | @block.call(*args, &block) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/spy/instance.rb: -------------------------------------------------------------------------------- 1 | require 'spy/fake_method' 2 | require 'spy/strategy/wrap' 3 | require 'spy/strategy/intercept' 4 | 5 | # An instance of a spied method 6 | # - Holds a reference to the original method 7 | # - Wraps the original method 8 | # - Provides hooks for callbacks 9 | module Spy 10 | class Instance 11 | attr_reader :original, :spied, :strategy, :call_history 12 | 13 | def initialize(blueprint) 14 | original = 15 | case blueprint.type 16 | when :dynamic_delegation 17 | FakeMethod.new(blueprint.msg) { |*args, &block| blueprint.target.method_missing(blueprint.msg, *args, &block) } 18 | when :instance_method 19 | blueprint.target.instance_method(blueprint.msg) 20 | else 21 | blueprint.target.method(blueprint.msg) 22 | end 23 | 24 | @original = original 25 | @spied = blueprint.target 26 | @strategy = choose_strategy(blueprint) 27 | @call_history = [] 28 | 29 | @internal = {} 30 | @internal[:conditional_filters] = [] 31 | @internal[:before_callbacks] = [] 32 | @internal[:after_callbacks]= [] 33 | @internal[:around_procs] = [] 34 | @internal[:instead]= nil 35 | end 36 | 37 | def name 38 | @original.name 39 | end 40 | 41 | def call_count 42 | @call_history.size 43 | end 44 | 45 | def replay_all 46 | @call_history.map(&:replay) 47 | end 48 | 49 | def start 50 | @strategy.apply 51 | self 52 | end 53 | 54 | def stop 55 | @strategy.undo 56 | self 57 | end 58 | 59 | def when(&block) 60 | @internal[:conditional_filters] << block 61 | self 62 | end 63 | 64 | # Expect block to yield. Call the rest of the chain 65 | # when it does 66 | def wrap(&block) 67 | @internal[:around_procs] << block 68 | self 69 | end 70 | 71 | def before(&block) 72 | @internal[:before_callbacks] << block 73 | self 74 | end 75 | 76 | def after(&block) 77 | @internal[:after_callbacks] << block 78 | self 79 | end 80 | 81 | def instead(&block) 82 | @internal[:instead] = block 83 | self 84 | end 85 | 86 | def called? 87 | @call_history.any? 88 | end 89 | 90 | # @private 91 | def call_original(*args) 92 | if original.is_a?(UnboundMethod) 93 | call_original_unbound_method(*args) 94 | else 95 | call_original_method(*args) 96 | end 97 | end 98 | 99 | # @private 100 | def apply(method_call) 101 | return method_call.call_original unless passes_all_conditions?(method_call) 102 | 103 | result = nil 104 | runner = 105 | if @internal[:instead] 106 | proc do 107 | @call_history << method_call 108 | result = @internal[:instead].call(method_call) 109 | end 110 | else 111 | proc do 112 | @call_history << method_call 113 | result = method_call.call_original(true) 114 | end 115 | end 116 | 117 | if @internal[:around_procs].any? 118 | runner = @internal[:around_procs].reduce(runner) do |p, wrapper| 119 | proc { wrapper[method_call, &p] } 120 | end 121 | end 122 | 123 | run_before_callbacks(method_call) 124 | 125 | runner.call 126 | 127 | run_after_callbacks(method_call) 128 | 129 | result 130 | end 131 | 132 | private 133 | 134 | def passes_all_conditions?(method_call) 135 | @internal[:conditional_filters].all? { |f| f[method_call] } 136 | end 137 | 138 | def run_before_callbacks(method_call) 139 | @internal[:before_callbacks].each { |f| f[method_call] } 140 | end 141 | 142 | def run_after_callbacks(method_call) 143 | @internal[:after_callbacks].each { |f| f[method_call] } 144 | end 145 | 146 | def call_original_unbound_method(receiver, args, block) 147 | original.bind(receiver).call(*args, &block) 148 | end 149 | 150 | def call_original_method(_receiver, args, block) 151 | original.call(*args, &block) 152 | end 153 | 154 | def choose_strategy(blueprint) 155 | if blueprint.type == :dynamic_delegation 156 | Strategy::Intercept.new(self) 157 | elsif @original.owner == @spied || @original.owner == @spied.singleton_class 158 | Strategy::Wrap.new(self) 159 | else 160 | Strategy::Intercept.new(self) 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/spy/method_call.rb: -------------------------------------------------------------------------------- 1 | module Spy 2 | class MethodCall 3 | attr_reader :receiver, :backtrace, :args, :block, :result, :spy 4 | 5 | def initialize(spy, receiver, args, block, backtrace) 6 | @spy = spy 7 | @receiver = receiver 8 | @args = args 9 | @block = block 10 | @backtrace = backtrace 11 | end 12 | 13 | def name 14 | @spy.original.name 15 | end 16 | 17 | def call_original(persist_result = false) 18 | result = @spy.call_original(@receiver, @args, @block) 19 | @result = result if persist_result 20 | result 21 | end 22 | 23 | alias replay call_original 24 | alias caller backtrace 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/spy/multi.rb: -------------------------------------------------------------------------------- 1 | module Spy 2 | class Multi 3 | attr_reader :spies 4 | 5 | def initialize(spies) 6 | @spies = spies 7 | end 8 | 9 | def call_count 10 | @spies.map(&:call_count).reduce(&:+) 11 | end 12 | 13 | def [](name) 14 | @spies.find { |spy| spy.name == name } 15 | end 16 | 17 | def called 18 | @spies.select { |spy| spy.call_count > 0 } 19 | end 20 | 21 | def uncalled 22 | @spies.select { |spy| spy.call_count == 0 } 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/spy/registry.rb: -------------------------------------------------------------------------------- 1 | require 'spy/errors' 2 | 3 | module Spy 4 | # Responsible for managing the top-level state of which spies exist. 5 | class Registry 6 | def initialize 7 | @store = {} 8 | end 9 | 10 | # Keeps track of the spy for later management. Ensures spy uniqueness 11 | # 12 | # @param [Spy::Blueprint] 13 | # @param [Spy::Instance] spy - the instantiated spy 14 | # @raises [Spy::Errors::AlreadySpiedError] if the spy is already being 15 | # tracked 16 | def insert(blueprint, spy) 17 | key = blueprint.to_s 18 | raise Errors::AlreadySpiedError if @store[key] 19 | @store[key] = [blueprint, spy] 20 | end 21 | 22 | # Stops tracking the spy 23 | # 24 | # @param [Spy::Blueprint] 25 | # @raises [Spy::Errors::MethodNotSpiedError] if the spy isn't being tracked 26 | def remove(blueprint) 27 | key = blueprint.to_s 28 | raise Errors::MethodNotSpiedError unless @store[key] 29 | @store.delete(key)[1] 30 | end 31 | 32 | # Stops tracking all spies 33 | def remove_all 34 | store = @store 35 | @store = {} 36 | store.values.map(&:last) 37 | end 38 | 39 | def get(blueprint) 40 | key = blueprint.to_s 41 | @store[key] 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/spy/replace_method.rb: -------------------------------------------------------------------------------- 1 | require 'spy/method_call' 2 | 3 | module Spy 4 | module ReplaceMethod 5 | def self.call(klass, spy, mode: nil, remove_existing: false) 6 | klass.class_eval do 7 | name = spy.original.name 8 | 9 | remove_method(name) if remove_existing 10 | 11 | case mode 12 | when :stub 13 | define_method(name, ReplaceMethod.impl(spy)) 14 | when :restore 15 | define_method(name, spy.original) 16 | end 17 | end 18 | end 19 | 20 | def self.impl(spy) 21 | proc do |*args, &block| 22 | backtrace = caller.drop_while { |path| path =~ /lib\/spy\/replace_method\.rb$/ } 23 | method_call = MethodCall.new(spy, self, args, block, backtrace) 24 | spy.apply(method_call) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/spy/strategy/intercept.rb: -------------------------------------------------------------------------------- 1 | require 'spy/replace_method' 2 | 3 | module Spy 4 | module Strategy 5 | class Intercept 6 | def initialize(spy) 7 | @spy = spy 8 | @target = 9 | case spy.original 10 | when Method 11 | spy.spied.singleton_class 12 | when UnboundMethod 13 | spy.spied 14 | when FakeMethod 15 | spy.spied.singleton_class 16 | end 17 | end 18 | 19 | def apply 20 | ReplaceMethod.call(@target, @spy, mode: :stub) 21 | end 22 | 23 | def undo 24 | ReplaceMethod.call(@target, @spy, remove_existing: true) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/spy/strategy/wrap.rb: -------------------------------------------------------------------------------- 1 | require 'spy/replace_method' 2 | 3 | module Spy 4 | module Strategy 5 | class Wrap 6 | def initialize(spy) 7 | @spy = spy 8 | end 9 | 10 | def apply 11 | ReplaceMethod.call(@spy.original.owner, @spy, mode: :stub) 12 | end 13 | 14 | def undo 15 | ReplaceMethod.call(@spy.original.owner, @spy, mode: :restore) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/spy/version.rb: -------------------------------------------------------------------------------- 1 | module Spy 2 | VERSION = '4.2.0' 3 | end 4 | -------------------------------------------------------------------------------- /spy_rb.gemspec: -------------------------------------------------------------------------------- 1 | $: << File.expand_path('../lib', __FILE__) 2 | require 'spy/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'spy_rb' 6 | s.version = Spy::VERSION 7 | s.licenses = ['MIT'] 8 | s.summary = 'Test Spies for Ruby' 9 | s.description = "Mocking frameworks work by stubbing out functionality. Spy works by listening in on functionality and allowing it to run in the background. Spy is designed to be lightweight and work alongside Mocking frameworks instead of trying to replace them entirely." 10 | s.authors = ['Josh Bodah'] 11 | s.email = 'jb3689@yahoo.com' 12 | s.files = Dir['lib/**/*.rb'] 13 | s.homepage = 'https://github.com/jbodah/spy_rb' 14 | 15 | s.add_development_dependency 'rake' 16 | s.add_development_dependency 'minitest' 17 | end 18 | -------------------------------------------------------------------------------- /test/before_after_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BeforeAfterTest < Minitest::Spec 4 | describe 'Spy#before' do 5 | it 'is called before the original method' do 6 | arr = [] 7 | spy = Spy.on(arr, :<<) 8 | spy.before { arr.push('b') } 9 | arr << 'a' 10 | assert_equal %w(b a), arr 11 | end 12 | 13 | it 'is passed the receiver and the args of the call' do 14 | arr = [] 15 | spy = Spy.on(arr, :<<) 16 | called_args = nil 17 | spy.before do |mc| 18 | assert_equal arr, mc.receiver 19 | called_args = *mc.args 20 | end 21 | arr << 'a' 22 | assert_equal ['a'], called_args 23 | end 24 | 25 | it 'passes a Spy::MethodCall to the block' do 26 | obj = [] 27 | spy = Spy.on(obj, :<<) 28 | 29 | yielded = nil 30 | spy.before { |mc| yielded = mc } 31 | 32 | obj << 'hello' 33 | 34 | assert yielded.is_a? Spy::MethodCall 35 | end 36 | 37 | it 'has a ref to the spy instance' do 38 | obj = [] 39 | spy = Spy.on(obj, :<<) 40 | 41 | called = false 42 | spy.after { |mc| called = true; assert mc.spy.call_count == 1 } 43 | 44 | obj << 'hello' 45 | assert called 46 | end 47 | end 48 | 49 | describe 'Spy#after' do 50 | it 'is called after the original method' do 51 | arr = [] 52 | spy = Spy.on(arr, :<<) 53 | spy.after { arr.push('b') } 54 | arr << 'a' 55 | assert_equal %w(a b), arr 56 | end 57 | 58 | it 'is passed the receiver and the args of the call' do 59 | arr = [] 60 | spy = Spy.on(arr, :<<) 61 | called_args = nil 62 | spy.after do |mc| 63 | assert_equal arr, mc.receiver 64 | called_args = *mc.args 65 | end 66 | arr << 'a' 67 | assert_equal ['a'], called_args 68 | end 69 | 70 | it 'passes a Spy::MethodCall to the block' do 71 | obj = [] 72 | spy = Spy.on(obj, :<<) 73 | 74 | yielded = nil 75 | spy.after { |mc| yielded = mc } 76 | 77 | obj << 'hello' 78 | 79 | assert yielded.is_a? Spy::MethodCall 80 | end 81 | 82 | it 'has the Spy::MethodCall result field filled in' do 83 | obj = [] 84 | spy = Spy.on(obj, :<<) 85 | 86 | yielded = nil 87 | spy.after { |mc| yielded = mc } 88 | 89 | obj << 'hello' 90 | 91 | assert_equal obj, yielded.result 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/block_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BlockTest < Minitest::Spec 4 | it 'can yield a new argument to the block' do 5 | obj = Object.new 6 | obj.instance_eval do 7 | def yield_a 8 | yield "a" 9 | end 10 | end 11 | Spy.on(obj, :yield_a).instead { |method_call| method_call.block.call("b") } 12 | assert_equal "bbb", obj.yield_a { |a| a * 3 } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/call_history_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class CallHistoryTest < Minitest::Spec 4 | describe 'Spy::Instance#call_history' do 5 | it 'is empty when no calls have been made' do 6 | arr = [] 7 | spy = Spy.on(arr, :<<) 8 | assert spy.call_history.empty? 9 | end 10 | 11 | it 'has a single item when one call has been made' do 12 | arr = [] 13 | spy = Spy.on(arr, :<<) 14 | arr << 'a' 15 | assert_equal 1, spy.call_history.size 16 | end 17 | 18 | it 'has two items, ordered from first call to last, when two calls have been made' do 19 | arr = [] 20 | spy = Spy.on(arr, :<<) 21 | arr << 'a' 22 | arr << 'b' 23 | assert_equal 2, spy.call_history.size 24 | assert_equal arr, spy.call_history[0].receiver 25 | assert_equal ['a'], spy.call_history[0].args 26 | assert_equal arr, spy.call_history[1].receiver 27 | assert_equal ['b'], spy.call_history[1].args 28 | end 29 | 30 | it 'contains the result' do 31 | obj = Object.new 32 | obj.instance_eval { define_singleton_method :add, proc { |a, b| a + b } } 33 | spy = Spy.on(obj, :add) 34 | obj.add(2, 2) 35 | obj.add(3, 3) 36 | assert_equal 2, spy.call_history.size 37 | assert_equal 4, spy.call_history[0].result 38 | assert_equal 6, spy.call_history[1].result 39 | end 40 | 41 | it 'records any block that the call was passed (on captured blocks)' do 42 | obj = Object.new 43 | obj.instance_eval do 44 | def perform(&block) 45 | block.call 46 | end 47 | end 48 | spy = Spy.on(obj, :perform) 49 | 50 | sum = 0 51 | obj.perform { sum += 1 } 52 | assert_equal sum, 1, 'expected proc to be called on spied method' 53 | spy.call_history[0].block.call 54 | assert_equal sum, 2, 'expected Spy::MethodCall#block.call to call original block' 55 | end 56 | 57 | it 'records any block that the call was passed (on yielded blocks)' do 58 | obj = Object.new 59 | obj.instance_eval do 60 | def perform 61 | yield 62 | end 63 | end 64 | spy = Spy.on(obj, :perform) 65 | 66 | sum = 0 67 | obj.perform { sum += 1 } 68 | assert_equal sum, 1, 'expected proc to be called on spied method' 69 | spy.call_history[0].block.call 70 | assert_equal sum, 2, 'expected Spy::MethodCall#block.call to call original block' 71 | end 72 | 73 | it 'records the method name' do 74 | obj = Object.new 75 | obj.instance_eval { define_singleton_method :perform, proc {} } 76 | spy = Spy.on(obj, :perform) 77 | obj.perform 78 | assert_equal :perform, spy.call_history[0].name 79 | end 80 | 81 | it 'records the caller' do 82 | obj = Object.new 83 | obj.instance_eval { define_singleton_method :perform, proc {} } 84 | spy = Spy.on(obj, :perform) 85 | obj.perform 86 | assert spy.call_history[0].caller[0] =~ /call_history_test\.rb/ 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/called_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class CalledTest < Minitest::Spec 4 | describe 'Spy::Instance#called?' do 5 | it 'works' do 6 | obj = Object.new 7 | spy = Spy.on(obj, :to_s) 8 | refute(spy.called?) 9 | obj.to_s 10 | assert(spy.called?) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/class_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ClassTest < Minitest::Spec 4 | it 'works' do 5 | klass = Class.new(Object) 6 | klass.class_eval do 7 | def name; "penny"; end 8 | def say_hi(name); "hi #{name}"; end 9 | end 10 | multi = Spy.on_class(klass) 11 | assert multi.call_count == 0 12 | obj = klass.new 13 | obj.name 14 | assert_equal "hi josh", obj.say_hi("josh") 15 | assert_equal 2, multi.call_count 16 | assert_equal 1, multi[:name].call_count 17 | assert_equal 1, multi[:say_hi].call_count 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/dynamic_delegation_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class DynamicDelegationTest < Minitest::Spec 4 | class Proxy 5 | def initialize(delegate) 6 | @delegate = delegate 7 | end 8 | 9 | def method_missing(sym, include_all=false) 10 | @delegate.send(sym) 11 | end 12 | 13 | def respond_to_missing?(sym, include_all=false) 14 | sym == :hello 15 | end 16 | end 17 | 18 | class TestClass 19 | def hello 20 | 'hello' 21 | end 22 | end 23 | 24 | it 'can be spied' do 25 | p = Proxy.new(TestClass.new) 26 | Spy.on(p, :hello) 27 | end 28 | 29 | it 'has a call history' do 30 | p = Proxy.new(TestClass.new) 31 | spy = Spy.on(p, :hello) 32 | p.hello 33 | assert spy.call_count == 1 34 | end 35 | 36 | it 'can be restored' do 37 | p = Proxy.new(TestClass.new) 38 | spy = Spy.on(p, :hello) 39 | Spy.restore(p, :hello, :dynamic_delegation) 40 | p.hello 41 | assert spy.call_count == 0 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/instead_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class InsteadTest < Minitest::Spec 4 | describe 'Spy::Instance#instead' do 5 | after do 6 | Spy.restore(:all) 7 | end 8 | 9 | it 'will run the instead block and not the original call' do 10 | o = Object.new 11 | o.instance_eval do 12 | def incr; total += 1; end 13 | def total; @total ||= 0; end 14 | def total=(n); @total = n; end 15 | end 16 | 17 | Spy.on(o, :incr).instead do |mc| 18 | mc.receiver.total += 2 19 | end 20 | 21 | o.incr 22 | 23 | assert_equal 2, o.total 24 | end 25 | 26 | it 'will return the value returned by the block' do 27 | o = Object.new 28 | o.instance_eval do 29 | def name; 'josh'; end 30 | end 31 | 32 | Spy.on(o, :name).instead { 'penny' } 33 | 34 | assert_equal 'penny', o.name 35 | end 36 | 37 | it 'plays nicely with Spy::Instance#when' do 38 | o = Object.new 39 | o.instance_eval do 40 | def value=(n); @value = n; end 41 | def value; @value; end 42 | def name; 'josh'; end 43 | end 44 | 45 | Spy.on(o, :name).when { o.value == 0 }.instead { 'penny' } 46 | 47 | o.value = 0 48 | assert_equal 'penny', o.name 49 | 50 | o.value = 1 51 | assert_equal 'josh', o.name 52 | end 53 | 54 | it 'allows multiple whens and insteads' do 55 | skip 'havent implemented multiple whens yet' 56 | 57 | o = Object.new 58 | o.instance_eval do 59 | def value=(n); @value = n; end 60 | def value; @value; end 61 | def name; 'josh'; end 62 | end 63 | 64 | Spy.on(o, :name).when { o.value == 0 }.instead { 'penny' } 65 | Spy.on(o, :name).when { o.value == 1 }.instead { 'lauren' } 66 | 67 | o.value = 0 68 | assert_equal 'penny', o.name 69 | 70 | o.value = 1 71 | assert_equal 'lauren', o.name 72 | end 73 | 74 | it 'records the call in call history' do 75 | o = Object.new 76 | spy = Spy.on(o, :to_s).instead { 'moo' } 77 | 78 | o.to_s 79 | 80 | assert spy.called? 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/multi_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class MultiTest < Minitest::Spec 4 | describe '#uncalled' do 5 | it 'can be used to find unused singleton methods' do 6 | obj = Object.new 7 | obj.instance_eval do 8 | def name; "josh"; end 9 | def hair_color; "black"; end 10 | end 11 | 12 | multi = Spy.on_object(obj) 13 | obj.name 14 | 15 | uncalled = multi.uncalled.select { |spy| spy.original.owner == obj.singleton_class } 16 | assert_equal 1, uncalled.size 17 | assert_equal :hair_color, uncalled[0].name 18 | end 19 | 20 | it 'can be used to find unused instance methods' do 21 | klass = Class.new(Object) 22 | klass.class_eval do 23 | def name; "josh"; end 24 | def hair_color; "black"; end 25 | end 26 | 27 | multi = Spy.on_class(klass) 28 | obj = klass.new 29 | obj.name 30 | 31 | uncalled = multi.uncalled.select { |spy| spy.original.owner == klass } 32 | assert_equal 1, uncalled.size 33 | assert_equal :hair_color, uncalled[0].name 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/name_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class NameTest < Minitest::Spec 4 | describe 'Spy::Instance' do 5 | it 'delegates name to original' do 6 | obj = Object.new 7 | spy = Spy.on(obj, :to_s) 8 | assert_equal :to_s, spy.name 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/object_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ObjectTest < Minitest::Spec 4 | it 'works' do 5 | obj = Object.new 6 | obj.instance_eval do 7 | def name; "penny"; end 8 | def say_hi(name); "hi #{name}"; end 9 | end 10 | multi = Spy.on_object(obj) 11 | assert multi.call_count == 0 12 | obj.name 13 | assert_equal "hi josh", obj.say_hi("josh") 14 | assert_equal 2, multi.call_count 15 | assert_equal 1, multi[:name].call_count 16 | assert_equal 1, multi[:say_hi].call_count 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/replay_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ReplayTest < Minitest::Spec 4 | after do 5 | Spy.restore(:all) 6 | end 7 | 8 | describe 'Spy::Instance#replay_all' do 9 | it 'replays each of the calls in sequence' do 10 | spy = Spy.on_any_instance(String, :<<) 11 | str = '' 12 | str << 'a' 13 | assert_equal 'a', str 14 | str << 'b' 15 | assert_equal 'ab', str 16 | spy.replay_all 17 | assert_equal 'abab', str 18 | end 19 | 20 | it "doesn't track replays" do 21 | spy = Spy.on_any_instance(String, :<<) 22 | str = '' 23 | str << 'a' 24 | assert_equal 'a', str 25 | str << 'b' 26 | assert_equal 'ab', str 27 | spy.replay_all 28 | assert_equal 2, spy.call_count 29 | end 30 | end 31 | 32 | describe 'Spy::MethodCall#replay' do 33 | it "doesn't track replays" do 34 | spy = Spy.on_any_instance(String, :<<) 35 | str = '' 36 | str << 'a' 37 | spy.call_history[0].replay 38 | assert_equal 1, spy.call_count 39 | end 40 | 41 | it 'replays a single call' do 42 | spy = Spy.on_any_instance(String, :<<) 43 | str = '' 44 | str << 'a' 45 | assert_equal 'a', str 46 | spy.call_history[0].replay 47 | assert_equal 'aa', str 48 | end 49 | 50 | it 'replays calls with the correct block (when captured)' do 51 | obj = Object.new 52 | obj.instance_eval do 53 | def self.block_caller(&block) 54 | block.call 55 | end 56 | end 57 | spy = Spy.on(obj, :block_caller) 58 | sum = 0 59 | obj.block_caller { sum += 1 } 60 | assert_equal 1, sum 61 | spy.call_history[0].replay 62 | assert_equal 2, sum 63 | end 64 | 65 | it 'replays calls with the correct block (when yielded)' do 66 | obj = Object.new 67 | obj.instance_eval do 68 | def self.block_caller 69 | yield 70 | end 71 | end 72 | spy = Spy.on(obj, :block_caller) 73 | sum = 0 74 | obj.block_caller { sum += 1 } 75 | assert_equal 1, sum 76 | spy.call_history[0].replay 77 | assert_equal 2, sum 78 | end 79 | 80 | it 'is an alias of Spy::MethodCall#call_original' do 81 | spy = Spy.on_any_instance(String, :<<) 82 | str = '' 83 | str << 'a' 84 | assert(spy.call_history[0].method(:replay) == 85 | spy.call_history[0].method(:call_original), 86 | 'Spy::MethodCall#replay is not an alias of Spy::MethodCall#call_original') 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/smoke_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class SmokeTest < Minitest::Spec 4 | after do 5 | Spy.restore(:all) 6 | end 7 | 8 | it 'raises already spied error properly' do 9 | klass = Class.new(Object) 10 | assert_raises Spy::Errors::AlreadySpiedError do 11 | Spy.on(klass, :to_s) 12 | Spy.on(klass, :to_s) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/spy_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TestSuperclass 4 | def self.superclass_singleton_owned_method; end 5 | def superclass_owned_method; end 6 | end 7 | 8 | module TestModule 9 | def module_owned_method; end 10 | end 11 | 12 | class TestClass < TestSuperclass 13 | include TestModule 14 | 15 | def self.existing_singleton_owned_method; end 16 | def class_owned_method; end 17 | end 18 | 19 | class SpyTest < Minitest::Spec 20 | def eval_option(opt, *args) 21 | opt.respond_to?(:call) ? opt.call(*args) : opt 22 | end 23 | 24 | # Wrapping 25 | [ 26 | { 27 | name: 'an instance and a dynamic singleton-owned method', 28 | to_spy: proc { TestClass.new }, 29 | msg: :singleton_owned_method, 30 | original: proc { |spied, sym| spied.define_singleton_method(sym, proc {}); spied.method(sym) }, 31 | cleanup: proc { |spied, sym| spied.singleton_class.class_eval { remove_method sym } }, 32 | owner: proc { |spied| spied.singleton_class } 33 | }, 34 | { 35 | name: 'a class and a dynamic singleton-owned method', 36 | to_spy: TestClass, 37 | msg: :singleton_owned_method, 38 | original: proc { |spied, sym| spied.define_singleton_method(sym, proc {}); spied.method(sym) }, 39 | cleanup: proc { |spied, sym| spied.singleton_class.class_eval { remove_method sym } }, 40 | owner: TestClass.singleton_class 41 | }, 42 | { 43 | name: 'a module and a dynamic singleton-owned method', 44 | to_spy: TestModule, 45 | msg: :singleton_owned_method, 46 | original: proc { |spied, sym| spied.define_singleton_method(sym, proc {}); spied.method(sym) }, 47 | cleanup: proc { |spied, sym| spied.singleton_class.class_eval { remove_method sym } }, 48 | owner: TestModule.singleton_class 49 | }, 50 | { 51 | name: 'a class and an existing singleton-owned method', 52 | to_spy: TestClass, 53 | msg: :existing_singleton_owned_method, 54 | original: proc { TestClass.method :existing_singleton_owned_method }, 55 | owner: TestClass.singleton_class 56 | } 57 | ].each do |t| 58 | describe t[:name] do 59 | before do 60 | @spied = eval_option(t[:to_spy]) 61 | @sym = t[:msg] 62 | @original_method = eval_option(t[:original], @spied, @sym) 63 | # sanity check 64 | assert_equal eval_option(t[:owner], @spied), @original_method.owner 65 | end 66 | 67 | after do 68 | Spy.restore :all 69 | eval_option(t[:cleanup], @spied, @sym) 70 | end 71 | 72 | describe 'Spy.on' do 73 | it 'chooses a wrapping strategy' do 74 | s = Spy.on(@spied, @sym) 75 | assert_equal s.strategy.class, Spy::Strategy::Wrap 76 | end 77 | 78 | it 'wraps the method' do 79 | Spy.on(@spied, @sym) 80 | wrapped = @spied.method(@sym) 81 | refute_equal @original_method, wrapped 82 | assert_equal @original_method.owner, wrapped.owner 83 | refute_equal @original_method.source_location, wrapped.source_location 84 | end 85 | end 86 | 87 | describe 'Spy.restore' do 88 | it 'restores the original method' do 89 | Spy.on(@spied, @sym) 90 | Spy.restore(@spied, @sym) 91 | restored = @spied.method(@sym) 92 | assert_equal @original_method, restored 93 | end 94 | end 95 | end 96 | end 97 | 98 | # Intercepting 99 | [ 100 | { name: 'an instance and a class-owned method', to_spy: proc { TestClass.new }, msg: :class_owned_method, owner: TestClass }, 101 | { name: 'an instance and a module-owned method', to_spy: proc { TestClass.new }, msg: :module_owned_method, owner: TestModule }, 102 | { name: 'an instance and a superclass-owned method', to_spy: proc { TestClass.new }, msg: :superclass_owned_method, owner: TestSuperclass }, 103 | # NOTE: Module#include only adds instance methods. You can make a PR if you're including modules in your singleton classes 104 | # { name: 'a class and a module-singleton-owned method', to_spy: Proc.new { TestClass }, msg: :module_singleton_owned_method, owner: TestModule.singleton_class } 105 | { name: 'a class and a superclass-singleton-owned method', to_spy: proc { TestClass }, msg: :superclass_singleton_owned_method, owner: TestSuperclass.singleton_class } 106 | ].each do |t| 107 | describe t[:name] do 108 | before do 109 | @spied = t[:to_spy].call 110 | @sym = t[:msg] 111 | @original_method = @spied.method(@sym) 112 | assert_equal t[:owner], @original_method.owner 113 | end 114 | 115 | after do 116 | Spy.restore :all 117 | end 118 | 119 | describe 'Spy.on' do 120 | it 'chooses an intercept strategy' do 121 | s = Spy.on(@spied, @sym) 122 | assert_equal s.strategy.class, Spy::Strategy::Intercept 123 | end 124 | 125 | it 'defines a singleton method' do 126 | Spy.on(@spied, @sym) 127 | singleton_method = @spied.method(@sym) 128 | refute_equal @original_method, singleton_method 129 | assert_equal @spied.singleton_class, singleton_method.owner 130 | refute_equal @original_method.source_location, singleton_method.source_location 131 | end 132 | end 133 | 134 | # NOTE: @jbodah 2018-05-09: this is no longer true in Ruby 2.4+; remove_method works differently 135 | # describe 'Spy.restore' do 136 | # it 'restores the original method' do 137 | # spy = Spy.on(@spied, @sym) 138 | # Spy.restore(@spied, @sym) 139 | # restored = @spied.method(@sym) 140 | # assert_equal @original_method, restored 141 | # end 142 | # end 143 | end 144 | end 145 | 146 | describe 'any_instance' do 147 | describe 'Spy.on_any_instance' do 148 | describe 'an instance' do 149 | it 'throws an ArgumentError' do 150 | obj = Object.new 151 | assert_raises ArgumentError do 152 | Spy.on_any_instance(obj, :hello) 153 | end 154 | end 155 | end 156 | 157 | # Wrapping 158 | [ 159 | { name: 'a class and a class-owned method', to_spy: proc { TestClass }, msg: :class_owned_method }, 160 | { name: 'a module and a module-owned method', to_spy: proc { TestModule }, msg: :module_owned_method } 161 | ].each do |t| 162 | describe t[:name] do 163 | describe 'and a class-owned method' do 164 | before do 165 | @spied = t[:to_spy].call 166 | @sym = t[:msg] 167 | @original_method = @spied.instance_method(@sym) 168 | end 169 | 170 | after do 171 | Spy.restore :all 172 | end 173 | 174 | it 'chooses a wrapping strategy' do 175 | s = Spy.on_any_instance(@spied, @sym) 176 | assert_equal s.strategy.class, Spy::Strategy::Wrap 177 | end 178 | 179 | it 'wraps the method' do 180 | Spy.on_any_instance(@spied, @sym) 181 | wrapped = @spied.instance_method(@sym) 182 | refute_equal @original_method, wrapped 183 | assert_equal @original_method.owner, wrapped.owner 184 | refute_equal @original_method.source_location, wrapped.source_location 185 | end 186 | 187 | it 'restores the original method' do 188 | Spy.on_any_instance(@spied, @sym) 189 | Spy.restore(@spied, @sym, :instance_method) 190 | restored = @spied.instance_method(@sym) 191 | assert_equal @original_method, restored 192 | end 193 | end 194 | end 195 | end 196 | 197 | # Intercepting 198 | [ 199 | { name: 'a class and a superclass-owned method', msg: :superclass_owned_method }, 200 | { name: 'a class and a module-owned method', msg: :module_owned_method } 201 | ].each do |t| 202 | describe t[:name] do 203 | before do 204 | @spied = TestClass 205 | @sym = t[:msg] 206 | @original_method = @spied.instance_method(@sym) 207 | end 208 | 209 | after do 210 | Spy.restore :all 211 | end 212 | 213 | it 'chooses an intercept strategy' do 214 | s = Spy.on_any_instance(@spied, @sym) 215 | assert_equal s.strategy.class, Spy::Strategy::Intercept 216 | end 217 | 218 | it 'defines a method on the class' do 219 | Spy.on_any_instance(@spied, @sym) 220 | class_defined_method = @spied.instance_method(@sym) 221 | refute_equal @original_method, class_defined_method 222 | assert_equal @spied, class_defined_method.owner 223 | refute_equal @original_method.source_location, class_defined_method.source_location 224 | end 225 | 226 | it 'restores the original method' do 227 | Spy.on_any_instance(@spied, @sym) 228 | Spy.restore(@spied, @sym, :instance_method) 229 | restored = @spied.instance_method(@sym) 230 | assert_equal @original_method, restored 231 | end 232 | end 233 | end 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Add lib to load path 2 | $LOAD_PATH.push 'lib', __FILE__ 3 | require 'spy' 4 | 5 | require 'coveralls' 6 | Coveralls.wear! 7 | 8 | require 'minitest/pride' 9 | require 'minitest/autorun' 10 | require 'minitest/spec' 11 | -------------------------------------------------------------------------------- /test/wrap_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class WrapTest < Minitest::Spec 4 | class TestClass 5 | attr_accessor :string 6 | 7 | def initialize 8 | @string = '' 9 | end 10 | 11 | def append(char) 12 | @string << char 13 | end 14 | 15 | def recursive_add(original, n) 16 | return original if n == 0 17 | recursive_add(original, n - 1) + 1 18 | end 19 | end 20 | 21 | describe 'Spy::Instance#wrap' do 22 | describe 'followed by the method call' do 23 | it 'correctly wraps the call based on the block.call placement' do 24 | # yield before 25 | spied = TestClass.new 26 | spy = Spy.on(spied, :append) 27 | spy.wrap do |&block| 28 | spied.string << 'a' 29 | block.call 30 | end 31 | 32 | spied.append('b') 33 | 34 | assert_equal 'ab', spied.string, 35 | 'expected wrapping block code to be called before block.call' 36 | 37 | # yield after 38 | spied = TestClass.new 39 | spy = Spy.on(spied, :append) 40 | spy.wrap do |&block| 41 | block.call 42 | spied.string << 'a' 43 | end 44 | 45 | spied.append('b') 46 | 47 | assert_equal 'ba', spied.string, 48 | 'expected wrapping block code to be called after block.call' 49 | end 50 | 51 | it 'still updates the call count properly even with multiple wraps' do 52 | spied = TestClass.new 53 | spy = Spy.on(spied, :append) 54 | 2.times { spy.wrap { |&block| block.call } } 55 | spied.append 'a' 56 | assert_equal 1, spy.call_count 57 | end 58 | 59 | it 'only updates the call count when the actual original call is made' do 60 | spied = TestClass.new 61 | spy = Spy.on(spied, :append) 62 | spy.wrap {} 63 | spied.append('b') 64 | assert_equal 0, spy.call_count 65 | end 66 | 67 | it 'returns the original result' do 68 | obj = Object.new.tap do |o| 69 | o.instance_eval do 70 | def hello 71 | 'hello' 72 | end 73 | end 74 | end 75 | 76 | s = Spy.on(obj, :hello) 77 | s.wrap do |&block| 78 | block.call 79 | 456 80 | end 81 | 82 | assert_equal 'hello', obj.hello 83 | end 84 | 85 | it 'passes the receiver and the args to the wrap block' do 86 | obj = Object.new.tap { |o| o.instance_eval { def say(*args); end } } 87 | s = Spy.on(obj, :say) 88 | passed_args = [1, 2, 3] 89 | s.wrap do |mc| 90 | assert_equal obj, mc.receiver 91 | assert_equal passed_args, mc.args 92 | end 93 | obj.say(*passed_args) 94 | end 95 | 96 | it 'works with recursive methods' do 97 | r = TestClass.new 98 | spy = Spy.on(r, :recursive_add) 99 | 100 | spy.wrap do |*_args, &block| 101 | block.call 102 | end 103 | assert_equal 4, r.recursive_add(2, 2) 104 | end 105 | 106 | it 'passes a Spy::MethodCall to the block' do 107 | obj = [] 108 | spy = Spy.on(obj, :<<) 109 | 110 | yielded = nil 111 | spy.wrap { |mc| yielded = mc } 112 | 113 | obj << 'hello' 114 | 115 | assert yielded.is_a? Spy::MethodCall 116 | end 117 | 118 | it 'fills in the Spy::MethodCall result field after yielding execution back to the spied method' do 119 | obj = [] 120 | spy = Spy.on(obj, :<<) 121 | 122 | yielded = nil 123 | spy.wrap { |mc, &block| block.call; yielded = mc } 124 | 125 | obj << 'hello' 126 | 127 | assert_equal obj, yielded.result 128 | end 129 | end 130 | end 131 | end 132 | --------------------------------------------------------------------------------