split 3.4.1 → 4.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.rubocop.yml +177 -1
  4. data/.rubocop_todo.yml +40 -493
  5. data/.travis.yml +14 -42
  6. data/CHANGELOG.md +35 -0
  7. data/Gemfile +1 -0
  8. data/README.md +19 -1
  9. data/Rakefile +1 -0
  10. data/lib/split.rb +8 -2
  11. data/lib/split/algorithms/block_randomization.rb +1 -0
  12. data/lib/split/algorithms/weighted_sample.rb +2 -1
  13. data/lib/split/algorithms/whiplash.rb +3 -2
  14. data/lib/split/alternative.rb +1 -0
  15. data/lib/split/cache.rb +28 -0
  16. data/lib/split/combined_experiments_helper.rb +1 -0
  17. data/lib/split/configuration.rb +6 -12
  18. data/lib/split/dashboard.rb +17 -2
  19. data/lib/split/dashboard/helpers.rb +1 -0
  20. data/lib/split/dashboard/pagination_helpers.rb +1 -0
  21. data/lib/split/dashboard/paginator.rb +1 -0
  22. data/lib/split/dashboard/public/dashboard.js +10 -0
  23. data/lib/split/dashboard/public/style.css +5 -0
  24. data/lib/split/dashboard/views/_controls.erb +13 -0
  25. data/lib/split/encapsulated_helper.rb +3 -2
  26. data/lib/split/engine.rb +1 -0
  27. data/lib/split/exceptions.rb +1 -0
  28. data/lib/split/experiment.rb +81 -59
  29. data/lib/split/experiment_catalog.rb +1 -3
  30. data/lib/split/extensions/string.rb +1 -0
  31. data/lib/split/goals_collection.rb +1 -0
  32. data/lib/split/helper.rb +26 -7
  33. data/lib/split/metric.rb +2 -1
  34. data/lib/split/persistence.rb +4 -2
  35. data/lib/split/persistence/cookie_adapter.rb +1 -0
  36. data/lib/split/persistence/redis_adapter.rb +5 -0
  37. data/lib/split/persistence/session_adapter.rb +1 -0
  38. data/lib/split/redis_interface.rb +8 -28
  39. data/lib/split/trial.rb +20 -10
  40. data/lib/split/user.rb +14 -2
  41. data/lib/split/version.rb +2 -4
  42. data/lib/split/zscore.rb +1 -0
  43. data/spec/alternative_spec.rb +1 -1
  44. data/spec/cache_spec.rb +88 -0
  45. data/spec/configuration_spec.rb +1 -14
  46. data/spec/dashboard_spec.rb +45 -5
  47. data/spec/encapsulated_helper_spec.rb +1 -1
  48. data/spec/experiment_spec.rb +78 -7
  49. data/spec/goals_collection_spec.rb +1 -1
  50. data/spec/helper_spec.rb +68 -32
  51. data/spec/persistence/cookie_adapter_spec.rb +1 -1
  52. data/spec/persistence/redis_adapter_spec.rb +9 -0
  53. data/spec/redis_interface_spec.rb +0 -69
  54. data/spec/spec_helper.rb +5 -6
  55. data/spec/trial_spec.rb +45 -19
  56. data/spec/user_spec.rb +17 -0
  57. data/split.gemspec +7 -7
  58. metadata +23 -34
  59. data/gemfiles/4.2.gemfile +0 -9
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  module Persistence
4
5
  class SessionAdapter
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  # Simplifies the interface to Redis.
4
5
  class RedisInterface
@@ -7,40 +8,19 @@ module Split
7
8
  end
8
9
 
9
10
  def persist_list(list_name, list_values)
10
- max_index = list_length(list_name) - 1
11
- list_values.each_with_index do |value, index|
12
- if index > max_index
13
- add_to_list(list_name, value)
14
- else
15
- set_list_index(list_name, index, value)
11
+ if list_values.length > 0
12
+ redis.multi do |multi|
13
+ tmp_list = "#{list_name}_tmp"
14
+ multi.rpush(tmp_list, list_values)
15
+ multi.rename(tmp_list, list_name)
16
16
  end
