├── .circleci └── config.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── .travis.yml ├── .yardopts ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── impersonator.gemspec ├── lib ├── impersonator.rb └── impersonator │ ├── api.rb │ ├── block_invocation.rb │ ├── block_spy.rb │ ├── configuration.rb │ ├── double.rb │ ├── errors │ ├── configuration_error.rb │ └── method_invocation_error.rb │ ├── has_logger.rb │ ├── method.rb │ ├── method_invocation.rb │ ├── method_matching_configuration.rb │ ├── proxy.rb │ ├── record_mode.rb │ ├── recording.rb │ ├── replay_mode.rb │ └── version.rb └── spec ├── configuration_spec.rb ├── double_spec.rb ├── errors_spec.rb ├── impersonate_double_spec.rb ├── impersonate_methods_spec.rb ├── recording_file_generation_spec.rb ├── spec_helper.rb └── support ├── hooks ├── clear_recordings_hook.rb └── reset_hook.rb └── test ├── calculator.rb └── file_helpers.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | defaults: &defaults 3 | working_directory: ~/impersonator 4 | docker: 5 | - image: circleci/ruby:2.6.2-node-browsers 6 | environment: 7 | BUNDLE_PATH: vendor/bundle 8 | PGHOST: 127.0.0.1 9 | PGUSER: impersonator 10 | commands: 11 | prepare: 12 | description: "Common preparation steps" 13 | steps: 14 | - checkout 15 | 16 | - restore_cache: 17 | keys: 18 | - v1-dependencies-{{ checksum "Gemfile.lock" }} 19 | # fallback to using the latest cache if no exact match is found 20 | - v1-dependencies- 21 | - run: 22 | name: install dependencies 23 | command: | 24 | bundle install --jobs=4 --retry=3 --path vendor/bundle 25 | 26 | - save_cache: 27 | paths: 28 | - ./vendor/bundle 29 | key: v1-dependencies-{{ checksum "Gemfile.lock" }} 30 | 31 | jobs: 32 | tests: 33 | <<: *defaults 34 | steps: 35 | - prepare 36 | - run: 37 | name: run tests 38 | command: | 39 | mkdir /tmp/test-results 40 | TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)" 41 | 42 | bundle exec rspec --format progress \ 43 | --format RspecJunitFormatter \ 44 | --out /tmp/test-results/rspec.xml \ 45 | --tag ~type:performance \ 46 | --format progress \ 47 | $TEST_FILES 48 | 49 | # collect reports 50 | - store_test_results: 51 | path: /tmp/test-results 52 | - store_artifacts: 53 | path: /tmp/test-results 54 | destination: test-results 55 | rubocop: 56 | <<: *defaults 57 | steps: 58 | - prepare 59 | - run: 60 | name: Rubocop 61 | command: bundle exec rubocop 62 | workflows: 63 | version: 2 64 | pipeline: 65 | jobs: 66 | - tests 67 | - rubocop 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | spec/recordings 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | 3 | Style/FrozenStringLiteralComment: 4 | Enabled: false 5 | 6 | Metrics/LineLength: 7 | Max: 105 8 | 9 | Metrics/AbcSize: 10 | Enabled: false 11 | 12 | Metrics/CyclomaticComplexity: 13 | Enabled: false 14 | 15 | Metrics/BlockLength: 16 | Enabled: false 17 | 18 | RSpec/MultipleExpectations: 19 | Enabled: false 20 | 21 | Style/ClassAndModuleChildren: 22 | Enabled: false 23 | 24 | Naming/BinaryOperatorParameterName: 25 | Enabled: false 26 | 27 | Metrics/ParameterLists: 28 | Enabled: false 29 | 30 | Style/Documentation: 31 | Enabled: false 32 | 33 | Naming/UncommunicativeMethodParamName: 34 | Enabled: false 35 | 36 | RSpec/ExampleLength: 37 | Enabled: false 38 | 39 | Naming/VariableNumber: 40 | Enabled: false 41 | 42 | Metrics/MethodLength: 43 | Enabled: false 44 | 45 | RSpec/EmptyExampleGroup: 46 | Enabled: false 47 | 48 | RSpec/FilePath: 49 | Enabled: false 50 | 51 | Lint/UselessAccessModifier: 52 | Enabled: false 53 | 54 | Metrics/ModuleLength: 55 | Enabled: false 56 | 57 | RSpec/SubjectStub: 58 | Enabled: false 59 | 60 | RSpec/MessageSpies: 61 | Enabled: false 62 | 63 | RSpec/VerifiedDoubles: 64 | Enabled: false 65 | 66 | RSpec/DescribeClass: 67 | Enabled: false 68 | 69 | Style/NumericLiterals: 70 | Enabled: false 71 | 72 | Naming/MemoizedInstanceVariableName: 73 | Enabled: false 74 | 75 | RSpec/LetSetup: 76 | Enabled: false 77 | 78 | # Replace some legits include? usages 79 | RSpec/PredicateMatcher: 80 | Enabled: false 81 | 82 | # For some algo tests we do want to use instance_vars to capture data within algos 83 | RSpec/InstanceVariable: 84 | Enabled: false 85 | 86 | Style/ModuleFunction: 87 | Enabled: false 88 | 89 | Lint/HandleExceptions: 90 | Enabled: false 91 | 92 | RSpec/BeforeAfterAll: 93 | Enabled: false 94 | 95 | Lint/Loop: 96 | Enabled: false 97 | 98 | Style/NumericPredicate: 99 | Enabled: false 100 | 101 | Metrics/ClassLength: 102 | Enabled: false 103 | 104 | RSpec/NestedGroups: 105 | Enabled: false 106 | 107 | Metrics/PerceivedComplexity: 108 | Enabled: false 109 | 110 | Style/GuardClause: 111 | Enabled: false 112 | 113 | Naming/RescuedExceptionsVariableName: 114 | Enabled: false 115 | 116 | Lint/UnusedMethodArgument: 117 | AllowUnusedKeywordArguments: true 118 | IgnoreEmptyMethods: true 119 | 120 | Lint/NestedMethodDefinition: 121 | Enabled: false 122 | 123 | Style/MethodMissingSuper: 124 | Enabled: false 125 | 126 | RSpec/MultipleDescribes: 127 | Enabled: false 128 | 129 | AllCops: 130 | Exclude: 131 | - "**/*.sql" 132 | - "recipes/**/*" 133 | - "db/**/*" 134 | - "tmp/**/*" 135 | - "vendor/**/*" 136 | - "bin/**/*" 137 | - "log/**/*" 138 | - "ansible/**/*" 139 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.6.3 7 | before_install: gem install bundler -v 1.17.2 8 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup-provider=redcarpet 2 | --markup=markdown 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.2 4 | 5 | - [Add YARD documentation](https://github.com/jorgemanrubia/impersonator/pull/3) 6 | 7 | ## 0.1.1 8 | 9 | - Initial release 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in impersonator.gemspec 6 | gemspec 7 | 8 | gem 'rubocop', require: false 9 | gem 'rubocop-rspec', require: false 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | impersonator (0.1.3) 5 | zeitwerk (~> 2.1.6) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.0) 11 | diff-lcs (1.3) 12 | jaro_winkler (1.5.2) 13 | parallel (1.17.0) 14 | parser (2.6.3.0) 15 | ast (~> 2.4.0) 16 | rainbow (3.0.0) 17 | rake (10.5.0) 18 | rspec (3.8.0) 19 | rspec-core (~> 3.8.0) 20 | rspec-expectations (~> 3.8.0) 21 | rspec-mocks (~> 3.8.0) 22 | rspec-core (3.8.0) 23 | rspec-support (~> 3.8.0) 24 | rspec-expectations (3.8.3) 25 | diff-lcs (>= 1.2.0, < 2.0) 26 | rspec-support (~> 3.8.0) 27 | rspec-mocks (3.8.0) 28 | diff-lcs (>= 1.2.0, < 2.0) 29 | rspec-support (~> 3.8.0) 30 | rspec-support (3.8.0) 31 | rspec_junit_formatter (0.4.1) 32 | rspec-core (>= 2, < 4, != 2.12.0) 33 | rubocop (0.71.0) 34 | jaro_winkler (~> 1.5.1) 35 | parallel (~> 1.10) 36 | parser (>= 2.6) 37 | rainbow (>= 2.2.2, < 4.0) 38 | ruby-progressbar (~> 1.7) 39 | unicode-display_width (>= 1.4.0, < 1.7) 40 | rubocop-rspec (1.33.0) 41 | rubocop (>= 0.60.0) 42 | ruby-progressbar (1.10.1) 43 | unicode-display_width (1.6.0) 44 | zeitwerk (2.1.8) 45 | 46 | PLATFORMS 47 | ruby 48 | 49 | DEPENDENCIES 50 | bundler (~> 1.17) 51 | impersonator! 52 | rake (~> 10.0) 53 | rspec (~> 3.0) 54 | rspec_junit_formatter 55 | rubocop 56 | rubocop-rspec 57 | 58 | BUNDLED WITH 59 | 1.17.2 60 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Jorge Manrubia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/jorgemanrubia/impersonator.svg?style=svg)](https://circleci.com/gh/jorgemanrubia/impersonator) 2 | 3 | # Impersonator 4 | 5 | Impersonator is a Ruby library to record and replay object interactions. 6 | 7 | When testing, you often find services that are expensive to invoke, and you need to use a [double](https://martinfowler.com/bliki/TestDouble.html) instead. Creating stubs and mocks for simple scenarios is easy, but, for complex interactions, things get messy fast. Stubbing elaborated canned response and orchestrating multiple expectations quickly degenerates in brittle tests that are hard to write and maintain. 8 | 9 | Impersonator comes to the rescue. Given an object and a list of methods to impersonate: 10 | 11 | - The first time each method is invoked, it will record its invocations, including passed arguments, return values, and yielded values. This is known as *record mode*. 12 | - The next times, it will reproduce the recorded values and will validate that the method was invoked with the same arguments, in a specific order and the exact number of times. This is known as *replay mode*. 13 | 14 | Impersonator only focuses on validating invocation signature and reproducing output values, which is perfect for many services. It won't work for services that trigger additional logic that is relevant to the test (e.g., if the method sends an email, the impersonated method won't send it). 15 | 16 | Familiar with [VCR](https://github.com/vcr/vcr)? Impersonator is like VCR but for ruby objects instead of HTTP. 17 | 18 | ## Installation 19 | 20 | Add this line to your application's Gemfile: 21 | 22 | ```ruby 23 | gem 'impersonator', group: :test 24 | ``` 25 | 26 | And then execute: 27 | 28 | $ bundle 29 | 30 | ## Usage 31 | 32 | Use `Impersonator.impersonate` passing in a list of methods to impersonate and a block that will instantiate the object at record time: 33 | 34 | ```ruby 35 | calculator = Impersonator.impersonate(:add, :divide) { Calculator.new } 36 | ``` 37 | 38 | * At record time, `Calculator` will be instantiated and their methods normally invoked, recording the returned values (and yielded values if any). 39 | * At replay time, `Calculator` won't be instantiated. Instead, a double object will be generated on the fly that will replay the recorded values. 40 | 41 | ```ruby 42 | class Calculator 43 | def add(number_1, number_2) 44 | number_1 + number_2 45 | end 46 | end 47 | 48 | # The first time it records... 49 | Impersonator.recording('calculator add') do 50 | calculator = Impersonator.impersonate(:add) { Calculator.new } 51 | puts calculator.add(2, 3) # 5 52 | end 53 | 54 | # The next time it replays 55 | Object.send :remove_const, :Calculator # Calculator does not even have to exist now 56 | Impersonator.recording('calculator add') do 57 | calculator = Impersonator.impersonate(:add) { Calculator.new } 58 | puts calculator.add(2, 3) # 5 59 | end 60 | ``` 61 | 62 | Typically you will use `impersonate` for testing, so this is how your test will look: 63 | 64 | ```ruby 65 | # The second time the test runs, impersonator will replay the 66 | # recorded results 67 | test 'sums the numbers' do 68 | Impersonator.recording('calculator add') do 69 | calculator = Impersonator.impersonate(:add){ Calculator.new } 70 | assert_equal 5, calculator.add(2, 3) 71 | end 72 | end 73 | ``` 74 | 75 | Impersonated methods will record and replay: 76 | 77 | - Arguments 78 | - Return values 79 | - Yielded values 80 | 81 | ### Impersonate certain methods only 82 | 83 | Use `Impersonator#impersonate_methods` to impersonate certain methods only. At replay time, the impersonated object will delegate to the actual object all the methods except the impersonated ones. 84 | 85 | ```ruby 86 | actual_calculator = Calculator.new 87 | impersonator = Impersonator.impersonate(actual_calculator, :add) 88 | ``` 89 | 90 | In this case, in replay mode, `Calculator` gets instantiated normally and any method other than `#add` will be delegated to `actual_calculator`. 91 | 92 | ## Configuration 93 | 94 | ### Recordings path 95 | 96 | `Impersonator` works by recording method invocations in `YAML` format. By default, recordings are saved in: 97 | 98 | - `spec/recordings` if a `spec` folder is present in the project 99 | - `test/recordings` otherwise 100 | 101 | You can configure this path with: 102 | 103 | ```ruby 104 | Impersonator.configure do |config| 105 | config.recordings_path = 'my/own/recording/path' 106 | end 107 | ``` 108 | 109 | ### Ignore arguments when matching methods 110 | 111 | By default, to determine if a method invocation was right, the list of arguments will be matched with `==`. You can configure how this work by providing a list of argument indexes to ignore. 112 | 113 | ```ruby 114 | impersonator = Impersonator.impersonate(:add){ Test::Calculator.new } 115 | impersonator.configure_method_matching_for(:add) do |config| 116 | config.ignore_arguments_at 0 117 | end 118 | 119 | # Now the first parameter of #add will be ignored. 120 | # 121 | # In record mode: 122 | impersonator.add(1, 2) # 3 123 | 124 | # In replay mode 125 | impersonator.add(9999, 2) # will still return 3 and won't fail because the first argument is ignored 126 | ``` 127 | 128 | ### Disabling record mode 129 | 130 | You can disable `impersonator` by passing `disable: true` to `Impersonator.recording`: 131 | 132 | ```ruby 133 | Impersonator.recording('test recording', disabled: true) do 134 | # ... 135 | end 136 | ``` 137 | 138 | This will effectively force record mode at all times. This is handy while you are figuring out how interactions with the mocked service go. It will save the recordings, but it will never use them. 139 | 140 | ### Configuring attributes to serialize 141 | 142 | `Impersonator` relies on Ruby standard `YAML` library for serializing/deserializing data. It works with simple attributes, arrays, hashes and objects which attributes are serializable in a recurring way. This means that you don't have to care when interchanging value objects, which is a common scenario when impersonating RPC-like clients. 143 | 144 | However, there are some types, like `Proc`, anonymous classes, or `IO` classes like `File`, that will make the serialization process fail. You can customize which attributes are serialized by overriding `init_with` and `encode_with` in the class you want to serialize. You will typically exclude the problematic attributes by including only the compatible ones. 145 | 146 | ```ruby 147 | class MyClass 148 | # ... 149 | 150 | def init_with(coder) 151 | self.name = coder['name'] 152 | end 153 | 154 | def encode_with(coder) 155 | coder['name'] = name 156 | end 157 | end 158 | ``` 159 | 160 | ### RSpec configuration 161 | 162 | `Impersonator` is test-framework agnostic. If you are using [RSpec](https://rspec.info), you can configure an `around` hook that will start a recording session automatically for each example that has an `impersonator` tag: 163 | 164 | ```ruby 165 | RSpec.configure do |config| 166 | config.around(:example, :impersonator) do |example| 167 | Impersonator.recording(example.full_description) do 168 | example.run 169 | end 170 | end 171 | end 172 | ``` 173 | 174 | Now you can just tag your tests with `impersonator` and an implicit recording named after the example will be available automatically, so you don't have to invoke `Impersonator.recording` anymore. 175 | 176 | ```ruby 177 | describe Calculator, :impersonator do 178 | it 'sums numbers' do 179 | # there is an implicit recording stored in 'calculator-sums-numbers.yaml' 180 | impersonator = Impersonator.impersonate(:add){ Calculator.new } 181 | expect(impersonator.add(1, 2)).to eq(3) 182 | end 183 | end 184 | ``` 185 | 186 | ## Thanks 187 | 188 | - This library was heavily inspired by [VCR](https://github.com/vcr/vcr). A gem that blew my mind years ago and that has been in my toolbox since then. 189 | 190 | ## Links 191 | 192 | - [API documentation at rubydoc.info](https://www.rubydoc.info/github/jorgemanrubia/impersonator) 193 | - [Blog post](https://www.jorgemanrubia.com/2019/06/16/impersonator-a-ruby-library-to-record-and-replay-object-interactions/) 194 | 195 | ## Contributing 196 | 197 | Bug reports and pull requests are welcome on GitHub at https://github.com/jorgemanrubia/impersonator. 198 | 199 | ## License 200 | 201 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).` 202 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "impersonator" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /impersonator.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'impersonator/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'impersonator' 7 | spec.version = Impersonator::VERSION 8 | spec.authors = ['Jorge Manrubia'] 9 | spec.email = ['jorge.manrubia@gmail.com'] 10 | 11 | spec.summary = 'Generate test stubs that replay recorded interactions' 12 | spec.description = 'Record and replay object interactions. Ideal for mocking not-http services'\ 13 | ' when testing (just because, for http, VCR is probably what you want)' 14 | spec.homepage = 'https://github.com/jorgemanrubia/impersonator' 15 | spec.license = 'MIT' 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata['homepage_uri'] = spec.homepage 21 | spec.metadata['source_code_uri'] = 'https://github.com/jorgemanrubia/impersonator' 22 | else 23 | raise 'RubyGems 2.0 or newer is required to protect against ' \ 24 | 'public gem pushes.' 25 | end 26 | 27 | # Specify which files should be added to the gem when it is released. 28 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 29 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 30 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 31 | end 32 | spec.bindir = 'exe' 33 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 34 | spec.require_paths = ['lib'] 35 | 36 | spec.add_runtime_dependency 'zeitwerk', '~> 2.1.6' 37 | spec.add_development_dependency 'bundler', '~> 1.17' 38 | spec.add_development_dependency 'rake', '~> 10.0' 39 | spec.add_development_dependency 'rspec', '~> 3.0' 40 | spec.add_development_dependency 'rspec_junit_formatter' 41 | end 42 | -------------------------------------------------------------------------------- /lib/impersonator.rb: -------------------------------------------------------------------------------- 1 | require 'impersonator/version' 2 | 3 | require 'zeitwerk' 4 | require 'logger' 5 | require 'fileutils' 6 | require 'yaml' 7 | 8 | loader = Zeitwerk::Loader.for_gem 9 | loader.setup 10 | 11 | module Impersonator 12 | extend Api 13 | 14 | # The gem logger instance 15 | # 16 | # @return [::Logger] 17 | def self.logger 18 | @logger ||= ::Logger.new(STDOUT).tap do |logger| 19 | logger.level = Logger::WARN 20 | logger.datetime_format = '%Y-%m-%d %H:%M:%S' 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/impersonator/api.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # Public API exposed by the global `Impersonator` module. 3 | module Api 4 | # Wraps the execution of the yielded code withing a new {Recording recording} titled with the 5 | # passed label. 6 | # 7 | # @param [String] label The label for the recording 8 | # @param [Boolean] disabled `true` will disable replay mode and always execute code in *record* 9 | # mode. `false` by default 10 | def recording(label, disabled: false) 11 | @current_recording = ::Impersonator::Recording.new label, 12 | disabled: disabled, 13 | recordings_path: configuration.recordings_path 14 | @current_recording.start 15 | yield 16 | @current_recording.finish 17 | ensure 18 | @current_recording = nil 19 | end 20 | 21 | # The current recording, if any, or `nil` otherwise. 22 | # 23 | # @return [Recording, nil] 24 | def current_recording 25 | @current_recording 26 | end 27 | 28 | # Configures how Impersonator works by yielding a {Configuration configuration} object 29 | # you can use to tweak settings. 30 | # 31 | # ``` 32 | # Impersonator.configure do |config| 33 | # config.recordings_path = 'my/own/recording/path' 34 | # end 35 | # ``` 36 | # 37 | # @yieldparam config [Configuration] 38 | def configure 39 | yield configuration 40 | end 41 | 42 | # @return [Configuration] 43 | def configuration 44 | @configuration ||= Configuration.new 45 | end 46 | 47 | # Reset configuration and other global state. 48 | # 49 | # It is meant to be used internally by tests. 50 | def reset 51 | @current_recording = nil 52 | @configuration = nil 53 | end 54 | 55 | # Receives a list of methods to impersonate and a block that will be used, at record time, to 56 | # instantiate the object to impersonate. At replay time, it will generate a double that will 57 | # replay the methods. 58 | # 59 | # impersonator = Impersonator.impersonate(:add, :subtract) { Calculator.new } 60 | # impersonator.add(3, 4) 61 | # 62 | # Notice that the actual object won't be instantiated in record mode. For that reason, the 63 | # impersonated object will only respond to the list of impersonated methods. 64 | # 65 | # If you need to invoke other (not impersonated) methods see #impersonate_method instead. 66 | # 67 | # @param [Array] methods list of methods to impersonate 68 | # @return [Proxy] the impersonated proxy object 69 | def impersonate(*methods) 70 | unless block_given? 71 | raise ArgumentError, 'Provide a block to instantiate the object to impersonate in record mode' 72 | end 73 | 74 | object_to_impersonate = if current_recording&.record_mode? 75 | yield 76 | else 77 | Double.new(*methods) 78 | end 79 | impersonate_methods(object_to_impersonate, *methods) 80 | end 81 | 82 | # Impersonates a list of methods of a given object 83 | # 84 | # The returned object will impersonate the list of methods and will delegate the rest of method 85 | # calls to the actual object. 86 | # 87 | # @param [Object] actual_object The actual object to impersonate 88 | # @param [Array] methods list of methods to impersonate 89 | # @return [Proxy] the impersonated proxy object 90 | def impersonate_methods(actual_object, *methods) 91 | unless @current_recording 92 | raise Impersonator::Errors::ConfigurationError, 'You must start a recording to impersonate'\ 93 | ' objects. Use Impersonator.recording {}' 94 | end 95 | 96 | ::Impersonator::Proxy.new(actual_object, recording: current_recording, 97 | impersonated_methods: methods) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/impersonator/block_invocation.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # A block invocation 3 | BlockInvocation = Struct.new(:arguments, keyword_init: true) 4 | end 5 | -------------------------------------------------------------------------------- /lib/impersonator/block_spy.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # An spy object that can collect {BlockInvocation block invocations} 3 | BlockSpy = Struct.new(:block_invocations, :actual_block, keyword_init: true) do 4 | # @return [Proc] a proc that will collect {BlockInvocation block invocations} 5 | def block 6 | @block ||= proc do |*arguments| 7 | self.block_invocations ||= [] 8 | self.block_invocations << BlockInvocation.new(arguments: arguments) 9 | return_value = actual_block.call(*arguments) 10 | return_value 11 | end 12 | end 13 | 14 | def init_with(coder) 15 | self.block_invocations = coder['block_invocations'] 16 | end 17 | 18 | def encode_with(coder) 19 | coder['block_invocations'] = block_invocations 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/impersonator/configuration.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # General configuration settings for Impersonator 3 | Configuration = Struct.new(:recordings_path, keyword_init: true) do 4 | # @!attribute recordings_path [String] The path where recordings are saved to 5 | 6 | DEFAULT_RECORDINGS_FOLDER = 'recordings'.freeze 7 | 8 | def initialize(*) 9 | super 10 | self.recordings_path ||= detect_default_recordings_path 11 | end 12 | 13 | private 14 | 15 | def detect_default_recordings_path 16 | base_path = File.exist?('spec') ? 'spec' : 'test' 17 | File.join(base_path, DEFAULT_RECORDINGS_FOLDER) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/impersonator/double.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # A simple double implementation. It will generate empty stubs for the passed list of methods 3 | class Double 4 | # @param [Array] methods The list of methods this double will respond to 5 | def initialize(*methods) 6 | define_methods(methods) 7 | end 8 | 9 | private 10 | 11 | def define_methods(methods) 12 | methods.each do |method| 13 | self.class.define_method(method) {} 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/impersonator/errors/configuration_error.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | module Errors 3 | # Indicates a configuration error 4 | class ConfigurationError < StandardError 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/impersonator/errors/method_invocation_error.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | module Errors 3 | # Unexpected method invocation error 4 | class MethodInvocationError < StandardError 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/impersonator/has_logger.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # A mixin that will add a method `logger` to access a logger instance 3 | # 4 | # @see ::Impersonator.logger 5 | module HasLogger 6 | def logger 7 | ::Impersonator.logger 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/impersonator/method.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # A method instance 3 | Method = Struct.new(:name, :arguments, :block, :matching_configuration, keyword_init: true) do 4 | # @!attribute name [String] Method name 5 | # @!attribute arguments [Array] Arguments passed to the method invocation 6 | # @!attribute arguments [#call] The block passed to the method 7 | # @!attribute matching_configuration [MethodMatchingConfiguration] The configuration that will 8 | # be used to match the method invocation at replay mode 9 | 10 | def to_s 11 | string = name.to_s 12 | 13 | arguments_string = arguments&.collect(&:to_s)&.join(', ') 14 | 15 | string << "(#{arguments_string})" 16 | string << ' {with block}' if block 17 | string 18 | end 19 | 20 | # The spy used to spy the block yield invocations 21 | # 22 | # @return [BlockSpy] 23 | def block_spy 24 | return nil if !@block_spy && !block 25 | 26 | @block_spy ||= BlockSpy.new(actual_block: block) 27 | end 28 | 29 | def init_with(coder) 30 | self.name = coder['name'] 31 | self.arguments = coder['arguments'] 32 | self.matching_configuration = coder['matching_configuration'] 33 | @block_spy = coder['block_spy'] 34 | end 35 | 36 | def encode_with(coder) 37 | coder['name'] = name 38 | coder['arguments'] = arguments 39 | coder['block_spy'] = block_spy 40 | coder['matching_configuration'] = matching_configuration 41 | end 42 | 43 | def ==(other_method) 44 | my_arguments = arguments.dup 45 | other_arguments = other_method.arguments.dup 46 | matching_configuration&.ignored_positions&.each do |ignored_position| 47 | my_arguments.delete_at(ignored_position) 48 | other_arguments.delete_at(ignored_position) 49 | end 50 | 51 | name == other_method.name && my_arguments == other_arguments && 52 | !block_spy == !other_method.block_spy 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/impersonator/method_invocation.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # A method invocation groups a {Method method instance} and a return value 3 | MethodInvocation = Struct.new(:method_instance, :return_value, keyword_init: true) 4 | end 5 | -------------------------------------------------------------------------------- /lib/impersonator/method_matching_configuration.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # Configuration options for matching methods 3 | class MethodMatchingConfiguration 4 | attr_reader :ignored_positions 5 | 6 | def initialize 7 | @ignored_positions = [] 8 | end 9 | 10 | # Configure positions to ignore 11 | # 12 | # @param [Array] positions The positions of arguments to ignore (0 being the first one) 13 | def ignore_arguments_at(*positions) 14 | ignored_positions.push(*positions) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/impersonator/proxy.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # A proxy represents the impersonated object at both record and replay times. 3 | # 4 | # For not impersonated methods, it will just delegate to the impersonate object. For impersonated 5 | # methods, it will interact with the {Recording recording} for recording or replaying the object 6 | # interactions. 7 | class Proxy 8 | include HasLogger 9 | 10 | attr_reader :impersonated_object 11 | 12 | # @param [Object] impersonated_object 13 | # @param [Recording] recording 14 | # @param [Array] impersonated_methods The methods to impersonate 15 | def initialize(impersonated_object, recording:, impersonated_methods:) 16 | validate_object_has_methods_to_impersonate!(impersonated_object, impersonated_methods) 17 | 18 | @impersonated_object = impersonated_object 19 | @impersonated_methods = impersonated_methods.collect(&:to_sym) 20 | @recording = recording 21 | @method_matching_configurations_by_method = {} 22 | end 23 | 24 | def method_missing(method_name, *args, &block) 25 | if @impersonated_methods.include?(method_name.to_sym) 26 | invoke_impersonated_method(method_name, *args, &block) 27 | else 28 | @impersonated_object.send(method_name, *args, &block) 29 | end 30 | end 31 | 32 | def respond_to_missing?(method_name, *args) 33 | impersonated_object.respond_to_missing?(method_name, *args) 34 | end 35 | 36 | # Configure matching options for a given method 37 | # 38 | # ```ruby 39 | # impersonator.configure_method_matching_for(:add) do |config| 40 | # config.ignore_arguments_at 0 41 | # end 42 | # ``` 43 | # 44 | # @param [String, Symbol] method The method to configure matching options for 45 | # @yieldparam config [MethodMatchingConfiguration] 46 | def configure_method_matching_for(method) 47 | method_matching_configurations_by_method[method.to_sym] ||= MethodMatchingConfiguration.new 48 | yield method_matching_configurations_by_method[method] 49 | end 50 | 51 | private 52 | 53 | attr_reader :recording, :impersonated_methods, :method_matching_configurations_by_method 54 | 55 | def validate_object_has_methods_to_impersonate!(object, methods_to_impersonate) 56 | missing_methods = methods_to_impersonate.find_all do |method| 57 | !object.respond_to?(method.to_sym) 58 | end 59 | 60 | unless missing_methods.empty? 61 | raise Impersonator::Errors::ConfigurationError, 'These methods to impersonate does not'\ 62 | "exist: #{missing_methods.inspect}" 63 | end 64 | end 65 | 66 | def invoke_impersonated_method(method_name, *args, &block) 67 | matching_configuration = method_matching_configurations_by_method[method_name.to_sym] 68 | method = Method.new(name: method_name, arguments: args, block: block, 69 | matching_configuration: matching_configuration) 70 | recording.invoke(@impersonated_object, method, args) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/impersonator/record_mode.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # The state of a {Recording recording} in record mode 3 | class RecordMode 4 | include HasLogger 5 | 6 | # recording file path 7 | attr_reader :recording_path 8 | 9 | # @param [String] recording_path the file path to the recording file 10 | def initialize(recording_path) 11 | @recording_path = recording_path 12 | end 13 | 14 | # Start a recording session 15 | def start 16 | logger.debug 'Recording mode' 17 | make_sure_recordings_dir_exists 18 | @method_invocations = [] 19 | end 20 | 21 | # Records the method invocation 22 | # 23 | # @param [Object, Double] impersonated_object 24 | # @param [MethodInvocation] method 25 | # @param [Array] args 26 | def invoke(impersonated_object, method, args) 27 | spiable_block = method&.block_spy&.block 28 | impersonated_object.send(method.name, *args, &spiable_block).tap do |return_value| 29 | record(method, return_value) 30 | end 31 | end 32 | 33 | # Finishes the record session 34 | def finish 35 | File.open(recording_path, 'w') do |file| 36 | YAML.dump(@method_invocations, file) 37 | end 38 | end 39 | 40 | private 41 | 42 | # Record a {MethodInvocation method invocation} with a given return value 43 | # @param [Method] method 44 | # @param [Object] return_value 45 | def record(method, return_value) 46 | method_invocation = MethodInvocation.new(method_instance: method, return_value: return_value) 47 | 48 | @method_invocations << method_invocation 49 | end 50 | 51 | def make_sure_recordings_dir_exists 52 | dirname = File.dirname(recording_path) 53 | FileUtils.mkdir_p(dirname) unless File.directory?(dirname) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/impersonator/recording.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Impersonator 4 | # A recording is responsible for saving interactions at record time, and replaying them at 5 | # replay time. 6 | # 7 | # A recording is always in one of two states. 8 | # 9 | # * {RecordMode Record mode} 10 | # * {ReplayMode Replay mode} 11 | # 12 | # The state objects are responsible of dealing with the recording logic, which happens in 3 13 | # moments: 14 | # 15 | # * {#start} 16 | # * {#invoke} 17 | # * {#finish} 18 | # 19 | # @see RecordMode 20 | # @see ReplayMode 21 | class Recording 22 | include HasLogger 23 | 24 | attr_reader :label 25 | 26 | # @param [String] label 27 | # @param [Boolean] disabled `true` for always working in *record* mode. `false` by default 28 | # @param [String] the path to save recordings to 29 | def initialize(label, disabled: false, recordings_path:) 30 | @label = label 31 | @recordings_path = recordings_path 32 | @disabled = disabled 33 | 34 | initialize_current_mode 35 | end 36 | 37 | # Start a recording/replay session 38 | def start 39 | logger.debug "Starting recording #{label}..." 40 | current_mode.start 41 | end 42 | 43 | # Handles the invocation of a given method on the impersonated object 44 | # 45 | # It will either record the interaction or replay it dependening on if there 46 | # is a recording available or not 47 | # 48 | # @param [Object, Double] impersonated_object 49 | # @param [MethodInvocation] method 50 | # @param [Array] args 51 | def invoke(impersonated_object, method, args) 52 | current_mode.invoke(impersonated_object, method, args) 53 | end 54 | 55 | # Finish a record/replay session. 56 | def finish 57 | logger.debug "Recording #{label} finished" 58 | current_mode.finish 59 | end 60 | 61 | # Return whether it is currently at replay mode 62 | # 63 | # @return [Boolean] 64 | def replay_mode? 65 | @current_mode == replay_mode 66 | end 67 | 68 | # Return whether it is currently at record mode 69 | # 70 | # @return [Boolean] 71 | def record_mode? 72 | !replay_mode? 73 | end 74 | 75 | private 76 | 77 | attr_reader :current_mode 78 | 79 | def initialize_current_mode 80 | @current_mode = if can_replay? 81 | replay_mode 82 | else 83 | record_mode 84 | end 85 | end 86 | 87 | def can_replay? 88 | !@disabled && File.exist?(recording_path) 89 | end 90 | 91 | def record_mode 92 | @record_mode ||= RecordMode.new(recording_path) 93 | end 94 | 95 | def replay_mode 96 | @replay_mode ||= ReplayMode.new(recording_path) 97 | end 98 | 99 | def recording_path 100 | File.join(@recordings_path, "#{label_as_file_name}.yml") 101 | end 102 | 103 | def label_as_file_name 104 | label.downcase.gsub(/[\(\)\s \#:]/, '-').gsub(/[\-]+/, '-').gsub(/(^-)|(-$)/, '') 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/impersonator/replay_mode.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | # The state of a {Recording recording} in replay mode 3 | class ReplayMode 4 | include HasLogger 5 | 6 | # recording file path 7 | attr_reader :recording_path 8 | 9 | # @param [String] recording_path the file path to the recording file 10 | def initialize(recording_path) 11 | @recording_path = recording_path 12 | end 13 | 14 | # Start a replay session 15 | def start 16 | logger.debug 'Replay mode' 17 | @replay_mode = true 18 | @method_invocations = YAML.load_file(recording_path) 19 | end 20 | 21 | # Replays the method invocation 22 | # 23 | # @param [Object, Double] impersonated_object not used in replay mode 24 | # @param [MethodInvocation] method 25 | # @param [Array] args not used in replay mode 26 | def invoke(_impersonated_object, method, _args) 27 | method_invocation = @method_invocations.shift 28 | unless method_invocation 29 | raise Impersonator::Errors::MethodInvocationError, 'Unexpected method invocation received:'\ 30 | "#{method}" 31 | end 32 | 33 | validate_method_signature!(method, method_invocation.method_instance) 34 | replay_block(method_invocation, method) 35 | 36 | method_invocation.return_value 37 | end 38 | 39 | # Finishes the record session 40 | def finish 41 | unless @method_invocations.empty? 42 | raise Impersonator::Errors::MethodInvocationError, 43 | "Expecting #{@method_invocations.length} method invocations"\ 44 | " that didn't happen: #{@method_invocations.inspect}" 45 | end 46 | end 47 | 48 | private 49 | 50 | def replay_block(recorded_method_invocation, method_to_replay) 51 | block_spy = recorded_method_invocation.method_instance.block_spy 52 | block_spy&.block_invocations&.each do |block_invocation| 53 | method_to_replay.block.call(*block_invocation.arguments) 54 | end 55 | end 56 | 57 | def validate_method_signature!(expected_method, actual_method) 58 | unless actual_method == expected_method 59 | raise Impersonator::Errors::MethodInvocationError, <<~ERROR 60 | Expecting: 61 | #{expected_method} 62 | But received: 63 | #{actual_method} 64 | ERROR 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/impersonator/version.rb: -------------------------------------------------------------------------------- 1 | module Impersonator 2 | VERSION = '0.1.3'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Impersonator.configure', clear_recordings: true do 2 | let(:actual_calculator) { Test::Calculator.new } 3 | 4 | describe '#recordings_path' do 5 | it 'defaults to spec/recordings' do 6 | validate_generates_fixture('spec/recordings/test-recording.yml') 7 | end 8 | 9 | it 'can change the place where recordings are generated' do 10 | validate_generates_fixture('spec/recordings/myfolder/test-recording.yml') do 11 | Impersonator.configure do |config| 12 | config.recordings_path = 'spec/recordings/myfolder' 13 | end 14 | end 15 | end 16 | end 17 | 18 | def validate_generates_fixture(expected_file_path) 19 | expect(File.exist?(expected_file_path)).to be_falsey 20 | yield if block_given? 21 | 22 | Impersonator.recording('test recording') do 23 | impersonator = Impersonator.impersonate_methods(actual_calculator, :next) 24 | impersonator.next 25 | expect(actual_calculator).to be_invoked 26 | end 27 | 28 | expect(File.exist?(expected_file_path)).to be_truthy 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/double_spec.rb: -------------------------------------------------------------------------------- 1 | describe Impersonator::Double do 2 | describe '#initialize' do 3 | it 'generates methods for the list of names passed in' do 4 | object = described_class.new(:add, :next) 5 | object.add 6 | object.next 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/errors_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Error detection', clear_recordings: true do 2 | let(:actual_calculator) { Test::Calculator.new } 3 | let(:block) { proc {} } 4 | 5 | it 'does not generate recordings when an error is raised' do 6 | begin 7 | Impersonator.recording('error-recording') do 8 | Impersonator.impersonate_methods(actual_calculator, :add) 9 | raise 'Some error' 10 | end 11 | rescue StandardError 12 | end 13 | 14 | expect(File.exist?('spec/recordings/error-recording.yml')).to be_falsey 15 | end 16 | 17 | describe Impersonator::Errors::ConfigurationError do 18 | it 'raises an error when trying to impersonate without starting a recording' do 19 | expect { Impersonator.impersonate_methods(actual_calculator, :next, :previous) } 20 | .to raise_error(Impersonator::Errors::ConfigurationError) 21 | end 22 | 23 | it 'raises an error when the method to impersonate does not exist' do 24 | Impersonator.recording('missing method') do 25 | expect { Impersonator.impersonate_methods(actual_calculator, :some_missing_method) } 26 | .to raise_error(Impersonator::Errors::ConfigurationError) 27 | end 28 | end 29 | end 30 | 31 | describe Impersonator::Errors::MethodInvocationError do 32 | it 'raises an error when there is an invocation that is not recorded' do 33 | Impersonator.recording('simple value') do 34 | impersonator = Impersonator.impersonate_methods(actual_calculator, :next, :previous) 35 | impersonator.next 36 | end 37 | 38 | actual_calculator.reset 39 | 40 | Impersonator.recording('simple value') do 41 | impersonator = Impersonator.impersonate_methods(actual_calculator, :next, :previous) 42 | 43 | impersonator.next 44 | expect { impersonator.next }.to raise_error(Impersonator::Errors::MethodInvocationError) 45 | end 46 | end 47 | 48 | it 'raises an error when invoking method with the wrong arguments in replay mode' do 49 | Impersonator.recording('simple value') do 50 | impersonator = Impersonator.impersonate_methods(actual_calculator, :add) 51 | impersonator.add(1, 2) 52 | end 53 | 54 | actual_calculator.reset 55 | 56 | Impersonator.recording('simple value') do 57 | impersonator = Impersonator.impersonate_methods(actual_calculator, :add) 58 | expect { impersonator.add(3, 4) }.to raise_error(Impersonator::Errors::MethodInvocationError) 59 | end 60 | end 61 | 62 | it 'raises an error when there is an invocation with a not expected a block' do 63 | Impersonator.recording('simple value') do 64 | impersonator = Impersonator.impersonate_methods(actual_calculator, :add, :lineal_sequence) 65 | impersonator.add(1, 2, &block) 66 | impersonator.add(1, 2) 67 | end 68 | 69 | actual_calculator.reset 70 | 71 | Impersonator.recording('simple value') do 72 | impersonator = Impersonator.impersonate_methods(actual_calculator, :add, :lineal_sequence) 73 | 74 | impersonator.add(1, 2, &block) 75 | expect { impersonator.add(1, 2, &block) } 76 | .to raise_error(Impersonator::Errors::MethodInvocationError) 77 | end 78 | end 79 | 80 | it 'raises an error when there is an invocation missing a block' do 81 | Impersonator.recording('simple value') do 82 | impersonator = Impersonator.impersonate_methods(actual_calculator, :add, :lineal_sequence) 83 | impersonator.add(1, 2, &block) 84 | impersonator.add(1, 2, &block) 85 | end 86 | 87 | actual_calculator.reset 88 | 89 | Impersonator.recording('simple value') do 90 | impersonator = Impersonator.impersonate_methods(actual_calculator, :add, :lineal_sequence) 91 | 92 | impersonator.add(1, 2, &block) 93 | expect { impersonator.add(1, 2) }.to raise_error(Impersonator::Errors::MethodInvocationError) 94 | end 95 | end 96 | end 97 | 98 | it 'raises an error when there more recorded invocations that actual invocations' do 99 | Impersonator.recording('simple value') do 100 | impersonator = Impersonator.impersonate_methods(actual_calculator, :next, :previous) 101 | impersonator.next 102 | impersonator.next 103 | end 104 | 105 | actual_calculator.reset 106 | 107 | expect do 108 | Impersonator.recording('simple value') do 109 | impersonator = Impersonator.impersonate_methods(actual_calculator, :next, :previous) 110 | 111 | impersonator.next 112 | end 113 | end.to raise_error(Impersonator::Errors::MethodInvocationError) 114 | end 115 | end 116 | 117 | describe 'dummy' do 118 | it 'test me' do 119 | class Calculator 120 | def add(number_1, number_2) 121 | number_1 + number_2 122 | end 123 | end 124 | 125 | # The first time it records... 126 | Impersonator.recording('calculator add') do 127 | impersonated_calculator = Impersonator.impersonate(:add) { Calculator.new } 128 | puts impersonated_calculator.add(2, 3) # 5 129 | end 130 | 131 | # The next time it replays 132 | Object.send :remove_const, :Calculator # Calculator does not even have to exist now 133 | Impersonator.recording('calculator add') do 134 | impersonated_calculator = Impersonator.impersonate(:add) { Calculator.new } 135 | puts impersonated_calculator.add(2, 3) # 5 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/impersonate_double_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe 'Impersonate double', clear_recordings: true do 4 | let(:actual_calculator) { Test::Calculator.new } 5 | 6 | it "doesn't use the real object in recording mode" do 7 | Impersonator.recording('test double recording') do 8 | impersonator = Impersonator.impersonate(:next) { actual_calculator } 9 | 10 | expect(impersonator.next).to eq(1) 11 | expect(actual_calculator).to be_invoked 12 | end 13 | 14 | actual_calculator.reset 15 | 16 | Impersonator.recording('test double recording') do 17 | impersonator = Impersonator.impersonate(:next) { raise 'This should never be invoked' } 18 | 19 | expect(impersonator.next).to eq(1) 20 | expect(actual_calculator).not_to be_invoked 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/impersonate_methods_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Impersonate', clear_recordings: true do 2 | let(:actual_calculator) { Test::Calculator.new } 3 | 4 | context 'with methods without arguments' do 5 | it 'records and impersonates a method that return a simple value' do 6 | test_impersonation do |impersonator| 7 | expect(impersonator.next).to eq(1) 8 | end 9 | end 10 | 11 | it 'records and impersonates multiple invocations in a row of a method that returns a value' do 12 | test_impersonation do |impersonator| 13 | expect(impersonator.next).to eq(1) 14 | expect(impersonator.next).to eq(2) 15 | expect(impersonator.next).to eq(3) 16 | end 17 | end 18 | 19 | it 'records and impersonates multiple invocations of multiple methods combined' do 20 | test_impersonation do |impersonator| 21 | expect(impersonator.next).to eq(1) 22 | expect(impersonator.next).to eq(2) 23 | expect(impersonator.previous).to eq(1) 24 | expect(impersonator.previous).to eq(0) 25 | expect(impersonator.next).to eq(1) 26 | end 27 | end 28 | end 29 | 30 | context 'with methods with arguments' do 31 | it 'records and impersonates invocations of methods with arguments' do 32 | test_impersonation do |impersonator| 33 | expect(impersonator.add(1, 2)).to eq(3) 34 | expect(impersonator.add(3, 4)).to eq(7) 35 | end 36 | end 37 | 38 | it 'can ignore arguments when matching methods' do 39 | Impersonator.recording('test recording') do 40 | impersonator = Impersonator.impersonate(:add) { Test::Calculator.new } 41 | impersonator.configure_method_matching_for(:add) do |config| 42 | config.ignore_arguments_at 0 43 | end 44 | 45 | expect(impersonator.add(1, 2)).to eq(3) 46 | end 47 | 48 | actual_calculator.reset 49 | 50 | Impersonator.recording('test recording') do 51 | impersonator = Impersonator.impersonate(:add) { Test::Calculator.new } 52 | 53 | expect(impersonator.add(99999999, 2)).to eq(3) 54 | end 55 | end 56 | end 57 | 58 | context 'with methods yielding to blocks' do 59 | it 'replays the yielded values' do 60 | test_impersonation do |impersonator| 61 | expect { |block| impersonator.add(1, 2, &block) }.to yield_with_args(3) 62 | end 63 | end 64 | 65 | it 'replays yielding multiple times' do 66 | test_impersonation do |impersonator| 67 | expect { |block| impersonator.lineal_sequence(3, &block) }.to yield_successive_args(1, 2, 3) 68 | end 69 | end 70 | end 71 | 72 | describe 'Configuration' do 73 | it 'can disable replay mode' do 74 | Impersonator.recording('test recording') do 75 | build_impersonator.next 76 | expect(actual_calculator).to be_invoked 77 | end 78 | 79 | actual_calculator.reset 80 | 81 | Impersonator.recording('test recording', disabled: true) do 82 | build_impersonator.next 83 | expect(actual_calculator).to be_invoked 84 | end 85 | end 86 | end 87 | 88 | def test_impersonation(&block) 89 | Impersonator.recording('test recording') do 90 | impersonator = build_impersonator 91 | 92 | block.call(impersonator) 93 | expect(actual_calculator).to be_invoked 94 | end 95 | 96 | actual_calculator.reset 97 | 98 | Impersonator.recording('test recording') do 99 | impersonator = build_impersonator 100 | block.call(impersonator) 101 | expect(actual_calculator).not_to be_invoked 102 | end 103 | end 104 | 105 | def build_impersonator 106 | Impersonator.impersonate_methods(actual_calculator, :next, :previous, :add, :lineal_sequence) 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/recording_file_generation_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Recording file generation', clear_recordings: true do 2 | let(:actual_calculator) { Test::Calculator.new } 3 | 4 | it 'generates a fixture file named after the recording label by replacing spaces with -' do 5 | validate_fixture_was_generated for_label: 'my example', expected_file_name: 'my-example.yml' 6 | end 7 | 8 | it 'eliminates symbols from label when generating file names' do 9 | validate_fixture_was_generated for_label: '((my()#(example::))', 10 | expected_file_name: 'my-example.yml' 11 | end 12 | 13 | def validate_fixture_was_generated(for_label:, expected_file_name:) 14 | expected_file_path = "spec/recordings/#{expected_file_name}" 15 | 16 | expect(File.exist?(expected_file_path)).to be_falsey 17 | yield if block_given? 18 | 19 | Impersonator.recording(for_label) do 20 | impersonator = Impersonator.impersonate_methods(actual_calculator, :next) 21 | impersonator.next 22 | expect(actual_calculator).to be_invoked 23 | end 24 | 25 | expect(File.exist?(expected_file_path)).to be_truthy 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'impersonator' 3 | require 'zeitwerk' 4 | 5 | RSpec.configure do |config| 6 | # Enable flags like --only-failures and --next-failure 7 | config.example_status_persistence_file_path = '.rspec_status' 8 | 9 | # Disable RSpec exposing methods globally on `Module` and `main` 10 | config.disable_monkey_patching! 11 | 12 | config.expect_with :rspec do |c| 13 | c.syntax = :expect 14 | end 15 | 16 | config.expose_dsl_globally = true 17 | 18 | ::Impersonator.logger.level = Logger::DEBUG 19 | 20 | Dir['spec/support/hooks/**/*.rb'].each do |f| 21 | load f 22 | end 23 | 24 | loader = Zeitwerk::Loader.for_gem 25 | loader.push_dir('spec/support') 26 | loader.setup 27 | 28 | config.include Test::FileHelpers 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/hooks/clear_recordings_hook.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.before do 3 | clear_recordings_dir 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/hooks/reset_hook.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.before do 3 | Impersonator.reset 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/test/calculator.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | # Dummy class to test method invocations 3 | class Calculator 4 | def initialize 5 | @counter = 0 6 | end 7 | 8 | def invoked? 9 | @invoked 10 | end 11 | 12 | def reset 13 | @invoked = false 14 | end 15 | 16 | def next 17 | invoked! 18 | @counter += 1 19 | end 20 | 21 | def previous 22 | invoked! 23 | @counter -= 1 24 | end 25 | 26 | def add(number_1, number_2) 27 | invoked! 28 | result = number_1 + number_2 29 | if block_given? 30 | yield result 31 | else 32 | result 33 | end 34 | end 35 | 36 | # Will yield 1, 2, 3 ... n 37 | def lineal_sequence(n) 38 | invoked! 39 | 40 | n.times do |index| 41 | yield index + 1 42 | end 43 | end 44 | 45 | private 46 | 47 | def invoked! 48 | @invoked = true 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/support/test/file_helpers.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | module FileHelpers 3 | def clear_recordings_dir 4 | FileUtils.rm_rf(Dir.glob('spec/recordings/**/*')) 5 | end 6 | end 7 | end 8 | --------------------------------------------------------------------------------