shoegaze 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +190 -0
- data/lib/shoegaze.rb +15 -0
- data/lib/shoegaze/datastore.rb +36 -0
- data/lib/shoegaze/implementation.rb +71 -0
- data/lib/shoegaze/mock.rb +34 -0
- data/lib/shoegaze/model.rb +165 -0
- data/lib/shoegaze/proxy.rb +24 -0
- data/lib/shoegaze/proxy/interface.rb +169 -0
- data/lib/shoegaze/proxy/template.rb +61 -0
- data/lib/shoegaze/scenario.rb +100 -0
- data/lib/shoegaze/scenario/mock.rb +52 -0
- data/lib/shoegaze/scenario/orchestrator.rb +167 -0
- data/lib/shoegaze/version.rb +3 -0
- data/spec/features/basic_class_method_spec.rb +55 -0
- data/spec/features/basic_instance_method_spec.rb +53 -0
- data/spec/features/class_default_scenario_spec.rb +51 -0
- data/spec/features/class_method_block_argument_spec.rb +61 -0
- data/spec/features/data_store_spec.rb +73 -0
- data/spec/features/instance_customized_initializer.rb +24 -0
- data/spec/features/instance_default_scenario_spec.rb +51 -0
- data/spec/features/instance_method_block_argument_spec.rb +60 -0
- data/spec/features/nested_class_method_spec.rb +47 -0
- data/spec/features/nested_instance_method_spec.rb +47 -0
- data/spec/shoegaze/datastore_spec.rb +38 -0
- data/spec/shoegaze/implementation_spec.rb +97 -0
- data/spec/shoegaze/mock_spec.rb +42 -0
- data/spec/shoegaze/proxy/interface_spec.rb +88 -0
- data/spec/shoegaze/proxy/template_spec.rb +36 -0
- data/spec/shoegaze/proxy_spec.rb +18 -0
- data/spec/shoegaze/scenario/mock_spec.rb +31 -0
- data/spec/shoegaze/scenario/orchestrator_spec.rb +145 -0
- data/spec/shoegaze/scenario_spec.rb +74 -0
- metadata +133 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: ba57e3190d3ef4c36207d28554bb7459ada2e9be71d4e2b8fd02ee26f76bf930
|
4
|
+
data.tar.gz: 0a242d7a0738ee9f2ebd38fb6eff4232bcd541f805f82145611491cccd387a68
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 52ce88092cb6d5fce87931cb38bcc6cbbbb064c91b66029f8d557c4094b0d7fd73f5c073aa9d9f90a1052dd39e57afada10501607a6526e09a2f5d8ff77e3550
|
7
|
+
data.tar.gz: ebe97a469dfc58a2ce1bb23d4f8012b4b5eb2f5a16e92d5f6d4eb61f5143fc8948e08dede4283d7c8060d56f356641b911c0a4cc7f91110010f2c88c70c46721
|
data/README.md
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
# Shoegaze
|
2
|
+
|
3
|
+
[](https://buildkite.com/compose/shoegaze)
|
4
|
+
|
5
|
+
Create mocks of modules (especially clients) with easily-defined scenarios (success, invalid, etc) and an optional in-memory persistence layer.
|
6
|
+
|
7
|
+
[Documentation](http://www.rubydoc.info/github/compose/shoegaze/master)
|
8
|
+
|
9
|
+
## The problem
|
10
|
+
|
11
|
+
When unit testing, libraries that constitute dependencies become cumbersome to stub as complexity increases. Most of the time you want to simulate various high-level scenarios that can be produced in your library. Stubbing those high-level scenarios in your tests with low-level tools can be cumbersome and requires a lot of logic about your dependencies to be sprinked throughout your tests. Outright mocking the libraries in various ways (Webmock, VCR, DIY fake classes) can be tricky.
|
12
|
+
|
13
|
+
## How Shoegaze solves the problem
|
14
|
+
|
15
|
+
When you mock a library using Shoegaze, Shoegaze creates an Rspec double of the library. Using a simple DSL you can specify test `implementations` and their `scenarios` for the mocked library's methods. Swap out your real library implementation for the mock, then drive the high-level behavior in your tests by specifying the scenarios to run. This provides a consistent interface for creating mocks and forces you to mock your library's API, which is the right place to separate the concerns of the library from your tests.
|
16
|
+
|
17
|
+
## Mock Twitter Client Example
|
18
|
+
|
19
|
+
``` ruby
|
20
|
+
class FakeTwitterClient < Shoegaze::Mock
|
21
|
+
# optional. provides in-memory persisted ActiveModel objects so that
|
22
|
+
# your mock can 'remember' its state
|
23
|
+
extend Shoegaze::Datastore
|
24
|
+
|
25
|
+
mock "Twitter::Client"
|
26
|
+
|
27
|
+
# creates both a FakeTwitterClient::Update 'model' and a FactoryBot
|
28
|
+
# factory for the model
|
29
|
+
datastore :Update do
|
30
|
+
id{ BSON::ObjectId.new }
|
31
|
+
date{ Time.new }
|
32
|
+
body{ Faker::Lorem.sentence }
|
33
|
+
location{ [Faker::Address.latitude, Faker::Address.longitude] }
|
34
|
+
end
|
35
|
+
|
36
|
+
implement :update do
|
37
|
+
scenario :success do
|
38
|
+
# optional. you can provide transforms for your 'models' to
|
39
|
+
# represent the data returned by the implementation in various
|
40
|
+
# ways
|
41
|
+
representer do
|
42
|
+
include Representable::JSON
|
43
|
+
|
44
|
+
property :id
|
45
|
+
property :date
|
46
|
+
property :body
|
47
|
+
|
48
|
+
# notice we omit location in this representation
|
49
|
+
end
|
50
|
+
|
51
|
+
# this method will call :as_json on the representer, meaning it
|
52
|
+
# will return a hash. you can also call to_json, for example, to
|
53
|
+
# return stringified JSON instead
|
54
|
+
represent_method :as_json
|
55
|
+
|
56
|
+
datasource do
|
57
|
+
# generate an update and store it in the memory store (you can
|
58
|
+
# grab it with FakerTwitterClient::Update.find(update.id)
|
59
|
+
FactoryBot.create(Update)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
scenario :unavailable do
|
64
|
+
datasource do |id|
|
65
|
+
raise Twitter::ConnectionFailed.new(Struct.new(:status).new(status: "504"))
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# you can mock chained methods by nesting implementations, too
|
71
|
+
#
|
72
|
+
# example: ProductMaker.accounts.create({name: "Jack Dorsey"})
|
73
|
+
implement :accounts do
|
74
|
+
scenario :success do
|
75
|
+
datasource do
|
76
|
+
implement :create do
|
77
|
+
# default scenarios can be specified
|
78
|
+
default do |params|
|
79
|
+
FactoryBot.create(Account, params)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
```
|
88
|
+
|
89
|
+
``` ruby
|
90
|
+
class ProductMaker
|
91
|
+
class << self
|
92
|
+
def create(name)
|
93
|
+
# ...
|
94
|
+
twitter_client.update("We have a new product: #{product.name}!")
|
95
|
+
|
96
|
+
product
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def twitter_client
|
102
|
+
@twitter_client ||= Twitter::Client.new
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
```
|
107
|
+
|
108
|
+
``` ruby
|
109
|
+
RSpec.configure do |config|
|
110
|
+
config.before :each do
|
111
|
+
# swap out the twitter client for the mock in all tests
|
112
|
+
stub_const("Twitter::Client", FakeTwitterClient.proxy)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
```
|
117
|
+
|
118
|
+
```ruby
|
119
|
+
describe ProductMaker do
|
120
|
+
describe "#create" do
|
121
|
+
describe "tweeting" do
|
122
|
+
describe "is successful" do
|
123
|
+
before :each do
|
124
|
+
FakeTwitterClient.calling(:update).with(update.body).yields(:success)
|
125
|
+
end
|
126
|
+
|
127
|
+
it "posts a twitter update" do
|
128
|
+
product = ProductMaker.create("some_product")
|
129
|
+
|
130
|
+
update = FakeTwitterClient::Update.find_by_body("We have a new product: #{product.name}!")
|
131
|
+
expect(update).to exist
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
describe "is unavailable" do
|
136
|
+
before :each do
|
137
|
+
FakeTwitterClient.calling(:update).with(update.body).yields(:unavailable)
|
138
|
+
end
|
139
|
+
|
140
|
+
it "raises a Twitter connection failed error" do
|
141
|
+
expect{ ProductMaker.create("some_product") }.to raise_exception(Twitter::ConnectionFailed)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
```
|
148
|
+
|
149
|
+
# Unit Test Manifesto
|
150
|
+
|
151
|
+
## Inject mock dependencies
|
152
|
+
|
153
|
+
If a component calls a Twitter client library, create a mock of the
|
154
|
+
Twitter client library's API and swap the real implementation for
|
155
|
+
the mock.
|
156
|
+
|
157
|
+
## Steer injected dependencies at the highest practical level (success, failure, etc)
|
158
|
+
|
159
|
+
If you want to test how the component handles Twitter being
|
160
|
+
unavailable, create a scenario in the mock implementation of the
|
161
|
+
Twitter client library that simulates how the real implementation
|
162
|
+
behaves when Twitter is unavailable.
|
163
|
+
|
164
|
+
## Test the wiring & the I/O, not the code
|
165
|
+
|
166
|
+
Prove that your dependencies were called with the input you
|
167
|
+
expect. In a unit test that should be good enough most of the time.
|
168
|
+
If those dependency calls would have produced side-effects and it
|
169
|
+
matters to your code, your unit may be too complex. Consider
|
170
|
+
refactoring. If the side-effects the core intent of the the
|
171
|
+
implementation, simulate the side-effects rather than actually
|
172
|
+
producing the side-effects.
|
173
|
+
|
174
|
+
## Use the dumbest possible test subjects that can prove the I/O works
|
175
|
+
|
176
|
+
Much of the time arguments to the API you're testing are not
|
177
|
+
inspected in any meaningful way. If all you need to do is prove the
|
178
|
+
argument was passed-along correctly, a `double` can do the job
|
179
|
+
rather than the real argument type.
|
180
|
+
|
181
|
+
## Generate test data randomly and dynamically rather than use fixtures
|
182
|
+
|
183
|
+
Use Faker. Generate test data in the most flexible format. Generally
|
184
|
+
that's a ruby object with accessors produced by Factory Bot, since
|
185
|
+
it can easily be turned into a hash, JSON, or left as-is.
|
186
|
+
|
187
|
+
## Test the immediate layer of the component you're testing
|
188
|
+
|
189
|
+
If a component calls Net:HTTP, don't serve HTTP, create a mock
|
190
|
+
Net:HTTP object and prove it was called with the anticipated I/O.
|
data/lib/shoegaze.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
module Shoegaze
|
2
|
+
end
|
3
|
+
|
4
|
+
require 'rspec'
|
5
|
+
require 'factory_bot'
|
6
|
+
require 'representable'
|
7
|
+
require 'multi_json'
|
8
|
+
require 'representable/json'
|
9
|
+
|
10
|
+
require_relative 'shoegaze/datastore'
|
11
|
+
require_relative 'shoegaze/implementation'
|
12
|
+
require_relative 'shoegaze/proxy'
|
13
|
+
require_relative 'shoegaze/mock'
|
14
|
+
require_relative 'shoegaze/scenario'
|
15
|
+
require_relative 'shoegaze/model'
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require_relative "./model"
|
2
|
+
|
3
|
+
module Shoegaze
|
4
|
+
module Datastore
|
5
|
+
# Defines both a TopModel-inherited class and a factory in the mock namespace
|
6
|
+
#
|
7
|
+
# @param name [Symbol] upcased name of the datastore to create (example: :User)
|
8
|
+
# @param block [Block] FactoryBot factory implementation expressed in a block
|
9
|
+
# @return [Class] the created datastore class
|
10
|
+
#
|
11
|
+
# example:
|
12
|
+
#
|
13
|
+
# datastore :User do
|
14
|
+
# id 123
|
15
|
+
# name "Karlita"
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
def datastore(name, &block)
|
19
|
+
klass = create_datastore_class(name)
|
20
|
+
|
21
|
+
FactoryBot.define do
|
22
|
+
factory klass do
|
23
|
+
self.instance_eval(&block)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
klass
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def create_datastore_class(name)
|
33
|
+
self.const_set(name, Class.new(Shoegaze::Model))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module Shoegaze
|
2
|
+
class Implementation
|
3
|
+
attr_reader :scenarios
|
4
|
+
|
5
|
+
def initialize(mock_class, mock_double, scope, method_name, &block)
|
6
|
+
@_mock_class = mock_class
|
7
|
+
@_mock_double = mock_double
|
8
|
+
@_scope = scope
|
9
|
+
@_method_name = method_name
|
10
|
+
@scenarios = {}
|
11
|
+
|
12
|
+
self.instance_eval(&block)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Defines a named scenario for the implementation
|
16
|
+
#
|
17
|
+
# @param name [Symbol] name of the scenario
|
18
|
+
# @param block [Block] Shoegaze::Scenario implementation expressed in a block
|
19
|
+
# @return [Scenario] the created scenario
|
20
|
+
#
|
21
|
+
# example:
|
22
|
+
#
|
23
|
+
# scenario :success do
|
24
|
+
# datasource do
|
25
|
+
# # ...
|
26
|
+
# end
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
def scenario(scenario_name, &block)
|
30
|
+
@scenarios[scenario_name] = Scenario.new(@_method_name, &block)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Defines the default scenario for the implementation
|
34
|
+
#
|
35
|
+
# @param block [Block] Shoegaze::Scenario implementation expressed in a block
|
36
|
+
# @return [Scenario] the created scenario
|
37
|
+
#
|
38
|
+
# example:
|
39
|
+
#
|
40
|
+
# default do
|
41
|
+
# datasource do
|
42
|
+
# # ...
|
43
|
+
# end
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
def default(&block)
|
47
|
+
@scenarios[:default] = scenario = Scenario.new(@_method_name, &block)
|
48
|
+
|
49
|
+
scenario_orchestrator = Scenario::Orchestrator.new(@_mock_class, @_mock_double, @_scope, @_method_name)
|
50
|
+
|
51
|
+
# NOTE: we can't use RSpec mock methods here because :default is called outside of a
|
52
|
+
# test scope. so we have added some :default_scenario* methods instead
|
53
|
+
@_mock_double.add_default_scenario(@_method_name, proc do |*args, &datasource_block|
|
54
|
+
scenario_orchestrator.with(*args).execute_scenario(scenario, &datasource_block)
|
55
|
+
end)
|
56
|
+
|
57
|
+
scenario
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def defining_method
|
63
|
+
case @_scope
|
64
|
+
when :class
|
65
|
+
:define_singleton_method
|
66
|
+
when :instance
|
67
|
+
:define_method
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Shoegaze
|
2
|
+
# Provides the top-level mocking interface from which our mocks will inherit.
|
3
|
+
class Mock
|
4
|
+
include Proxy::Interface
|
5
|
+
|
6
|
+
class << self
|
7
|
+
# Creates a Shoegaze mock proxy for the provided class name
|
8
|
+
#
|
9
|
+
# @param class_name [String] String name of the constant to mock
|
10
|
+
# @return [Class.new(Shoegaze::Proxy)] The created Shoegaze proxy. Use this as the replacement for your real implementation.
|
11
|
+
#
|
12
|
+
# example:
|
13
|
+
#
|
14
|
+
# class RealClass
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# class FakeClass < Shoegaze::Mock
|
18
|
+
# mock "RealClass"
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
def mock(class_name)
|
22
|
+
@mock_class_double = class_double(class_name)
|
23
|
+
@mock_instance_double = instance_double(class_name)
|
24
|
+
|
25
|
+
extend_double_with_extra_methods(@mock_instance_double)
|
26
|
+
extend_double_with_extra_methods(@mock_class_double)
|
27
|
+
|
28
|
+
@implementations = {class: {}, instance: {}}
|
29
|
+
|
30
|
+
proxy
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
require "active_model"
|
2
|
+
|
3
|
+
class Shoegaze::Model
|
4
|
+
include ActiveModel::Model
|
5
|
+
include ActiveModel::Serializers::JSON
|
6
|
+
|
7
|
+
class UnknownRecordError < StandardError; end;
|
8
|
+
|
9
|
+
class << self
|
10
|
+
attr_writer :store
|
11
|
+
|
12
|
+
def drop_records
|
13
|
+
Shoegaze::Model.store = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def store
|
17
|
+
if self == Shoegaze::Model
|
18
|
+
@store ||= {}
|
19
|
+
else
|
20
|
+
Shoegaze::Model.store
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def ensure_model_namespace(model)
|
25
|
+
# using an array here so that the models appear in the order they were created
|
26
|
+
store[model.class] ||= []
|
27
|
+
end
|
28
|
+
|
29
|
+
def register_model(model)
|
30
|
+
ensure_model_namespace(model)
|
31
|
+
store[model.class] << model
|
32
|
+
model
|
33
|
+
end
|
34
|
+
|
35
|
+
def unregister_model(model)
|
36
|
+
ensure_model_namespace(model)
|
37
|
+
store[model.class].delete(model)
|
38
|
+
model
|
39
|
+
end
|
40
|
+
|
41
|
+
def records_for_class(klass)
|
42
|
+
store[klass] || []
|
43
|
+
end
|
44
|
+
|
45
|
+
def where(options)
|
46
|
+
records_for_class(self).select do |r|
|
47
|
+
options.all? do |k, v|
|
48
|
+
if v.is_a?(Enumerable)
|
49
|
+
v.include?(r.send(k))
|
50
|
+
else
|
51
|
+
r.send(k) == v
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def find_by(options)
|
58
|
+
where(options).first
|
59
|
+
end
|
60
|
+
|
61
|
+
def all
|
62
|
+
records_for_class(self)
|
63
|
+
end
|
64
|
+
|
65
|
+
def first
|
66
|
+
all[0]
|
67
|
+
end
|
68
|
+
|
69
|
+
def last
|
70
|
+
all[-1]
|
71
|
+
end
|
72
|
+
|
73
|
+
def raw_find(id) #:nodoc:
|
74
|
+
records_for_class(self).find { |r| r.id == id } ||
|
75
|
+
raise(UnknownRecordError, "Couldn't find #{self} with ID=#{id}")
|
76
|
+
end
|
77
|
+
|
78
|
+
def find(id)
|
79
|
+
raw_find(id)
|
80
|
+
end
|
81
|
+
alias :[] :find
|
82
|
+
|
83
|
+
def exists?(id)
|
84
|
+
raw_find(id) != nil
|
85
|
+
end
|
86
|
+
|
87
|
+
def count
|
88
|
+
records_for_class(self).length
|
89
|
+
end
|
90
|
+
|
91
|
+
def select(&block)
|
92
|
+
records_for_class(self).select(&block)
|
93
|
+
end
|
94
|
+
|
95
|
+
def create(attrs = {})
|
96
|
+
instance = self.new(attrs)
|
97
|
+
register_model(instance)
|
98
|
+
end
|
99
|
+
|
100
|
+
def update(id, atts)
|
101
|
+
find(id).update_attributes(atts)
|
102
|
+
end
|
103
|
+
|
104
|
+
def destroy(id)
|
105
|
+
find(id).destroy
|
106
|
+
end
|
107
|
+
|
108
|
+
def find_by_attribute(name, value) #:nodoc:
|
109
|
+
records_for_class(self).find {|r| r.send(name) == value }
|
110
|
+
end
|
111
|
+
|
112
|
+
def method_missing(method_symbol, *args) #:nodoc:
|
113
|
+
method_name = method_symbol.to_s
|
114
|
+
|
115
|
+
if method_name =~ /^find_by_(\w+)!/
|
116
|
+
send("find_by_#{$1}", *args) || raise(UnknownRecord)
|
117
|
+
elsif method_name =~ /^find_by_(\w+)/
|
118
|
+
find_by_attribute($1, args.first)
|
119
|
+
elsif method_name =~ /^find_or_create_by_(\w+)/
|
120
|
+
send("find_by_#{$1}", *args) || create($1 => args.first)
|
121
|
+
elsif method_name =~ /^find_all_by_(\w+)/
|
122
|
+
find_all_by_attribute($1, args.first)
|
123
|
+
else
|
124
|
+
super
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
attr_accessor :id
|
130
|
+
|
131
|
+
def initialize(attrs = {})
|
132
|
+
@id = SecureRandom.uuid
|
133
|
+
@__data = OpenStruct.new(attrs)
|
134
|
+
end
|
135
|
+
|
136
|
+
def save
|
137
|
+
Shoegaze::Model.register_model(self)
|
138
|
+
end
|
139
|
+
alias :save! :save
|
140
|
+
|
141
|
+
def destroy
|
142
|
+
Shoegaze::Model.unregister_model(self)
|
143
|
+
end
|
144
|
+
alias :destroy! :destroy
|
145
|
+
|
146
|
+
def update_attributes(attrs)
|
147
|
+
attrs.each do |k, v|
|
148
|
+
send("#{k}=", v)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
alias :update_attributes! :update_attributes
|
152
|
+
|
153
|
+
def reload
|
154
|
+
self.class.find(id) || raise(Shoegaze::Model::UnknownRecordError)
|
155
|
+
end
|
156
|
+
|
157
|
+
def method_missing(method_symbol, *args) #:nodoc:
|
158
|
+
@__data.send(method_symbol, *args)
|
159
|
+
end
|
160
|
+
|
161
|
+
def as_json
|
162
|
+
@__data.to_h.with_indifferent_access
|
163
|
+
end
|
164
|
+
alias :attributes :as_json
|
165
|
+
end
|