17
17
  end
18
- make_list_length(list_name, list_values.length)
19
- list_values
20
- end
21
-
22
- def add_to_list(list_name, value)
23
- redis.rpush(list_name, value)
24
- end
25
-
26
- def set_list_index(list_name, index, value)
27
- redis.lset(list_name, index, value)
28
- end
29
-
30
- def list_length(list_name)
31
- redis.llen(list_name)
32
- end
33
18
 
34
- def remove_last_item_from_list(list_name)
35
- redis.rpop(list_name)
36
- end
37
-
38
- def make_list_length(list_name, new_length)
39
- redis.ltrim(list_name, 0, new_length - 1)
19
+ list_values
40
20
  end
41
21
 
42
22
  def add_to_set(set_name, value)
43
- redis.sadd(set_name, value) unless redis.sismember(set_name, value)
23
+ redis.sadd(set_name, value)
44
24
  end
45
25
 
46
26
  private
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class Trial
5
+ attr_accessor :goals
4
6
  attr_accessor :experiment
5
7
  attr_writer :metadata
6
8
 
@@ -8,11 +10,12 @@ module Split
8
10
  self.experiment = attrs.delete(:experiment)
9
11
  self.alternative = attrs.delete(:alternative)
10
12
  self.metadata = attrs.delete(:metadata)
13
+ self.goals = attrs.delete(:goals) || []
11
14
 
12
15
  @user = attrs.delete(:user)
13
16
  @options = attrs
14
17
 
15
- @alternative_choosen = false
18
+ @alternative_chosen = false
16
19
  end
17
20
 
18
21
  def metadata
@@ -33,7 +36,7 @@ module Split
33
36
  end
34
37
  end
35
38
 
36
- def complete!(goals=[], context = nil)
39
+ def complete!(context = nil)
37
40
  if alternative
38
41
  if Array(goals).empty?
39
42
  alternative.increment_completion
@@ -51,8 +54,9 @@ module Split
51
54
  def choose!(context = nil)
52
55
  @user.cleanup_old_experiments!
53
56
  # Only run the process once
54
- return alternative if @alternative_choosen
57
+ return alternative if @alternative_chosen
55
58
 
59
+ new_participant = @user[@experiment.key].nil?
56
60
  if override_is_alternative?
57
61
  self.alternative = @options[:override]
58
62
  if should_store_alternative? && !@user[@experiment.key]
@@ -70,19 +74,25 @@ module Split
70
74
  else
71
75
  self.alternative = @user[@experiment.key]
72
76
  if alternative.nil?
73
- self.alternative = @experiment.next_alternative
77
+ if @experiment.cohorting_disabled?
78
+ self.alternative = @experiment.control
79
+ else
80
+ self.alternative = @experiment.next_alternative
74
81
 
75
- # Increment the number of participants since we are actually choosing a new alternative
76
- self.alternative.increment_participation
82
+ # Increment the number of participants since we are actually choosing a new alternative
83
+ self.alternative.increment_participation
77
84
 
78
- run_callback context, Split.configuration.on_trial_choose
85
+ run_callback context, Split.configuration.on_trial_choose
86
+ end
79
87
  end
80
88
  end
81
89
  end
82
90
 
83
- @user[@experiment.key] = alternative.name if !@experiment.has_winner? && should_store_alternative?
84
- @alternative_choosen = true
85
- run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled?
91
+ new_participant_and_cohorting_disabled = new_participant && @experiment.cohorting_disabled?
92
+
93
+ @user[@experiment.key] = alternative.name unless @experiment.has_winner? || !should_store_alternative? || new_participant_and_cohorting_disabled
94
+ @alternative_chosen = true
95
+ run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled? || new_participant_and_cohorting_disabled
86
96
  alternative
87
97
  end
88
98
 
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'forwardable'
3
4
 
4
5
  module Split
@@ -7,7 +8,7 @@ module Split
7
8
  def_delegators :@user, :keys, :[], :[]=, :delete
8
9
  attr_reader :user
9
10
 
