gitlab-experiment 0.6.5 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,226 +6,293 @@ module Gitlab
6
6
  autoload :Trackable, 'gitlab/experiment/test_behaviors/trackable.rb'
7
7
  end
8
8
 
9
- module RSpecHelpers
10
- def stub_experiments(experiments, times = nil)
11
- experiments.each { |experiment| wrapped_experiment(experiment, times) }
9
+ WrappedExperiment = Struct.new(:klass, :experiment_name, :variant_name, :expectation_chain, :blocks)
10
+
11
+ module RSpecMocks
12
+ @__gitlab_experiment_receivers = {}
13
+
14
+ def self.track_gitlab_experiment_receiver(method, receiver)
15
+ # Leverage the `>=` method on Gitlab::Experiment to determine if the receiver is an experiment, not the other
16
+ # way round -- `receiver.<=` could be mocked and we want to be extra careful.
17
+ (@__gitlab_experiment_receivers[method.to_s] ||= []) << receiver if Gitlab::Experiment >= receiver
18
+ rescue StandardError # again, let's just be extra careful
19
+ false
12
20
  end
13
21
 
14
- def wrapped_experiment(experiment, times = nil, expected = false, &block)
15
- klass, experiment_name, variant_name = *experiment_details(experiment)
16
- base_klass = Configuration.base_class.constantize
22
+ def self.bind_gitlab_experiment_receiver(method)
23
+ method.unbind.bind(@__gitlab_experiment_receivers[method.to_s].pop)
24
+ end
17
25
 
18
- # Set expectations on experiment classes so we can and_wrap_original with more specific args
19
- experiment_klasses = base_klass.descendants.reject { |k| k == klass }
20
- experiment_klasses.push(base_klass).each do |k|
21
- allow(k).to receive(:new).and_call_original
26
+ module MethodDouble
27
+ def proxy_method_invoked(receiver, *args, &block)
28
+ RSpecMocks.track_gitlab_experiment_receiver(original_method, receiver)
29
+ super
22
30
  end
31
+ end
32
+ end
23
33
 
24
- receiver = receive(:new)
25
-
26
- # Be specific for BaseClass calls
27
- receiver = receiver.with(experiment_name, any_args) if experiment_name && klass == base_klass
34
+ module RSpecHelpers
35
+ def stub_experiments(experiments)
36
+ experiments.each do |experiment|
37
+ wrapped_experiment(experiment, remock: true) do |instance, wrapped|
38
+ # Stub internal methods that will make it behave as we've instructed.
39
+ allow(instance).to receive(:enabled?) { wrapped.variant_name != false }
40
+ if instance.respond_to?(:experiment_group?, true)
41
+ allow(instance).to receive(:experiment_group?) { !(wrapped.variant_name == false) }
42
+ end
28
43
 
29
- receiver.exactly(times).times if times
44
+ # Stub the variant resolution logic to handle true/false, and named variants.
45
+ allow(instance).to receive(:resolve_variant_name).and_wrap_original { |method|
46
+ # Call the original method if we specified simply `true`.
47
+ wrapped.variant_name == true ? method.call : wrapped.variant_name
48
+ }
49
+ end
50
+ end
30
51
 
31
- # Set expectations on experiment class of interest
32
- allow_or_expect_klass = expected ? expect(klass) : allow(klass)
33
- allow_or_expect_klass.to receiver.and_wrap_original do |method, *original_args, &original_block|
34
- method.call(*original_args).tap do |e|
35
- # Stub internal methods before calling the original_block
36
- allow(e).to receive(:enabled?).and_return(true)
52
+ wrapped_experiments
53
+ end
37
54
 
38
- if variant_name == true # passing true allows the rollout to do its job
39
- allow(e).to receive(:experiment_group?).and_return(true)
40
- else
41
- allow(e).to receive(:resolve_variant_name).and_return(variant_name.to_s)
42
- end
55
+ def wrapped_experiment(experiment, remock: false, &block)
56
+ klass, experiment_name, variant_name = *extract_experiment_details(experiment)
43
57
 
44
- # Stub/set expectations before calling the original_block
45
- yield e if block
58
+ wrapped_experiment = wrapped_experiments[experiment_name] =
59
+ (!remock && wrapped_experiments[experiment_name]) ||
60
+ WrappedExperiment.new(klass, experiment_name, variant_name, wrapped_experiment_chain_for(klass), [])
46
61
 
47
- original_block.call(e) if original_block.present?
48
- end
49
- end
62
+ wrapped_experiment.blocks << block if block
63
+ wrapped_experiment
50
64
  end
51
65
 
52
66
  private
53
67
 
