gitlab-experiment 0.6.5 → 0.7.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.
@@ -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