10
- def initialize(context, adapter=nil)
11
+ def initialize(context, adapter = nil)
11
12
  @user = adapter || Split::Persistence.adapter.new(context)
12
13
  @cleaned_up = false
13
14
  end
@@ -27,7 +28,8 @@ module Split
27
28
  def max_experiments_reached?(experiment_key)
28
29
  if Split.configuration.allow_multiple_experiments == 'control'
29
30
  experiments = active_experiments
30
- count_control = experiments.count {|k,v| k == experiment_key || v == 'control'}
31
+ experiment_key_without_version = key_without_version(experiment_key)
32
+ count_control = experiments.count {|k, v| k == experiment_key_without_version || v == 'control'}
31
33
  experiments.size > count_control
32
34
  else
33
35
  !Split.configuration.allow_multiple_experiments &&
@@ -52,6 +54,16 @@ module Split
52
54
  experiment_pairs
53
55
  end
54
56
 
57
+ def self.find(user_id, adapter)
58
+ adapter = adapter.is_a?(Symbol) ? Split::Persistence::ADAPTERS[adapter] : adapter
59
+
60
+ if adapter.respond_to?(:find)
61
+ User.new(nil, adapter.find(user_id))
62
+ else
63
+ nil
64
+ end
65
+ end
66
+
55
67
  private
56
68
 
57
69
  def keys_without_experiment(keys, experiment_key)
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
- MAJOR = 3
4
- MINOR = 4
5
- PATCH = 1
6
- VERSION = [MAJOR, MINOR, PATCH].join('.')
4
+ VERSION = "4.0.0.pre"
7
5
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Split
3
4
  class Zscore
4
5
 
@@ -126,7 +126,7 @@ describe Split::Alternative do
126
126
 
127
127
  it "should save to redis" do
128
128
  alternative.save
129
- expect(Split.redis.exists('basket_text:Basket')).to be true
129
+ expect(Split.redis.exists?('basket_text:Basket')).to be true
130
130
  end
131
131
 
132
132
  it "should increment participation count" do
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+
4
+ describe Split::Cache do
5
+
6
+ let(:namespace) { :test_namespace }
7
+ let(:key) { :test_key }
8
+ let(:now) { 1606189017 }
9
+
10
+ before { allow(Time).to receive(:now).and_return(now) }
11
+
12
+ describe 'clear' do
13
+
14
+ before { Split.configuration.cache = true }
15
+
16
+ it 'clears the cache' do
17
+ expect(Time).to receive(:now).and_return(now).exactly(2).times
18
+ Split::Cache.fetch(namespace, key) { Time.now }
19
+ Split::Cache.clear
20
+ Split::Cache.fetch(namespace, key) { Time.now }
21
+ end
22
+ end
23
+
24
+ describe 'clear_key' do
25
+ before { Split.configuration.cache = true }
26
+
27
+ it 'clears the cache' do
28
+ expect(Time).to receive(:now).and_return(now).exactly(3).times
29
+ Split::Cache.fetch(namespace, :key1) { Time.now }
30
+ Split::Cache.fetch(namespace, :key2) { Time.now }
31
+ Split::Cache.clear_key(:key1)
32
+
33
+ Split::Cache.fetch(namespace, :key1) { Time.now }
34
+ Split::Cache.fetch(namespace, :key2) { Time.now }
35
+ end
36
+ end
37
+
38
+ describe 'fetch' do
39
+
40
+ subject { Split::Cache.fetch(namespace, key) { Time.now } }
41
+
42
+ context 'when cache disabled' do
43
+
44
+ before { Split.configuration.cache = false }
45
+
46
+ it 'returns the yield' do
47
+ expect(subject).to eql(now)
48
+ end
49
+
50
+ it 'yields every time' do
51
+ expect(Time).to receive(:now).and_return(now).exactly(2).times
52
+ Split::Cache.fetch(namespace, key) { Time.now }
53
+ Split::Cache.fetch(namespace, key) { Time.now }
54
+ end
55
+ end
56
+
57
+ context 'when cache enabled' do
58
+
59
+ before { Split.configuration.cache = true }
60
+
61
+ it 'returns the yield' do
62
+ expect(subject).to eql(now)
63
+ end
64
+
65
+ it 'yields once' do
66
+ expect(Time).to receive(:now).and_return(now).once
67
+ Split::Cache.fetch(namespace, key) { Time.now }
68
+ Split::Cache.fetch(namespace, key) { Time.now }
69
+ end
70
+
71
+ it 'honors namespace' do
72
+ expect(Split::Cache.fetch(:a, key) { :a }).to eql(:a)
73
+ expect(Split::Cache.fetch(:b, key) { :b }).to eql(:b)
74
+
75
+ expect(Split::Cache.fetch(:a, key) { :a }).to eql(:a)
76
+ expect(Split::Cache.fetch(:b, key) { :b }).to eql(:b)
77
+ end
78
+
79
+ it 'honors key' do
80
+ expect(Split::Cache.fetch(namespace, :a) { :a }).to eql(:a)
81
+ expect(Split::Cache.fetch(namespace, :b) { :b }).to eql(:b)
82
+
83
+ expect(Split::Cache.fetch(namespace, :a) { :a }).to eql(:a)
84
+ expect(Split::Cache.fetch(namespace, :b) { :b }).to eql(:b)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -212,20 +212,6 @@ describe Split::Configuration do
212
212
  expect(@config.normalized_experiments).to eq({:my_experiment=>{:alternatives=>[{"control_opt"=>0.67}, [{"second_opt"=>0.1}, {"third_opt"=>0.23}]]}})
