gitlab-experiment 0.6.2 → 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.
@@ -14,27 +14,63 @@ module Gitlab
14
14
  end
15
15
 
16
16
  class Base
17
- attr_reader :experiment
17
+ DEFAULT_OPTIONS = {
18
+ include_control: false
19
+ }.freeze
20
+
21
+ attr_reader :experiment, :options
18
22
 
19
23
  delegate :variant_names, :cache, :id, to: :experiment
20
24
 
21
25
  def initialize(options = {})
22
- @options = options
23
- # validate! # we want to validate here, but we can't yet
26
+ @options = DEFAULT_OPTIONS.merge(options)
24
27
  end
25
28
 
26
- def rollout_for(experiment)
29
+ def for(experiment)
30
+ raise ArgumentError, 'you must provide an experiment instance' unless experiment.class <= Gitlab::Experiment
31
+
27
32
  @experiment = experiment
28
- validate! # until we have variant registration we can only validate here
29
- execute
33
+
34
+ self
35
+ end
36
+
37
+ def enabled?
38
+ require_experiment(__method__)
39
+
40
+ true
41
+ end
42
+
43
+ def resolve
44
+ require_experiment(__method__)
45
+
46
+ return nil if @experiment.respond_to?(:experiment_group?) && !@experiment.experiment_group?
47
+
48
+ validate! # allow the rollout strategy to validate itself
49
+
50
+ assignment = execute_assigment
51
+ assignment == :control ? nil : assignment # avoid caching control
30
52
  end
31
53
 
54
+ protected
55
+
32
56
  def validate!
33
57
  # base is always valid
34
58
  end
35
59
 
36
- def execute
37
- variant_names.first
60
+ def execute_assigment
61
+ behavior_names.first
62
+ end
63
+
64
+ private
65
+
66
+ def require_experiment(method_name)
67
+ return if @experiment.present?
68
+
69
+ raise ArgumentError, "you need to call `for` with an experiment instance before chaining `#{method_name}`"
70
+ end
71
+
72
+ def behavior_names
73
+ options[:include_control] ? [:control] + variant_names : variant_names
38
74
  end
39
75
  end
40
76
  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
91
140
  end
92
141
 
93
- if experiment == klass && !classes
94
- raise ArgumentError, "#{matcher_name} matcher requires an instance of an experiment"
142
+ chain :with do |expected|
143
+ @return_expected = true
144
+ @expected_return = expected
95
145
  end
96
146
 
97
- 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
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)
190
+ @experiment = require_experiment(experiment, 'segment')
191
+ @experiment.context(context)
192
+ @experiment.instance_variable_set(:@_assigned_variant_name, nil)
193
+ @experiment.run_callbacks(:segmentation)
126
194
 
127
- experiment.instance_variable_set(ivar, nil)
128
- experiment.run_callbacks(:segmentation_check)
129
-
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)
208
274
  end
209
275
 
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
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})"
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})"
224
293
  end
294
+
295
+ details.unshift(base).join("\n")
225
296
  end
226
297
  end
227
298
  end
@@ -234,10 +305,28 @@ RSpec.configure do |config|
234
305
 
235
306
  config.before(:each, :experiment) 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
 
239
314
  config.include Gitlab::Experiment::RSpecMatchers, :experiment
240
315
  config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
241
316
  metadata[:type] = :experiment
242
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)
243
332
  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.2'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end