gitlab-experiment 0.6.4 → 0.7.1

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.
@@ -7,34 +7,83 @@ module Gitlab
7
7
  autoload :Random, 'gitlab/experiment/rollout/random.rb'
8
8
  autoload :RoundRobin, 'gitlab/experiment/rollout/round_robin.rb'
9
9
 
10
- def self.resolve(klass)
11
- return "#{name}::#{klass.to_s.classify}".constantize if klass.is_a?(Symbol) || klass.is_a?(String)
12
-
13
- klass
10
+ def self.resolve(klass, options = {})
11
+ case klass
12
+ when String
13
+ Strategy.new(klass.classify.constantize, options)
14
+ when Symbol
15
+ Strategy.new("#{name}::#{klass.to_s.classify}".constantize, options)
16
+ when Class
17
+ Strategy.new(klass, options)
18
+ else
19
+ raise ArgumentError, "unable to resolve rollout from #{klass.inspect}"
20
+ end
14
21
  end
15
22
 
16
23
  class Base
17
- attr_reader :experiment
24
+ DEFAULT_OPTIONS = {
25
+ include_control: false
26
+ }.freeze
27
+
28
+ attr_reader :experiment, :options
18
29
 
19
30
  delegate :variant_names, :cache, :id, to: :experiment
20
31
 
21
32
  def initialize(options = {})
22
- @options = options
23
- # validate! # we want to validate here, but we can't yet
33
+ @options = DEFAULT_OPTIONS.merge(options)
24
34
  end
25
35
 
26
- def rollout_for(experiment)
36
+ def for(experiment)
37
+ raise ArgumentError, 'you must provide an experiment instance' unless experiment.class <= Gitlab::Experiment
38
+
27
39
  @experiment = experiment
28
- validate! # until we have variant registration we can only validate here
29
- execute
40
+
41
+ self
42
+ end
43
+
44
+ def enabled?
45
+ require_experiment(__method__)
46
+
47
+ true
30
48
  end
31
49
 
50
+ def resolve
51
+ require_experiment(__method__)
52
+
53
+ return nil if @experiment.respond_to?(:experiment_group?) && !@experiment.experiment_group?
54
+
55
+ validate! # allow the rollout strategy to validate itself
56
+
57
+ assignment = execute_assignment
58
+ assignment == :control ? nil : assignment # avoid caching control
59
+ end
60
+
61
+ protected
62
+
32
63
  def validate!
33
64
  # base is always valid
34
65
  end
35
66
 
36
- def execute
37
- variant_names.first
67
+ def execute_assignment
68
+ behavior_names.first
69
+ end
70
+
71
+ private
72
+
73
+ def require_experiment(method_name)
74
+ return if @experiment.present?
75
+
76
+ raise ArgumentError, "you need to call `for` with an experiment instance before chaining `#{method_name}`"
77
+ end
78
+
79
+ def behavior_names
80
+ options[:include_control] ? [:control] + variant_names : variant_names
81
+ end
82
+ end
83
+
84
+ Strategy = Struct.new(:klass, :options) do
85
+ def for(experiment)
86
+ klass.new(options).for(experiment)
38
87
  end
39
88
  end
40
89
  end
@@ -2,226 +2,297 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- module RSpecHelpers
6
- def stub_experiments(experiments, times = nil)
7
- experiments.each { |experiment| wrapped_experiment(experiment, times) }
8
- end
9
-
10
- def wrapped_experiment(experiment, times = nil, expected = false, &block)
11
- klass, experiment_name, variant_name = *experiment_details(experiment)
12
- base_klass = Configuration.base_class.constantize
5
+ module TestBehaviors
6
+ autoload :Trackable, 'gitlab/experiment/test_behaviors/trackable.rb'
7
+ end
13
8
 
14
- # Set expectations on experiment classes so we can and_wrap_original with more specific args
15
- experiment_klasses = base_klass.descendants.reject { |k| k == klass }
16
- experiment_klasses.push(base_klass).each do |k|
17
- allow(k).to receive(:new).and_call_original
18
- end
9
+ WrappedExperiment = Struct.new(:klass, :experiment_name, :variant_name, :expectation_chain, :blocks)
19
10
 
20
- receiver = receive(:new)
11
+ module RSpecMocks
12
+ @__gitlab_experiment_receivers = {}
21
13
 
22
- # Be specific for BaseClass calls
23
- receiver = receiver.with(experiment_name, any_args) if experiment_name && klass == base_klass
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
20
+ end
24
21
 
