gitlab-experiment 0.6.4 → 0.7.1

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