lab.nexedi.com will be down from Thursday, 20 March 2025, 07:30:00 UTC for a duration of approximately 2 hours

experiment_spec.rb 14.6 KB
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 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Experiment do
  include AfterNextHelpers

  subject { build(:experiment) }

  describe 'associations' do
    it { is_expected.to have_many(:experiment_users) }
    it { is_expected.to have_many(:experiment_subjects) }
  end

  describe 'validations' do
    it { is_expected.to validate_presence_of(:name) }
    it { is_expected.to validate_uniqueness_of(:name) }
    it { is_expected.to validate_length_of(:name).is_at_most(255) }
  end

  describe '.add_user' do
    let_it_be(:experiment_name) { :experiment_key }
    let_it_be(:user) { 'a user' }
    let_it_be(:group) { 'a group' }
    let_it_be(:context) { { a: 42 } }

    subject(:add_user) { described_class.add_user(experiment_name, group, user, context) }

    context 'when an experiment with the provided name does not exist' do
      it 'creates a new experiment record' do
        allow_next_instance_of(described_class) do |experiment|
          allow(experiment).to receive(:record_user_and_group).with(user, group, context)
        end
        expect { add_user }.to change(described_class, :count).by(1)
      end

      it 'forwards the user, group_type, and context to the instance' do
        expect_next_instance_of(described_class) do |experiment|
          expect(experiment).to receive(:record_user_and_group).with(user, group, context)
        end
        add_user
      end
    end

    context 'when an experiment with the provided name already exists' do
      let_it_be(:experiment) { create(:experiment, name: experiment_name) }

      it 'does not create a new experiment record' do
        allow_next_found_instance_of(described_class) do |experiment|
          allow(experiment).to receive(:record_user_and_group).with(user, group, context)
        end
        expect { add_user }.not_to change(described_class, :count)
      end

      it 'forwards the user, group_type, and context to the instance' do
        expect_next_found_instance_of(described_class) do |experiment|
          expect(experiment).to receive(:record_user_and_group).with(user, group, context)
        end
        add_user
      end
    end

    it 'works without the optional context argument' do
      allow_next_instance_of(described_class) do |experiment|
        expect(experiment).to receive(:record_user_and_group).with(user, group, {})
      end

      expect { described_class.add_user(experiment_name, group, user) }.not_to raise_error
    end
  end

  describe '.add_group' do
    let_it_be(:experiment_name) { :experiment_key }
    let_it_be(:variant) { :control }
    let_it_be(:group) { build(:group) }

    subject(:add_group) { described_class.add_group(experiment_name, variant: variant, group: group) }

    context 'when an experiment with the provided name does not exist' do
      it 'creates a new experiment record' do
        allow_next(described_class, name: :experiment_key)
          .to receive(:record_subject_and_variant!).with(group, variant)

        expect { add_group }.to change(described_class, :count).by(1)
      end
    end

    context 'when an experiment with the provided name already exists' do
      before do
        create(:experiment, name: experiment_name)
      end

      it 'does not create a new experiment record' do
        expect { add_group }.not_to change(described_class, :count)
      end
    end
  end

  describe '.record_conversion_event' do
    let_it_be(:user) { build(:user) }
    let_it_be(:context) { { a: 42 } }

    let(:experiment_key) { :test_experiment }

    subject(:record_conversion_event) { described_class.record_conversion_event(experiment_key, user, context) }

    context 'when no matching experiment exists' do
      it 'creates the experiment and uses it' do
        expect_next_instance_of(described_class) do |experiment|
          expect(experiment).to receive(:record_conversion_event_for_user)
        end
        expect { record_conversion_event }.to change { described_class.count }.by(1)
      end

      context 'but we are unable to successfully create one' do
        let(:experiment_key) { nil }

        it 'raises a RecordInvalid error' do
          expect { record_conversion_event }.to raise_error(ActiveRecord::RecordInvalid)
        end
      end
    end

    context 'when a matching experiment already exists' do
      before do
        create(:experiment, name: experiment_key)
      end

      it 'sends record_conversion_event_for_user to the experiment instance' do
        expect_next_found_instance_of(described_class) do |experiment|
          expect(experiment).to receive(:record_conversion_event_for_user).with(user, context)
        end
        record_conversion_event
      end
    end
  end

  shared_examples 'experiment user with context' do
    let_it_be(:context) { { a: 42, 'b' => 34, 'c': { c1: 100, c2: 'c2', e: :e }, d: [1, 3] } }
    let_it_be(:initial_expected_context) { { 'a' => 42, 'b' => 34, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [1, 3] } }

    before do
      subject
      experiment.record_user_and_group(user, :experimental, {})
    end

    it 'has an initial context with stringified keys' do
      expect(ExperimentUser.last.context).to eq(initial_expected_context)
    end

    context 'when updated' do
      before do
        subject
        experiment.record_user_and_group(user, :experimental, new_context)
      end

      context 'with an empty context' do
        let_it_be(:new_context) { {} }

        it 'keeps the initial context' do
          expect(ExperimentUser.last.context).to eq(initial_expected_context)
        end
      end

      context 'with string keys' do
        let_it_be(:new_context) { { f: :some_symbol } }

        it 'adds new symbols stringified' do
          expected_context = initial_expected_context.merge('f' => 'some_symbol')
          expect(ExperimentUser.last.context).to eq(expected_context)
        end
      end

      context 'with atomic values or array values' do
        let_it_be(:new_context) { { b: 97, d: [99] } }

        it 'overrides the values' do
          expected_context = { 'a' => 42, 'b' => 97, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [99] }
          expect(ExperimentUser.last.context).to eq(expected_context)
        end
      end

      context 'with nested hashes' do
        let_it_be(:new_context) { { c: { g: 107 } } }

        it 'inserts nested additional values in the same keys' do
          expected_context = initial_expected_context.deep_merge('c' => { 'g' => 107 })
          expect(ExperimentUser.last.context).to eq(expected_context)
        end
      end
    end
  end

  describe '#record_conversion_event_for_user' do
    let_it_be(:user) { create(:user) }
    let_it_be(:experiment) { create(:experiment) }
    let_it_be(:context) { { a: 42 } }

    subject { experiment.record_conversion_event_for_user(user, context) }

    context 'when no existing experiment_user record exists for the given user' do
      it 'does not update or create an experiment_user record' do
        expect { subject }.not_to change { ExperimentUser.all.to_a }
      end
    end

    context 'when an existing experiment_user exists for the given user' do
      context 'but it has already been converted' do
        let!(:experiment_user) { create(:experiment_user, experiment: experiment, user: user, converted_at: 2.days.ago) }

        it 'does not update the converted_at value' do
          expect { subject }.not_to change { experiment_user.converted_at }
        end

        it_behaves_like 'experiment user with context' do
          before do
            experiment.record_user_and_group(user, :experimental, context)
          end
        end
      end

      context 'and it has not yet been converted' do
        let(:experiment_user) { create(:experiment_user, experiment: experiment, user: user) }

        it 'updates the converted_at value' do
          expect { subject }.to change { experiment_user.reload.converted_at }
        end

        it_behaves_like 'experiment user with context' do
          before do
            experiment.record_user_and_group(user, :experimental, context)
          end
        end
      end
    end
  end

  describe '#record_conversion_event_for_subject' do
    let_it_be(:user) { create(:user) }
    let_it_be(:experiment) { create(:experiment) }
    let_it_be(:context) { { a: 42 } }

    subject(:record_conversion) { experiment.record_conversion_event_for_subject(user, context) }

    context 'when no existing experiment_subject record exists for the given user' do
      it 'does not update or create an experiment_subject record' do
        expect { record_conversion }.not_to change { ExperimentSubject.all.to_a }
      end
    end

    context 'when an existing experiment_subject exists for the given user' do
      context 'but it has already been converted' do
        let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user, converted_at: 2.days.ago) }

        it 'does not update the converted_at value' do
          expect { record_conversion }.not_to change { experiment_subject.converted_at }
        end
      end

      context 'and it has not yet been converted' do
        let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user) }

        it 'updates the converted_at value' do
          expect { record_conversion }.to change { experiment_subject.reload.converted_at }
        end
      end

      context 'with no existing context' do
        let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user) }

        it 'updates the context' do
          expect { record_conversion }.to change { experiment_subject.reload.context }.to('a' => 42)
        end
      end

      context 'with an existing context' do
        let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user, converted_at: 2.days.ago, context: { b: 1 } ) }

        it 'merges the context' do
          expect { record_conversion }.to change { experiment_subject.reload.context }.to('a' => 42, 'b' => 1)
        end
      end
    end
  end

  describe '#record_subject_and_variant!' do
    let_it_be(:subject_to_record) { create(:group) }
    let_it_be(:variant) { :control }
    let_it_be(:experiment) { create(:experiment) }

    subject(:record_subject_and_variant!) { experiment.record_subject_and_variant!(subject_to_record, variant) }

    context 'when no existing experiment_subject record exists for the given subject' do
      it 'creates an experiment_subject record' do
        expect { record_subject_and_variant! }.to change(ExperimentSubject, :count).by(1)
        expect(ExperimentSubject.last.variant).to eq(variant.to_s)
      end
    end

    context 'when an existing experiment_subject exists for the given subject' do
      let_it_be(:experiment_subject) do
        create(:experiment_subject, experiment: experiment, namespace: subject_to_record, user: nil, variant: :experimental)
      end

      context 'when it belongs to the same variant' do
        let(:variant) { :experimental }

        it 'does not initiate a transaction' do
          expect(Experiment.connection).not_to receive(:transaction)

          subject
        end
      end

      context 'but it belonged to a different variant' do
        it 'updates the variant value' do
          expect { record_subject_and_variant! }.to change { experiment_subject.reload.variant }.to('control')
        end
      end
    end

    describe 'providing a subject to record' do
      context 'when given a group as subject' do
        it 'saves the namespace as the experiment subject' do
          expect(record_subject_and_variant!.namespace).to eq(subject_to_record)
        end
      end

      context 'when given a users namespace as subject' do
        let_it_be(:subject_to_record) { build(:namespace) }

        it 'saves the namespace as the experiment_subject' do
          expect(record_subject_and_variant!.namespace).to eq(subject_to_record)
        end
      end

      context 'when given a user as subject' do
        let_it_be(:subject_to_record) { build(:user) }

        it 'saves the user as experiment_subject user' do
          expect(record_subject_and_variant!.user).to eq(subject_to_record)
        end
      end

      context 'when given a project as subject' do
        let_it_be(:subject_to_record) { build(:project) }

        it 'saves the project as experiment_subject user' do
          expect(record_subject_and_variant!.project).to eq(subject_to_record)
        end
      end

      context 'when given no subject' do
        let_it_be(:subject_to_record) { nil }

        it 'raises an error' do
          expect { record_subject_and_variant! }.to raise_error('Incompatible subject provided!')
        end
      end

      context 'when given an incompatible subject' do
        let_it_be(:subject_to_record) { build(:ci_build) }

        it 'raises an error' do
          expect { record_subject_and_variant! }.to raise_error('Incompatible subject provided!')
        end
      end
    end
  end

  describe '#record_user_and_group' do
    let_it_be(:experiment) { create(:experiment) }
    let_it_be(:user) { create(:user) }
    let_it_be(:group) { :control }
    let_it_be(:context) { { a: 42 } }

    subject { experiment.record_user_and_group(user, group, context) }

    context 'when an experiment_user does not yet exist for the given user' do
      it 'creates a new experiment_user record' do
        expect { subject }.to change(ExperimentUser, :count).by(1)
      end

      it 'assigns the correct group_type to the experiment_user' do
        subject

        expect(ExperimentUser.last.group_type).to eq('control')
      end

      it 'adds the correct context to the experiment_user' do
        subject

        expect(ExperimentUser.last.context).to eq({ 'a' => 42 })
      end
    end

    context 'when an experiment_user already exists for the given user' do
      before do
        # Create an existing experiment_user for this experiment and the :control group
        experiment.record_user_and_group(user, :control)
      end

      it 'does not create a new experiment_user record' do
        expect { subject }.not_to change(ExperimentUser, :count)
      end

      context 'when group type or context did not change' do
        let(:context) { {} }

        it 'does not initiate a transaction' do
          expect(Experiment.connection).not_to receive(:transaction)

          subject
        end
      end

      context 'but the group_type and context has changed' do
        let(:group) { :experimental }

        it 'updates the existing experiment_user record with group_type' do
          expect { subject }.to change { ExperimentUser.last.group_type }
        end
      end

      it_behaves_like 'experiment user with context'
    end
  end
end