25
- receiver.exactly(times).times if times
22
+ def self.bind_gitlab_experiment_receiver(method)
23
+ method.unbind.bind(@__gitlab_experiment_receivers[method.to_s].pop)
24
+ end
26
25
 
27
- # Set expectations on experiment class of interest
28
- allow_or_expect_klass = expected ? expect(klass) : allow(klass)
29
- allow_or_expect_klass.to receiver.and_wrap_original do |method, *original_args, &original_block|
30
- method.call(*original_args).tap do |e|
31
- # Stub internal methods before calling the original_block
32
- allow(e).to receive(:enabled?).and_return(true)
26
+ module MethodDouble
27
+ def proxy_method_invoked(receiver, *args, &block)
28
+ RSpecMocks.track_gitlab_experiment_receiver(original_method, receiver)
29
+ super
30
+ end
31
+ end
32
+ end
33
33
 
34
- if variant_name == true # passing true allows the rollout to do its job
35
- allow(e).to receive(:experiment_group?).and_return(true)
36
- else
37
- allow(e).to receive(:resolve_variant_name).and_return(variant_name.to_s)
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) }
38
42
  end
39
43
 
40
- # Stub/set expectations before calling the original_block
41
- yield e if block
42
-
43
- original_block.call(e) if original_block.present?
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
+ }
44
49
  end
45
50
  end
51
+
52
+ wrapped_experiments
53
+ end
54
+
55
+ def wrapped_experiment(experiment, remock: false, &block)
56
+ klass, experiment_name, variant_name = *extract_experiment_details(experiment)
57
+
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), [])
61
+
62
+ wrapped_experiment.blocks << block if block
63
+ wrapped_experiment
46
64
  end
47
65
 
48
66
  private
49
67
 
50
- def experiment_details(experiment)
51
- if experiment.is_a?(Symbol)
52
- experiment_name = experiment
53
- 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
54
83
  end
84
+ end
55
85
 
86
+ def extract_experiment_details(experiment)
87
+ experiment_name = nil
88
+ variant_name = nil
89
+
90
+ experiment_name = experiment if experiment.is_a?(Symbol)
56
91
  experiment_name, variant_name = *experiment if experiment.is_a?(Array)
57
92
 
58
93
  base_klass = Configuration.base_class.constantize
59
- variant_name = experiment.variant.name if experiment.is_a?(base_klass)
94
+ variant_name = experiment.assigned.name if experiment.is_a?(base_klass)
60
95
 
61
- if experiment.class.name.nil? # Anonymous class instance
62
- klass = experiment.class
63
- elsif experiment.instance_of?(Class) # Class level stubbing, eg. "MyExperiment"
64
- klass = experiment
65
- else
66
- experiment_name ||= experiment.instance_variable_get(:@name)
67
- klass = base_klass.constantize(experiment_name)
68
- end
96
+ resolved_klass = experiment_klass(experiment) { base_klass.constantize(experiment_name) }
97
+ experiment_name ||= experiment.instance_variable_get(:@_name)
69
98
 
70
- if experiment_name && klass == base_klass
71
- experiment_name = experiment_name.to_sym
99
+ [resolved_klass, experiment_name.to_s, variant_name]
100
+ end
72
101
 
73
- # For experiment names like: "group/experiment-name"
74
- 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
75
109
  end
76
-
77
- [klass, experiment_name, variant_name]
78
110
  end
79
111
  end
80
112
 
81
113
  module RSpecMatchers
82
114
  extend RSpec::Matchers::DSL
83
115
 
84
- def require_experiment(experiment, matcher_name, classes: false)
116
+ def require_experiment(experiment, matcher, instances_only: true)
85
117
  klass = experiment.instance_of?(Class) ? experiment : experiment.class
86
- unless klass <= Gitlab::Experiment
87
- raise(
88
- ArgumentError,
89
- "#{matcher_name} matcher is limited to experiment instances#{classes ? ' and classes' : ''}"
90
- )
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
140
+ end
141
+
142
+ chain :with do |expected|
143
+ @return_expected = true
144
+ @expected_return = expected
91
145
  end
92
146
 
93
- if experiment == klass && !classes
94
- raise ArgumentError, "#{matcher_name} matcher requires an instance of an experiment"
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")
95
153
  end
96
154
 
97
- experiment != klass
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
98
168
  end
99
169
 
100
170
  matcher :exclude do |context|
101
- ivar = :'@excluded'
102
-
103
171
  match do |experiment|