54
- def experiment_details(experiment)
55
- if experiment.is_a?(Symbol)
56
- experiment_name = experiment
57
- variant_name = nil
68
+ def wrapped_experiments
69
+ @__wrapped_experiments ||= defined?(HashWithIndifferentAccess) ? HashWithIndifferentAccess.new : {}
70
+ end
71
+
72
+ def wrapped_experiment_chain_for(klass)
73
+ @__wrapped_experiment_chains ||= {}
74
+ @__wrapped_experiment_chains[klass.name || klass.object_id] ||= begin
75
+ allow(klass).to receive(:new).and_wrap_original do |method, *args, &original_block|
76
+ RSpecMocks.bind_gitlab_experiment_receiver(method).call(*args).tap do |instance|
77
+ wrapped = @__wrapped_experiments[instance.instance_variable_get(:@_name)]
78
+ wrapped&.blocks&.each { |b| b.call(instance, wrapped) }
79
+
80
+ original_block&.call(instance)
81
+ end
82
+ end
58
83
  end
84
+ end
85
+
86
+ def extract_experiment_details(experiment)
87
+ experiment_name = nil
88
+ variant_name = nil
59
89
 
90
+ experiment_name = experiment if experiment.is_a?(Symbol)
60
91
  experiment_name, variant_name = *experiment if experiment.is_a?(Array)
61
92
 
62
93
  base_klass = Configuration.base_class.constantize
63
- variant_name = experiment.variant.name if experiment.is_a?(base_klass)
94
+ variant_name = experiment.assigned.name if experiment.is_a?(base_klass)
64
95
 
65
- if experiment.class.name.nil? # Anonymous class instance
66
- klass = experiment.class
67
- elsif experiment.instance_of?(Class) # Class level stubbing, eg. "MyExperiment"
68
- klass = experiment
69
- else
70
- experiment_name ||= experiment.instance_variable_get(:@name)
71
- klass = base_klass.constantize(experiment_name)
72
- end
96
+ resolved_klass = experiment_klass(experiment) { base_klass.constantize(experiment_name) }
97
+ experiment_name ||= experiment.instance_variable_get(:@_name)
73
98
 
74
- if experiment_name && klass == base_klass
75
- experiment_name = experiment_name.to_sym
99
+ [resolved_klass, experiment_name.to_s, variant_name]
100
+ end
76
101
 
77
- # For experiment names like: "group/experiment-name"
78
- experiment_name = experiment_name.to_s if experiment_name.inspect.include?('"')
102
+ def experiment_klass(experiment, &block)
103
+ if experiment.class.name.nil? # anonymous class instance
104
+ experiment.class
105
+ elsif experiment.instance_of?(Class) # class level stubbing, eg. "MyExperiment"
106
+ experiment
107
+ elsif block
108
+ yield
79
109
  end
80
-
81
- [klass, experiment_name, variant_name]
82
110
  end
83
111
  end
84
112
 
85
113
  module RSpecMatchers
86
114
  extend RSpec::Matchers::DSL
87
115
 
88
- def require_experiment(experiment, matcher_name, classes: false)
116
+ def require_experiment(experiment, matcher, instances_only: true)
89
117
  klass = experiment.instance_of?(Class) ? experiment : experiment.class
90
- unless klass <= Gitlab::Experiment
91
- raise(
92
- ArgumentError,
93
- "#{matcher_name} matcher is limited to experiment instances#{classes ? ' and classes' : ''}"
94
- )
118
+ raise ArgumentError, "the #{matcher} matcher is limited to experiments" unless klass <= Gitlab::Experiment
119
+
120
+ if instances_only && experiment == klass
121
+ raise ArgumentError, "the #{matcher} matcher is limited to experiment instances"
122
+ end
123
+
124
+ experiment
125
+ end
126
+
127
+ matcher :register_behavior do |behavior_name|
128
+ match do |experiment|
129
+ @experiment = require_experiment(experiment, 'register_behavior')
130
+
131
+ block = @experiment.behaviors[behavior_name.to_s]
132
+ @return_expected = false unless block
133
+
134
+ if @return_expected
135
+ @actual_return = block.call
136
+ @expected_return == @actual_return
137
+ else
138
+ block
139
+ end
95
140
  end
96
141
 
97
- if experiment == klass && !classes
98
- raise ArgumentError, "#{matcher_name} matcher requires an instance of an experiment"
142
+ chain :with do |expected|
143
+ @return_expected = true
144
+ @expected_return = expected
99
145
  end
100
146
 