213
213
  end
214
214
 
215
- context 'redis_url configuration [DEPRECATED]' do
216
- it 'should warn on set and assign to #redis' do
217
- expect(@config).to receive(:warn).with(/\[DEPRECATED\]/) { nil }
218
- @config.redis_url = 'example_url'
219
- expect(@config.redis).to eq('example_url')
220
- end
221
-
222
- it 'should warn on get and return #redis' do
223
- expect(@config).to receive(:warn).with(/\[DEPRECATED\]/) { nil }
224
- @config.redis = 'example_url'
225
- expect(@config.redis_url).to eq('example_url')
226
- end
227
- end
228
-
229
215
  context "redis configuration" do
230
216
  it "should default to local redis server" do
231
217
  expect(@config.redis).to eq("redis://localhost:6379")
@@ -240,6 +226,7 @@ describe Split::Configuration do
240
226
  it "should use the ENV variable" do
241
227
  ENV['REDIS_URL'] = "env_redis_url"
242
228
  expect(Split::Configuration.new.redis).to eq("env_redis_url")
229
+ ENV.delete('REDIS_URL')
243
230
  end
244
231
  end
245
232
  end
@@ -6,8 +6,16 @@ require 'split/dashboard'
6
6
  describe Split::Dashboard do
7
7
  include Rack::Test::Methods
8
8
 
9
+ class TestDashboard < Split::Dashboard
10
+ include Split::Helper
11
+
12
+ get '/my_experiment' do
13
+ ab_test(params[:experiment], 'blue', 'red')
14
+ end
15
+ end
16
+
9
17
  def app
10
- @app ||= Split::Dashboard
18
+ @app ||= TestDashboard
11
19
  end
12
20
 
13
21
  def link(color)
@@ -90,8 +98,17 @@ describe Split::Dashboard do
90
98
  it "should set current user's alternative" do
91
99
  blue_link.participant_count = 7
92
100
  post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
93
- expect(user[experiment.key]).to eq("blue")
94
- expect(blue_link.participant_count).to eq(8)
101
+
102
+ get "/my_experiment?experiment=#{experiment.name}"
103
+ expect(last_response.body).to include("blue")
104
+ end
105
+
106
+ it "should not modify an existing user" do
107
+ blue_link.participant_count = 7
108
+ post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
109
+
110
+ expect(user[experiment.key]).to eq("red")
111
+ expect(blue_link.participant_count).to eq(7)
95
112
  end
96
113
  end
97
114
 
@@ -108,8 +125,9 @@ describe Split::Dashboard do
108
125
  it "should set current user's alternative" do
