shoegaze 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build status](https://badge.buildkite.com/0d63e248d5ce1503e1cc1d4e928cc43ff48a9db53463ecdb32.svg)](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
|