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