109
126
  blue_link.participant_count = 7
110
127
  post "/force_alternative?experiment=#{experiment.name}", alternative: "blue"
111
- expect(user[experiment.key]).to eq("blue")
112
- expect(blue_link.participant_count).to eq(8)
128
+
129
+ get "/my_experiment?experiment=#{experiment.name}"
130
+ expect(last_response.body).to include("blue")
113
131
  end
114
132
  end
115
133
  end
@@ -161,6 +179,28 @@ describe Split::Dashboard do
161
179
  end
162
180
  end
163
181
 
182
+ describe "update cohorting" do
183
+ it "calls enable of cohorting when action is enable" do
184
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "enable" }
185
+
186
+ expect(experiment.cohorting_disabled?).to eq false
187
+ end
188
+
189
+ it "calls disable of cohorting when action is disable" do
190
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "disable" }
191
+
192
+ expect(experiment.cohorting_disabled?).to eq true
193
+ end
194
+
195
+ it "calls neither enable or disable cohorting when passed invalid action" do
196
+ previous_value = experiment.cohorting_disabled?
197
+
198
+ post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "other" }
199
+
200
+ expect(experiment.cohorting_disabled?).to eq previous_value
201
+ end
202
+ end
203
+
164
204
  it "should reset an experiment" do
165
205
  red_link.participant_count = 5
166
206
  blue_link.participant_count = 7
@@ -21,7 +21,7 @@ describe Split::EncapsulatedHelper do
21
21
  end
22
22
 
23
23
  it "calls the block with selected alternative" do
24
- expect{|block| ab_test('link_color', 'red', 'red', &block) }.to yield_with_args('red', nil)
24
+ expect{|block| ab_test('link_color', 'red', 'red', &block) }.to yield_with_args('red', {})
25
25
  end
26
26
 
27
27
  context "inside a view" do
@@ -37,7 +37,7 @@ describe Split::Experiment do
37
37
 
38
38
  it "should save to redis" do
39
39
  experiment.save
40
- expect(Split.redis.exists('basket_text')).to be true
40
+ expect(Split.redis.exists?('basket_text')).to be true
41
41
  end
42
42
 
43
43
  it "should save the start time to redis" do
@@ -85,7 +85,7 @@ describe Split::Experiment do
85
85
  it "should not create duplicates when saving multiple times" do
86
86
  experiment.save
87
87
  experiment.save
88
- expect(Split.redis.exists('basket_text')).to be true
88
+ expect(Split.redis.exists?('basket_text')).to be true
89
89
  expect(Split.redis.lrange('basket_text', 0, -1)).to eq(['{"Basket":1}', '{"Cart":1}'])
90
90
  end
91
91
 
@@ -118,6 +118,23 @@ describe Split::Experiment do
118
118
  experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :resettable => false)
119
119
  expect(experiment.resettable).to be_falsey
120
120
  end
121
+
122
+ context 'from configuration' do
123
+ let(:experiment_name) { :my_experiment }
124
+ let(:experiments) do
125
+ {
126
+ experiment_name => {
127
+ :alternatives => ['Control Opt', 'Alt one']
128
+ }
129
+ }
130
+ end
131
+
132
+ before { Split.configuration.experiments = experiments }
133
+
134
+ it 'assigns default values to the experiment' do
135
+ expect(Split::Experiment.new(experiment_name).resettable).to eq(true)
136
+ end
137
+ end
121
138
  end
122
139
 
123
140
  describe 'persistent configuration' do
@@ -134,10 +151,23 @@ describe Split::Experiment do
134
151
 
135
152
  describe '#metadata' do
136
153
  let(:experiment) { Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash, :metadata => meta) }
154
+ let(:meta) { { a: 'b' }}
155
+
156
+ before do
157
+ experiment.save
158
+ end
159
+
160
+ it "should delete the key when metadata is removed" do
161
+ experiment.metadata = nil
162
+ experiment.save
163
+
164
+ expect(Split.redis.exists?(experiment.metadata_key)).to be_falsey
165
+ end
166
+
137
167
  context 'simple hash' do
