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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +190 -0
  3. data/lib/shoegaze.rb +15 -0
  4. data/lib/shoegaze/datastore.rb +36 -0
  5. data/lib/shoegaze/implementation.rb +71 -0
  6. data/lib/shoegaze/mock.rb +34 -0
  7. data/lib/shoegaze/model.rb +165 -0
  8. data/lib/shoegaze/proxy.rb +24 -0
  9. data/lib/shoegaze/proxy/interface.rb +169 -0
  10. data/lib/shoegaze/proxy/template.rb +61 -0
  11. data/lib/shoegaze/scenario.rb +100 -0
  12. data/lib/shoegaze/scenario/mock.rb +52 -0
  13. data/lib/shoegaze/scenario/orchestrator.rb +167 -0
  14. data/lib/shoegaze/version.rb +3 -0
  15. data/spec/features/basic_class_method_spec.rb +55 -0
  16. data/spec/features/basic_instance_method_spec.rb +53 -0
  17. data/spec/features/class_default_scenario_spec.rb +51 -0
  18. data/spec/features/class_method_block_argument_spec.rb +61 -0
  19. data/spec/features/data_store_spec.rb +73 -0
  20. data/spec/features/instance_customized_initializer.rb +24 -0
  21. data/spec/features/instance_default_scenario_spec.rb +51 -0
  22. data/spec/features/instance_method_block_argument_spec.rb +60 -0
  23. data/spec/features/nested_class_method_spec.rb +47 -0
  24. data/spec/features/nested_instance_method_spec.rb +47 -0
  25. data/spec/shoegaze/datastore_spec.rb +38 -0
  26. data/spec/shoegaze/implementation_spec.rb +97 -0
  27. data/spec/shoegaze/mock_spec.rb +42 -0
  28. data/spec/shoegaze/proxy/interface_spec.rb +88 -0
  29. data/spec/shoegaze/proxy/template_spec.rb +36 -0
  30. data/spec/shoegaze/proxy_spec.rb +18 -0
  31. data/spec/shoegaze/scenario/mock_spec.rb +31 -0
  32. data/spec/shoegaze/scenario/orchestrator_spec.rb +145 -0
  33. data/spec/shoegaze/scenario_spec.rb +74 -0
  34. metadata +133 -0
@@ -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
@@ -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.
@@ -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