101
- experiment != klass
147
+ failure_message do
148
+ add_details("expected the #{behavior_name} behavior to be registered")
149
+ end
150
+
151
+ failure_message_when_negated do
152
+ add_details("expected the #{behavior_name} behavior not to be registered")
153
+ end
154
+
155
+ def add_details(base)
156
+ details = []
157
+
158
+ if @return_expected
159
+ base = "#{base} with a return value"
160
+ details << " expected return: #{@expected_return.inspect}\n" \
161
+ " actual return: #{@actual_return.inspect}"
162
+ else
163
+ details << " behaviors: #{@experiment.behaviors.keys.inspect}"
164
+ end
165
+
166
+ details.unshift(base).join("\n")
167
+ end
102
168
  end
103
169
 
104
170
  matcher :exclude do |context|
105
- ivar = :'@excluded'
106
-
107
171
  match do |experiment|
108
- require_experiment(experiment, 'exclude')
109
- experiment.context(context)
172
+ @experiment = require_experiment(experiment, 'exclude')
173
+ @experiment.context(context)
174
+ @experiment.instance_variable_set(:@_excluded, nil)
110
175
 
111
- experiment.instance_variable_set(ivar, nil)
112
- !experiment.run_callbacks(:exclusion_check) { :not_excluded }
176
+ !@experiment.run_callbacks(:exclusion_check) { :not_excluded }
113
177
  end
114
178
 
115
179
  failure_message do