138
168
  let(:meta) { { 'basket' => 'a', 'cart' => 'b' } }
169
+
139
170
  it "should persist metadata in redis" do
140
- experiment.save
141
171
  e = Split::ExperimentCatalog.find('basket_text')
142
172
  expect(e).to eq(experiment)
143
173
  expect(e.metadata).to eq(meta)
@@ -147,7 +177,6 @@ describe Split::Experiment do
147
177
  context 'nested hash' do
148
178
  let(:meta) { { 'basket' => { 'one' => 'two' }, 'cart' => 'b' } }
149
179
  it "should persist metadata in redis" do
150
- experiment.save
151
180
  e = Split::ExperimentCatalog.find('basket_text')
152
181
  expect(e).to eq(experiment)
153
182
  expect(e.metadata).to eq(meta)
@@ -180,7 +209,7 @@ describe Split::Experiment do
180
209
  experiment.save
181
210
 
182
211
  experiment.delete
183
- expect(Split.redis.exists('link_color')).to be false
212
+ expect(Split.redis.exists?('link_color')).to be false
184
213
  expect(Split::ExperimentCatalog.find('link_color')).to be_nil
185
214
  end
186
215
 
@@ -206,8 +235,14 @@ describe Split::Experiment do
206
235
  experiment.delete
207
236
  expect(experiment.start_time).to be_nil
208
237
  end
209
- end
210
238
 
239
+ it "should default cohorting back to false" do
240
+ experiment.disable_cohorting
241
+ expect(experiment.cohorting_disabled?).to eq(true)
242
+ experiment.delete
243
+ expect(experiment.cohorting_disabled?).to eq(false)
244
+ end
245
+ end
211
246
 
212
247
  describe 'winner' do
213
248
  it "should have no winner initially" do
@@ -216,12 +251,17 @@ describe Split::Experiment do
216
251
  end
217
252
 
218
253
  describe 'winner=' do
219
- it "should allow you to specify a winner" do
254
+ it 'should allow you to specify a winner' do
220
255
  experiment.save
221
256
  experiment.winner = 'red'
222
257
  expect(experiment.winner.name).to eq('red')
223
258
  end
224
259
 
260
+ it 'should call the on_experiment_winner_choose hook' do
261
+ expect(Split.configuration.on_experiment_winner_choose).to receive(:call)
262
+ experiment.winner = 'green'
263
+ end
264
+
225
265
  context 'when has_winner state is memoized' do
226
266
  before { expect(experiment).to_not have_winner }
227
267
 
@@ -370,6 +410,22 @@ describe Split::Experiment do
370
410
  end
371
411
  end
372
412
 
413
+ describe "#cohorting_disabled?" do
414
+ it "returns false when nothing has been configured" do
415
+ expect(experiment.cohorting_disabled?).to eq false
416
+ end
417
+
418
+ it "returns true when enable_cohorting is performed" do
419
+ experiment.enable_cohorting
420
+ expect(experiment.cohorting_disabled?).to eq false
421
+ end
422
+
423
+ it "returns false when nothing has been configured" do
424
+ experiment.disable_cohorting
425
+ expect(experiment.cohorting_disabled?).to eq true
426
+ end
427
+ end
428
+
373
429
  describe 'changing an existing experiment' do
374
430
  def same_but_different_alternative
375
431
  Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'yellow', 'orange')
@@ -392,6 +448,21 @@ describe Split::Experiment do
392
448
  expect(same_experiment_again.version).to eq(1)
393
449
  end
394
450
 
451
+ context "when metadata is changed" do
452
+ it "should increase version" do
453
+ experiment.save
454
+ experiment.metadata = { 'foo' => 'bar' }
455
+
456
+ expect { experiment.save }.to change { experiment.version }.by(1)
457
+ end
458
+
459
+ it "does not increase version" do
460
+ experiment.metadata = nil
461
+ experiment.save
462
+ expect { experiment.save }.to change { experiment.version }.by(0)
463
+ end
464
+ end
465
+
395
466
  context 'when experiment configuration is changed' do
396
467
  let(:reset_manually) { false }
397
468