impersonator 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c27e2c12811df79ee6ecda2a617b9952ac98dff360ca3e08d725832d54cf5b03
4
+ data.tar.gz: d40825fb2fbbdc7ac842bf58864acc2c02f2dd72b3a228b1ee0ee9ded86f891b
5
+ SHA512:
6
+ metadata.gz: e8039d138b2f899a27d9476873ce687586a5322a7d0846a2828207592f51fd6a80f134a54fc7dce99cc8ea1d8581cdae25f62382e2129054d2148d1d05360543
7
+ data.tar.gz: e39e557b268d09c24206d7db469fa7dd931002e0cf9b6b72b70e25ba2f87270f43b176170b43e83ae7b8fa0e26999c7e956afb1b5ac83a1fd2f0327359124e94
@@ -0,0 +1,71 @@
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
+
@@ -0,0 +1,12 @@
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
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,138 @@
1
+ require: rubocop-rspec
2
+
3
+ Style/FrozenStringLiteralComment:
4
+ Enabled: false
5
+
6
+ Metrics/LineLength:
7
+ Enabled: false
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/**/*"
@@ -0,0 +1 @@
1
+ 2.6.3
@@ -0,0 +1,7 @@
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
data/Gemfile ADDED
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,59 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ impersonator (0.1.1)
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.6)
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
@@ -0,0 +1,21 @@
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.
@@ -0,0 +1,200 @@
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
+ 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
+ impersonated_calculator = Impersonator.impersonate(:add) { Calculator.new }
51
+ puts impersonated_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
+ impersonated_calculator = Impersonator.impersonate(:add) { Calculator.new }
58
+ puts impersonated_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
+ - [Blog post](https://www.jorgemanrubia.com/2019/06/16/impersonator-a-ruby-library-to-record-and-replay-object-interactions/)
193
+
194
+ ## Contributing
195
+
196
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jorgemanrubia/impersonator.
197
+
198
+ ## License
199
+
200
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).`
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,14 @@
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__)
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,40 @@
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 when testing (just because, for http, VCR is probably what you want)'
13
+ spec.homepage = 'https://github.com/jorgemanrubia/impersonator'
14
+ spec.license = 'MIT'
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ if spec.respond_to?(:metadata)
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/jorgemanrubia/impersonator'
21
+ else
22
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
23
+ 'public gem pushes.'
24
+ end
25
+
26
+ # Specify which files should be added to the gem when it is released.
27
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
28
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
29
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
30
+ end
31
+ spec.bindir = 'exe'
32
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ['lib']
34
+
35
+ spec.add_runtime_dependency 'zeitwerk', '~> 2.1.6'
36
+ spec.add_development_dependency 'bundler', '~> 1.17'
37
+ spec.add_development_dependency 'rake', '~> 10.0'
38
+ spec.add_development_dependency 'rspec', '~> 3.0'
39
+ spec.add_development_dependency 'rspec_junit_formatter'
40
+ end
@@ -0,0 +1,20 @@
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
+ def self.logger
15
+ @logger ||= ::Logger.new(STDOUT).tap do |logger|
16
+ logger.level = Logger::WARN
17
+ logger.datetime_format = '%Y-%m-%d %H:%M:%S'
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,68 @@
1
+ module Impersonator
2
+ module Api
3
+ def recording(label, disabled: false)
4
+ @current_recording = ::Impersonator::Recording.new(label, disabled: disabled, recordings_path: configuration.recordings_path)
5
+ @current_recording.start
6
+ yield
7
+ @current_recording.finish
8
+ ensure
9
+ @current_recording = nil
10
+ end
11
+
12
+ def current_recording
13
+ @current_recording
14
+ end
15
+
16
+ def configure
17
+ yield configuration
18
+ end
19
+
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ # Reset configuration and other global state.
25
+ #
26
+ # It is meant to be used internally by tests.
27
+ def reset
28
+ @current_recording = nil
29
+ @configuration = nil
30
+ end
31
+
32
+ # Receives a list of methods to impersonate and a block that will be used, at record time, to
33
+ # instantiate the object to impersonate. At replay time, it will generate a double that will
34
+ # replay the methods.
35
+ #
36
+ # impersonator = Impersonator.impersonate(:add, :subtract) { Calculator.new }
37
+ # impersonator.add(3, 4)
38
+ #
39
+ # Notice that the actual object won't be instantiated in record mode. For that reason, the impersonated
40
+ # object will only respond to the list of impersonated methods.
41
+ #
42
+ # If you need to invoke other (not impersonated) methods see #impersonate_method instead.
43
+ #
44
+ # @return [Object] the impersonated object
45
+ def impersonate(*methods)
46
+ raise ArgumentError, 'Provide a block to instantiate the object to impersonate in record mode' unless block_given?
47
+
48
+ object_to_impersonate = if current_recording&.record_mode?
49
+ yield
50
+ else
51
+ Double.new(*methods)
52
+ end
53
+ impersonate_methods(object_to_impersonate, *methods)
54
+ end
55
+
56
+ # Impersonates a list of methods of a given object
57
+ #
58
+ # The returned object will impersonate the list of methods and will delegate the rest of method calls
59
+ # to the actual object.
60
+ #
61
+ # @return [Object] the impersonated object
62
+ def impersonate_methods(actual_object, *methods)
63
+ raise Impersonator::Errors::ConfigurationError, 'You must start a recording to impersonate objects. Use Impersonator.recording {}' unless @current_recording
64
+
65
+ ::Impersonator::Proxy.new(actual_object, recording: current_recording, impersonated_methods: methods)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module Impersonator
2
+ BlockInvocation = Struct.new(:arguments, keyword_init: true)
3
+ end
@@ -0,0 +1,20 @@
1
+ module Impersonator
2
+ BlockSpy = Struct.new(:block_invocations, :actual_block, keyword_init: true) do
3
+ def block
4
+ @block ||= proc do |*arguments|
5
+ self.block_invocations ||= []
6
+ self.block_invocations << BlockInvocation.new(arguments: arguments)
7
+ return_value = actual_block.call(*arguments)
8
+ return_value
9
+ end
10
+ end
11
+
12
+ def init_with(coder)
13
+ self.block_invocations = coder['block_invocations']
14
+ end
15
+
16
+ def encode_with(coder)
17
+ coder['block_invocations'] = block_invocations
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ module Impersonator
2
+ Configuration = Struct.new(:recordings_path, keyword_init: true) do
3
+ DEFAULT_RECORDINGS_FOLDER = 'recordings'.freeze
4
+
5
+ def initialize(*)
6
+ super
7
+ self.recordings_path ||= detect_default_recordings_path
8
+ end
9
+
10
+ private
11
+
12
+ def detect_default_recordings_path
13
+ base_path = File.exist?('spec') ? 'spec' : 'test'
14
+ File.join(base_path, DEFAULT_RECORDINGS_FOLDER)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module Impersonator
2
+ class Double
3
+ def initialize(*methods)
4
+ define_methods(methods)
5
+ end
6
+
7
+ private
8
+
9
+ def define_methods(methods)
10
+ methods.each do |method|
11
+ self.class.define_method(method) {}
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ module Impersonator
2
+ module Errors
3
+ class ConfigurationError < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Impersonator
2
+ module Errors
3
+ class MethodInvocationError < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module Impersonator
2
+ module HasLogger
3
+ def logger
4
+ ::Impersonator.logger
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,44 @@
1
+ module Impersonator
2
+ Method = Struct.new(:name, :arguments, :block, :matching_configuration, keyword_init: true) do
3
+ def to_s
4
+ string = name.to_s
5
+
6
+ arguments_string = arguments&.collect(&:to_s)&.join(', ')
7
+
8
+ string << "(#{arguments_string})"
9
+ string << ' {with block}' if block
10
+ string
11
+ end
12
+
13
+ def block_spy
14
+ return nil if !@block_spy && !block
15
+
16
+ @block_spy ||= BlockSpy.new(actual_block: block)
17
+ end
18
+
19
+ def init_with(coder)
20
+ self.name = coder['name']
21
+ self.arguments = coder['arguments']
22
+ self.matching_configuration = coder['matching_configuration']
23
+ @block_spy = coder['block_spy']
24
+ end
25
+
26
+ def encode_with(coder)
27
+ coder['name'] = name
28
+ coder['arguments'] = arguments
29
+ coder['block_spy'] = block_spy
30
+ coder['matching_configuration'] = matching_configuration
31
+ end
32
+
33
+ def ==(other_method)
34
+ my_arguments = arguments.dup
35
+ other_arguments = other_method.arguments.dup
36
+ matching_configuration&.ignored_positions&.each do |ignored_position|
37
+ my_arguments.delete_at(ignored_position)
38
+ other_arguments.delete_at(ignored_position)
39
+ end
40
+
41
+ name == other_method.name && my_arguments == other_arguments && !block_spy == !other_method.block_spy
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,3 @@
1
+ module Impersonator
2
+ MethodInvocation = Struct.new(:method_instance, :return_value, keyword_init: true)
3
+ end
@@ -0,0 +1,13 @@
1
+ module Impersonator
2
+ class MethodMatchingConfiguration
3
+ attr_reader :ignored_positions
4
+
5
+ def initialize
6
+ @ignored_positions = []
7
+ end
8
+
9
+ def ignore_arguments_at(*positions)
10
+ ignored_positions.push(*positions)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,56 @@
1
+ module Impersonator
2
+ class Proxy
3
+ include HasLogger
4
+
5
+ attr_reader :impersonated_object
6
+
7
+ def initialize(impersonated_object, recording:, impersonated_methods:)
8
+ validate_object_has_methods_to_impersonate!(impersonated_object, impersonated_methods)
9
+
10
+ @impersonated_object = impersonated_object
11
+ @impersonated_methods = impersonated_methods.collect(&:to_sym)
12
+ @recording = recording
13
+ @method_matching_configurations_by_method = {}
14
+ end
15
+
16
+ def method_missing(method_name, *args, &block)
17
+ if @impersonated_methods.include?(method_name.to_sym)
18
+ invoke_impersonated_method(method_name, *args, &block)
19
+ else
20
+ @impersonated_object.send(method_name, *args, &block)
21
+ end
22
+ end
23
+
24
+ def respond_to_missing?(method_name, *args)
25
+ impersonated_object.respond_to_missing?(method_name, *args)
26
+ end
27
+
28
+ def configure_method_matching_for(method)
29
+ method_matching_configurations_by_method[method.to_sym] ||= MethodMatchingConfiguration.new
30
+ yield method_matching_configurations_by_method[method]
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :recording, :impersonated_methods, :method_matching_configurations_by_method
36
+
37
+ def validate_object_has_methods_to_impersonate!(object, methods_to_impersonate)
38
+ missing_methods = methods_to_impersonate.find_all do |method|
39
+ !object.respond_to?(method.to_sym)
40
+ end
41
+
42
+ raise Impersonator::Errors::ConfigurationError, "These methods to impersonate does not exist: #{missing_methods.inspect}" unless missing_methods.empty?
43
+ end
44
+
45
+ def invoke_impersonated_method(method_name, *args, &block)
46
+ method = Method.new(name: method_name, arguments: args, block: block, matching_configuration: method_matching_configurations_by_method[method_name.to_sym])
47
+ if recording.replay_mode?
48
+ recording.replay(method)
49
+ else
50
+ @impersonated_object.send(method_name, *args, &method&.block_spy&.block).tap do |return_value|
51
+ recording.record(method, return_value)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Impersonator
4
+ class Recording
5
+ include HasLogger
6
+
7
+ attr_reader :label
8
+
9
+ def initialize(label, disabled: false, recordings_path:)
10
+ @label = label
11
+ @recordings_path = recordings_path
12
+ @disabled = disabled
13
+ end
14
+
15
+ def start
16
+ logger.debug "Starting recording #{label}..."
17
+ if can_replay?
18
+ start_in_replay_mode
19
+ else
20
+ start_in_record_mode
21
+ end
22
+ end
23
+
24
+ def record(method, return_value)
25
+ method_invocation = MethodInvocation.new(method_instance: method, return_value: return_value)
26
+
27
+ @method_invocations << method_invocation
28
+ end
29
+
30
+ def replay(method)
31
+ method_invocation = @method_invocations.shift
32
+ raise Impersonator::Errors::MethodInvocationError, "Unexpected method invocation received: #{method}" unless method_invocation
33
+
34
+ validate_method_signature!(method, method_invocation.method_instance)
35
+ replay_block(method_invocation, method)
36
+
37
+ method_invocation.return_value
38
+ end
39
+
40
+ def finish
41
+ logger.debug "Recording #{label} finished"
42
+ if record_mode?
43
+ finish_in_record_mode
44
+ else
45
+ finish_in_replay_mode
46
+ end
47
+ end
48
+
49
+ def replay_mode?
50
+ @replay_mode
51
+ end
52
+
53
+ def record_mode?
54
+ !replay_mode?
55
+ end
56
+
57
+ private
58
+
59
+ def can_replay?
60
+ !@disabled && File.exist?(file_path)
61
+ end
62
+
63
+ def replay_block(recorded_method_invocation, method_to_replay)
64
+ block_spy = recorded_method_invocation.method_instance.block_spy
65
+ block_spy&.block_invocations&.each do |block_invocation|
66
+ method_to_replay.block.call(*block_invocation.arguments)
67
+ end
68
+ end
69
+
70
+ def start_in_replay_mode
71
+ logger.debug 'Replay mode'
72
+ @replay_mode = true
73
+ @method_invocations = YAML.load_file(file_path)
74
+ end
75
+
76
+ def start_in_record_mode
77
+ logger.debug 'Recording mode'
78
+ @replay_mode = false
79
+ make_sure_recordings_dir_exists
80
+ @method_invocations = []
81
+ end
82
+
83
+ def finish_in_record_mode
84
+ File.open(file_path, 'w') do |file|
85
+ YAML.dump(@method_invocations, file)
86
+ end
87
+ end
88
+
89
+ def finish_in_replay_mode
90
+ unless @method_invocations.empty?
91
+ raise Impersonator::Errors::MethodInvocationError, "Expecting #{@method_invocations.length} method invocations"\
92
+ " that didn't happen: #{@method_invocations.inspect}"
93
+ end
94
+ end
95
+
96
+ def file_path
97
+ File.join(@recordings_path, "#{label_as_file_name}.yml")
98
+ end
99
+
100
+ def label_as_file_name
101
+ label.downcase.gsub(/[\(\)\s \#:]/, '-').gsub(/[\-]+/, '-').gsub(/(^-)|(-$)/, '')
102
+ end
103
+
104
+ def make_sure_recordings_dir_exists
105
+ dirname = File.dirname(file_path)
106
+ FileUtils.mkdir_p(dirname) unless File.directory?(dirname)
107
+ end
108
+
109
+ def validate_method_signature!(expected_method, actual_method)
110
+ unless actual_method == expected_method
111
+ raise Impersonator::Errors::MethodInvocationError, <<~ERROR
112
+ Expecting:
113
+ #{expected_method}
114
+ But received:
115
+ #{actual_method}
116
+ ERROR
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,3 @@
1
+ module Impersonator
2
+ VERSION = '0.1.1'.freeze
3
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: impersonator
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Jorge Manrubia
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-06-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: zeitwerk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.1.6
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.1.6
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.17'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.17'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec_junit_formatter
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: Record and replay object interactions. Ideal for mocking not-http services
84
+ when testing (just because, for http, VCR is probably what you want)
85
+ email:
86
+ - jorge.manrubia@gmail.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".circleci/config.yml"
92
+ - ".gitignore"
93
+ - ".rspec"
94
+ - ".rubocop.yml"
95
+ - ".ruby-version"
96
+ - ".travis.yml"
97
+ - Gemfile
98
+ - Gemfile.lock
99
+ - LICENSE.txt
100
+ - README.md
101
+ - Rakefile
102
+ - bin/console
103
+ - bin/setup
104
+ - impersonator.gemspec
105
+ - lib/impersonator.rb
106
+ - lib/impersonator/api.rb
107
+ - lib/impersonator/block_invocation.rb
108
+ - lib/impersonator/block_spy.rb
109
+ - lib/impersonator/configuration.rb
110
+ - lib/impersonator/double.rb
111
+ - lib/impersonator/errors/configuration_error.rb
112
+ - lib/impersonator/errors/method_invocation_error.rb
113
+ - lib/impersonator/has_logger.rb
114
+ - lib/impersonator/method.rb
115
+ - lib/impersonator/method_invocation.rb
116
+ - lib/impersonator/method_matching_configuration.rb
117
+ - lib/impersonator/proxy.rb
118
+ - lib/impersonator/recording.rb
119
+ - lib/impersonator/version.rb
120
+ homepage: https://github.com/jorgemanrubia/impersonator
121
+ licenses:
122
+ - MIT
123
+ metadata:
124
+ homepage_uri: https://github.com/jorgemanrubia/impersonator
125
+ source_code_uri: https://github.com/jorgemanrubia/impersonator
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubygems_version: 3.0.3
142
+ signing_key:
143
+ specification_version: 4
144
+ summary: Generate test stubs that replay recorded interactions
145
+ test_files: []