104
- require_experiment(experiment, 'exclude')
105
- experiment.context(context)
172
+ @experiment = require_experiment(experiment, 'exclude')
173
+ @experiment.context(context)
174
+ @experiment.instance_variable_set(:@_excluded, nil)
106
175
 
107
- experiment.instance_variable_set(ivar, nil)
108
- !experiment.run_callbacks(:exclusion_check) { :not_excluded }
176
+ !@experiment.run_callbacks(:exclusion_check) { :not_excluded }
109
177
  end
110
178
 
111
179
  failure_message do
112
- %(expected #{context} to be excluded)
180
+ "expected #{context} to be excluded"
113
181
  end
114
182
 
115
183
  failure_message_when_negated do
116
- %(expected #{context} not to be excluded)
184
+ "expected #{context} not to be excluded"
117
185
  end
118
186
  end
119
187
 
120
188
  matcher :segment do |context|
121
- ivar = :'@variant_name'
122
-
123
189
  match do |experiment|
124
- require_experiment(experiment, 'segment')
125
- experiment.context(context)
126
-
127
- experiment.instance_variable_set(ivar, nil)
128
- experiment.run_callbacks(:segmentation_check)
190
+ @experiment = require_experiment(experiment, 'segment')
191
+ @experiment.context(context)
192
+ @experiment.instance_variable_set(:@_assigned_variant_name, nil)
193
+ @experiment.run_callbacks(:segmentation)
129
194
 
130
- @actual = experiment.instance_variable_get(ivar)
131
- @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?
132
197
  end
133
198
 
134
199
  chain :into do |expected|
135
200
  raise ArgumentError, 'variant name must be provided' if expected.blank?
136
201
 
137
- @expected = expected.to_s
202
+ @expected_variant = expected.to_s
138
203
  end
139
204
 
140
205
  failure_message do
141
- %(expected #{context} to be segmented#{message_details})
206
+ add_details("expected #{context} to be segmented")
142
207
  end
143
208
 
144
209
  failure_message_when_negated do
145
- %(expected #{context} not to be segmented#{message_details})
210
+ add_details("expected #{context} not to be segmented")
146
211
  end
147
212
 
148
- def message_details
149
- message = ''
150
- message += %( into variant\n expected variant: #{@expected}) if @expected
151
- message += %(\n actual variant: #{@actual}) if @actual
152
- 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")
153
223
  end
154
224
  end
155
225
 
156
226
  matcher :track do |event, *event_args|
157
227
  match do |experiment|
158
- 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)
159
231
  end
160
232
 
161
233
  match_when_negated do |experiment|
162
- 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)
163
237
  end
164
238
 
165
- chain :for do |expected_variant|
239
+ chain(:for) do |expected|
166
240
  raise ArgumentError, 'variant name must be provided' if expected.blank?
167
241
 
168
- @expected_variant = expected_variant.to_s
242
+ @expected_variant = expected.to_s
169
243
  end
170
244
 
171
- 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?
172
247
 
173
- chain(:on_next_instance) { @on_next_instance = true }
248
+ @expected_context = expected
249
+ end
174
250
 
175
- def expect_tracking_on(experiment, negated, event, *event_args)
176
- klass = experiment.instance_of?(Class) ? experiment : experiment.class
177
- unless klass <= Gitlab::Experiment
178
- raise(
179
- ArgumentError,
180
- "track matcher is limited to experiment instances and classes"
181
- )
182
- end
251
+ chain(:on_next_instance) { @on_next_instance = true }
183
252
 
253
+ def set_expectations(event, *event_args, negated:)
254
+ failure_message = failure_message_with_details(event, negated: negated)
184
255
  expectations = proc do |e|
185
- @experiment = e
186
256
  allow(e).to receive(:track).and_call_original
187
257
 
188
258
  if negated
189
- expect(e).not_to receive(:track).with(*[event, *event_args])
190
- else
191
- if @expected_variant
192
- expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
193
- end
194
-
195
- if @expected_context
196
- expect(@experiment.context.value).to include(@expected_context), failure_message(:context, 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
- 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
200
268
  end
201
269
  end
202
270
 
203
- if experiment.instance_of?(Class) || @on_next_instance
204
- wrapped_experiment(experiment, nil, true) { |e| expectations.call(e) }
205
- else
206
- expectations.call(experiment)
207
- end
271
+ return wrapped_experiment(@experiment, &expectations) if @on_next_instance || @experiment.instance_of?(Class)
272
+
273
+ expectations.call(@experiment)
274
+ end
275
+
276
+ def failure_message_with_details(event, negated: false)
277
+ add_details("expected #{@experiment.inspect} #{negated ? 'not to' : 'to'} have tracked #{event.inspect}")
208
278
  end
209
279
 
210
- def failure_message(failure_type, event)
211
- case failure_type
212
- when :variant
213
- <<~MESSAGE.strip
214
- expected #{@experiment.inspect} to have tracked #{event.inspect} for variant
215
- expected variant: #{@expected_variant}
216
- actual variant: #{@experiment.variant.name}
217
- MESSAGE
218
- when :context
219
- <<~MESSAGE.strip
220
- expected #{@experiment.inspect} to have tracked #{event.inspect} with context
221
- expected context: #{@expected_context}
222
- actual context: #{@experiment.context.value}
223
- MESSAGE
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})"
224
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")
225
296
  end
226
297
  end
227
298
  end
@@ -232,12 +303,35 @@ RSpec.configure do |config|
232
303
  config.include Gitlab::Experiment::RSpecHelpers
233
304
  config.include Gitlab::Experiment::Dsl
234
305
 
235
- config.before(:each, :experiment) do
306
+ clear_cache = proc do
236
307
  RequestStore.clear!
308
+
309
+ if defined?(Gitlab::Experiment::TestBehaviors::TrackedStructure)
310
+ Gitlab::Experiment::TestBehaviors::TrackedStructure.reset!
311
+ end
237
312
  end
238
313
 
314
+ config.before(:each, :experiment, &clear_cache)
315
+ config.before(:each, type: :experiment, &clear_cache)
316
+
239
317
  config.include Gitlab::Experiment::RSpecMatchers, :experiment
240
- config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
241
- metadata[:type] = :experiment
318
+ config.include Gitlab::Experiment::RSpecMatchers, type: :experiment
319
+
320
+ config.define_derived_metadata(file_path: Regexp.new('spec/experiments/')) do |metadata|
321
+ metadata[:type] ||= :experiment
242
322
  end
323
+
324
+ # We need to monkeypatch rspec-mocks because there's an issue around stubbing class methods that impacts us here.
325
+ #
326
+ # 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
327
+ # needed.
328
+ #
329
+ # https://github.com/rspec/rspec-mocks/issues/1452
330
+ # https://github.com/rspec/rspec-mocks/issues/1451 (closed)
331
+ #
332
+ # The other way I've considered patching this is inside gitlab-experiment itself, by adding an Anonymous class and
333
+ # instantiating that instead of the configured base_class, and then it's less common but still possible to run into
334
+ # the issue.
335
+ require 'rspec/mocks/method_double'
336
+ RSpec::Mocks::MethodDouble.prepend(Gitlab::Experiment::RSpecMocks::MethodDouble)
243
337
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module TestBehaviors
6
+ module Trackable
7
+ private
8
+
9
+ def manage_nested_stack
10
+ TrackedStructure.push(self)
11
+ super
12
+ ensure
13
+ TrackedStructure.pop
14
+ end
15
+ end
16
+
17
+ class TrackedStructure
18
+ include Singleton
19
+
20
+ # dependency tracking
21
+ @flat = {}
22
+ @stack = []
23
+
24
+ # structure tracking
25
+ @tree = { name: nil, count: 0, children: {} }
26
+ @node = @tree
27
+
28
+ class << self
29
+ def reset!
30
+ # dependency tracking
31
+ @flat = {}
32
+ @stack = []
33
+
34
+ # structure tracking
35
+ @tree = { name: nil, count: 0, children: {} }
36
+ @node = @tree
37
+ end
38
+
39
+ def hierarchy
40
+ @tree[:children]
41
+ end
42
+
43
+ def dependencies
44
+ @flat
45
+ end
46
+
47
+ def push(instance)
48
+ # dependency tracking
49
+ @flat[instance.name] = ((@flat[instance.name] || []) + @stack.map(&:name)).uniq
50
+ @stack.push(instance)
51
+
52
+ # structure tracking
53
+ @last = @node
54
+ @node = @node[:children][instance.name] ||= { name: instance.name, count: 0, children: {} }
55
+ @node[:count] += 1
56
+ end
57
+
58
+ def pop
59
+ # dependency tracking
60
+ @stack.pop
61
+
62
+ # structure tracking
63
+ @node = @last
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.6.4'
5
+ VERSION = '0.7.1'
6
6
  end
7
7
  end