116
- %(expected #{context} to be excluded)
180
+ "expected #{context} to be excluded"
117
181
  end
118
182
 
119
183
  failure_message_when_negated do
120
- %(expected #{context} not to be excluded)
184
+ "expected #{context} not to be excluded"
121
185
  end
122
186
  end
123
187
 
124
188
  matcher :segment do |context|
125
- ivar = :'@variant_name'
126
-
127
189
  match do |experiment|
128
- require_experiment(experiment, 'segment')
129
- experiment.context(context)
190
+ @experiment = require_experiment(experiment, 'segment')
191
+ @experiment.context(context)
192
+ @experiment.instance_variable_set(:@_assigned_variant_name, nil)
193
+ @experiment.run_callbacks(:segmentation)
130
194
 
131
- experiment.instance_variable_set(ivar, nil)
132
- experiment.run_callbacks(:segmentation_check)
133
-
134
- @actual = experiment.instance_variable_get(ivar)
135
- @expected ? @actual.to_s == @expected.to_s : @actual.present?
195
+ @actual_variant = @experiment.instance_variable_get(:@_assigned_variant_name)
196
+ @expected_variant ? @actual_variant.to_s == @expected_variant.to_s : @actual_variant.present?
136
197
  end
137
198
 
138
199
  chain :into do |expected|
139
200
  raise ArgumentError, 'variant name must be provided' if expected.blank?
140
201
 
141
- @expected = expected.to_s
202
+ @expected_variant = expected.to_s
142
203
  end
143
204
 
144
205
  failure_message do
145
- %(expected #{context} to be segmented#{message_details})
206
+ add_details("expected #{context} to be segmented")
146
207
  end
147
208
 
148
209
  failure_message_when_negated do
149
- %(expected #{context} not to be segmented#{message_details})
210
+ add_details("expected #{context} not to be segmented")
150
211
  end
151
212
 
152
- def message_details
153
- message = ''
154
- message += %( into variant\n expected variant: #{@expected}) if @expected
155
- message += %(\n actual variant: #{@actual}) if @actual
156
- message
213
+ def add_details(base)
214
+ details = []
215
+
216
+ if @expected_variant
217
+ base = "#{base} into variant"
218
+ details << " expected variant: #{@expected_variant.inspect}\n" \
219
+ " actual variant: #{@actual_variant.inspect}"
220
+ end
221
+
222
+ details.unshift(base).join("\n")
157
223
  end
158
224
  end
159
225
 
160
226
  matcher :track do |event, *event_args|
161
227
  match do |experiment|
162
- expect_tracking_on(experiment, false, event, *event_args)
228
+ @experiment = require_experiment(experiment, 'track', instances_only: false)
229
+
230
+ set_expectations(event, *event_args, negated: false)
163
231
  end
164
232
 
165
233
  match_when_negated do |experiment|
166
- expect_tracking_on(experiment, true, event, *event_args)
234
+ @experiment = require_experiment(experiment, 'track', instances_only: false)
235
+
236
+ set_expectations(event, *event_args, negated: true)
167
237
  end
168
238
 
169
- chain :for do |expected_variant|
239
+ chain(:for) do |expected|
170
240
  raise ArgumentError, 'variant name must be provided' if expected.blank?
171
241
 
172
- @expected_variant = expected_variant.to_s
242
+ @expected_variant = expected.to_s
173
243
  end
174
244
 
175
- chain(:with_context) { |expected_context| @expected_context = expected_context }
245
+ chain(:with_context) do |expected|
246
+ raise ArgumentError, 'context name must be provided' if expected.nil?
176
247
 
177
- chain(:on_next_instance) { @on_next_instance = true }
248
+ @expected_context = expected
249
+ end
178
250
 
179
- def expect_tracking_on(experiment, negated, event, *event_args)
180
- klass = experiment.instance_of?(Class) ? experiment : experiment.class
181
- unless klass <= Gitlab::Experiment
182
- raise(
183
- ArgumentError,
184
- "track matcher is limited to experiment instances and classes"
185
- )
186
- end
251
+ chain(:on_next_instance) { @on_next_instance = true }
187
252
 
253
+ def set_expectations(event, *event_args, negated:)
254
+ failure_message = failure_message_with_details(event, negated: negated)
188
255
  expectations = proc do |e|
189
- @experiment = e
190
256
  allow(e).to receive(:track).and_call_original
191
257
 
192
258
  if negated
193
- expect(e).not_to receive(:track).with(*[event, *event_args])
194
- else
195
- if @expected_variant
196
- expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
259
+ if @expected_variant || @expected_context
260
+ raise ArgumentError, 'cannot specify `for` or `with_context` when negating on tracking calls'
197
261
  end
198
262
 
199
- if @expected_context
200
- expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
201
- end
202
-
203
- expect(e).to receive(:track).with(*[event, *event_args]).and_call_original
263
+ expect(e).not_to receive(:track).with(*[event, *event_args]), failure_message
264
+ else
265
+ expect(e.assigned.name).to(eq(@expected_variant), failure_message) if @expected_variant
266
+ expect(e.context.value).to(include(@expected_context), failure_message) if @expected_context
267
+ expect(e).to receive(:track).with(*[event, *event_args]).and_call_original, failure_message
204
268
  end
205
269
  end
206
270
 
207
- if experiment.instance_of?(Class) || @on_next_instance
208
- wrapped_experiment(experiment, nil, true) { |e| expectations.call(e) }
209
- else
210
- expectations.call(experiment)
211
- end
271
+ return wrapped_experiment(@experiment, &expectations) if @on_next_instance || @experiment.instance_of?(Class)
272
+
273
+ expectations.call(@experiment)
212
274
  end
213
275
 
214
- def failure_message(failure_type, event)
215
- case failure_type
216
- when :variant
217
- <<~MESSAGE.strip
218
- expected #{@experiment.inspect} to have tracked #{event.inspect} for variant
219
- expected variant: #{@expected_variant}
220
- actual variant: #{@experiment.variant.name}
221
- MESSAGE
222
- when :context
223
- <<~MESSAGE.strip
224
- expected #{@experiment.inspect} to have tracked #{event.inspect} with context
225
- expected context: #{@expected_context}
226
- actual context: #{@experiment.context.value}
227
- MESSAGE
276
+ def failure_message_with_details(event, negated: false)
277
+ add_details("expected #{@experiment.inspect} #{negated ? 'not to' : 'to'} have tracked #{event.inspect}")
278
+ end
279
+
280
+ def add_details(base)
281
+ details = []
282
+
283
+ if @expected_variant
284
+ base = "#{base} for variant"
285
+ details << " expected variant: #{@expected_variant.inspect}\n" \
286
+ " actual variant: #{@experiment.assigned.name.inspect})"
228
287
  end
288
+
289
+ if @expected_context
290
+ base = "#{base} with context"
291
+ details << " expected context: #{@expected_context.inspect}\n" \
292
+ " actual context: #{@experiment.context.value.inspect})"
293
+ end
294
+
295
+ details.unshift(base).join("\n")
229
296
  end
230
297
  end
231
298
  end
@@ -248,4 +315,18 @@ RSpec.configure do |config|
248
315
  config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
249
316
  metadata[:type] = :experiment
250
317
  end
318
+
319
+ # We need to monkeypatch rspec-mocks because there's an issue around stubbing class methods that impacts us here.
320
+ #
321
+ # You can find out what the outcome is of the issues I've opened on rspec-mocks, and maybe some day this won't be
322
+ # needed.
323
+ #
324
+ # https://github.com/rspec/rspec-mocks/issues/1452
325
+ # https://github.com/rspec/rspec-mocks/issues/1451 (closed)
326
+ #
327
+ # The other way I've considered patching this is inside gitlab-experiment itself, by adding an Anonymous class and
328
+ # instantiating that instead of the configured base_class, and then it's less common but still possible to run into
329
+ # the issue.
330
+ require 'rspec/mocks/method_double'
331
+ RSpec::Mocks::MethodDouble.prepend(Gitlab::Experiment::RSpecMocks::MethodDouble)
251
332
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.6.5'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end