shoegaze 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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