split 0.4.6 → 0.5.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.
Files changed (43) hide show
  1. data/.travis.yml +11 -3
  2. data/CHANGELOG.mdown +22 -1
  3. data/CONTRIBUTING.md +10 -0
  4. data/LICENSE +1 -1
  5. data/README.mdown +235 -60
  6. data/lib/split.rb +8 -9
  7. data/lib/split/algorithms.rb +3 -0
  8. data/lib/split/algorithms/weighted_sample.rb +17 -0
  9. data/lib/split/algorithms/whiplash.rb +35 -0
  10. data/lib/split/alternative.rb +12 -4
  11. data/lib/split/configuration.rb +91 -1
  12. data/lib/split/dashboard/helpers.rb +3 -3
  13. data/lib/split/dashboard/views/_experiment.erb +1 -1
  14. data/lib/split/exceptions.rb +4 -0
  15. data/lib/split/experiment.rb +112 -24
  16. data/lib/split/extensions.rb +3 -0
  17. data/lib/split/extensions/array.rb +4 -0
  18. data/lib/split/extensions/string.rb +15 -0
  19. data/lib/split/helper.rb +87 -55
  20. data/lib/split/metric.rb +68 -0
  21. data/lib/split/persistence.rb +28 -0
  22. data/lib/split/persistence/cookie_adapter.rb +44 -0
  23. data/lib/split/persistence/session_adapter.rb +28 -0
  24. data/lib/split/trial.rb +43 -0
  25. data/lib/split/version.rb +3 -3
  26. data/spec/algorithms/weighted_sample_spec.rb +18 -0
  27. data/spec/algorithms/whiplash_spec.rb +23 -0
  28. data/spec/alternative_spec.rb +81 -9
  29. data/spec/configuration_spec.rb +61 -9
  30. data/spec/dashboard_helpers_spec.rb +2 -5
  31. data/spec/dashboard_spec.rb +0 -2
  32. data/spec/experiment_spec.rb +144 -74
  33. data/spec/helper_spec.rb +234 -29
  34. data/spec/metric_spec.rb +30 -0
  35. data/spec/persistence/cookie_adapter_spec.rb +31 -0
  36. data/spec/persistence/session_adapter_spec.rb +31 -0
  37. data/spec/persistence_spec.rb +33 -0
  38. data/spec/spec_helper.rb +12 -0
  39. data/spec/support/cookies_mock.rb +19 -0
  40. data/spec/trial_spec.rb +59 -0
  41. data/split.gemspec +7 -3
  42. metadata +58 -29
  43. data/Guardfile +0 -5
@@ -5,13 +5,20 @@ require 'spec_helper'
5
5
  describe Split::Helper do
6
6
  include Split::Helper
7
7
 
8
- before(:each) do
9
- Split.redis.flushall
10
- @session = {}
11
- params = nil
12
- end
13
-
14
8
  describe "ab_test" do
9
+
10
+ it "should not raise an error when passed strings for alternatives" do
11
+ lambda { ab_test('xyz', '1', '2', '3') }.should_not raise_error
12
+ end
13
+
14
+ it "should raise the appropriate error when passed integers for alternatives" do
15
+ lambda { ab_test('xyz', 1, 2, 3) }.should raise_error(ArgumentError)
16
+ end
17
+
18
+ it "should raise the appropriate error when passed symbols for alternatives" do
19
+ lambda { ab_test('xyz', :a, :b, :c) }.should raise_error(ArgumentError)
20
+ end
21
+
15
22
  it "should assign a random alternative to a new user when there are an equal number of alternatives assigned" do
16
23
  ab_test('link_color', 'blue', 'red')
17
24
  ['red', 'blue'].should include(ab_user['link_color'])
@@ -74,12 +81,14 @@ describe Split::Helper do
74
81
  ab_test('link_color', {'blue' => 0.01}, 'red' => 0.2)
75
82
  experiment = Split::Experiment.find('link_color')
76
83
  experiment.alternative_names.should eql(['blue', 'red'])
84
+ # TODO: persist alternative weights
85
+ # experiment.alternatives.collect{|a| a.weight}.should == [0.01, 0.2]
77
86
  end
78
87
 
79
88
  it "should only let a user participate in one experiment at a time" do
80
- ab_test('link_color', 'blue', 'red')
89
+ link_color = ab_test('link_color', 'blue', 'red')
81
90
  ab_test('button_size', 'small', 'big')
82
- ab_user['button_size'].should eql('small')
91
+ ab_user.should eql({'link_color' => link_color})
83
92
  big = Split::Alternative.new('big', 'button_size')
84
93
  big.participant_count.should eql(0)
85
94
  small = Split::Alternative.new('small', 'button_size')
@@ -92,10 +101,19 @@ describe Split::Helper do
92
101
  end
93
102
  link_color = ab_test('link_color', 'blue', 'red')
94
103
  button_size = ab_test('button_size', 'small', 'big')
95
- ab_user['button_size'].should eql(button_size)
104
+ ab_user.should eql({'link_color' => link_color, 'button_size' => button_size})
96
105
  button_size_alt = Split::Alternative.new(button_size, 'button_size')
97
106
  button_size_alt.participant_count.should eql(1)
98
107
  end
108
+
109
+ it "should not over-write a finished key when an experiment is on a later version" do
110
+ experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
111
+ experiment.increment_version
112
+ ab_user = { experiment.key => 'blue', experiment.finished_key => true }
113
+ finshed_session = ab_user.dup
114
+ ab_test('link_color', 'blue', 'red')
115
+ ab_user.should eql(finshed_session)
116
+ end
99
117
  end
100
118
 
101
119
  describe 'finished' do
@@ -115,7 +133,7 @@ describe Split::Helper do
115
133
 
116
134
  it "should set experiment's finished key if reset is false" do
117
135
  finished(@experiment_name, :reset => false)
118
- session[:split].should eql(@experiment.key => @alternative_name, @experiment.finished_key => true)
136
+ ab_user.should eql(@experiment.key => @alternative_name, @experiment.finished_key => true)
119
137
  end
120
138
 
121
139
  it 'should not increment the counter if reset is false and the experiment has been already finished' do
@@ -125,36 +143,121 @@ describe Split::Helper do
125
143
  end
126
144
 
127
145
  it "should clear out the user's participation from their session" do
128
- session[:split].should eql(@experiment.key => @alternative_name)
146
+ ab_user.should eql(@experiment.key => @alternative_name)
129
147
  finished(@experiment_name)
130
- session[:split].should == {}
148
+ ab_user.should == {}
131
149
  end
132
150
 
133
151
  it "should not clear out the users session if reset is false" do
134
- session[:split].should eql(@experiment.key => @alternative_name)
152
+ ab_user.should eql(@experiment.key => @alternative_name)
135
153
  finished(@experiment_name, :reset => false)
136
- session[:split].should eql(@experiment.key => @alternative_name, @experiment.finished_key => true)
154
+ ab_user.should eql(@experiment.key => @alternative_name, @experiment.finished_key => true)
137
155
  end
138
156
 
139
157
  it "should reset the users session when experiment is not versioned" do
140
- session[:split].should eql(@experiment.key => @alternative_name)
158
+ ab_user.should eql(@experiment.key => @alternative_name)
141
159
  finished(@experiment_name)
142
- session[:split].should eql({})
160
+ ab_user.should eql({})
143
161
  end
144
162
 
145
163
  it "should reset the users session when experiment is versioned" do
146
164
  @experiment.increment_version
147
165
  @alternative_name = ab_test(@experiment_name, *@alternatives)
148
166
 
149
- session[:split].should eql(@experiment.key => @alternative_name)
167
+ ab_user.should eql(@experiment.key => @alternative_name)
150
168
  finished(@experiment_name)
151
- session[:split].should eql({})
169
+ ab_user.should eql({})
152
170
  end
153
171
 
154
172
  it "should do nothing where the experiment was not started by this user" do
155
- session[:split] = nil
173
+ ab_user = nil
156
174
  lambda { finished('some_experiment_not_started_by_the_user') }.should_not raise_exception
157
175
  end
176
+
177
+ it 'should not be doing other tests when it has completed one that has :reset => false' do
178
+ ab_user[@experiment.key] = @alternative_name
179
+ ab_user[@experiment.finished_key] = true
180
+ doing_other_tests?(@experiment.key).should be false
181
+ end
182
+
183
+ it "passes reset option from config" do
184
+ Split.configuration.experiments = {
185
+ @experiment_name => {
186
+ :alternatives => @alternatives,
187
+ :resettable => false,
188
+ }
189
+ }
190
+ finished @experiment_name
191
+ ab_user.should eql(@experiment.key => @alternative_name, @experiment.finished_key => true)
192
+ end
193
+
194
+ context "with metric name" do
195
+ before { Split.configuration.experiments = {} }
196
+ before { Split::Alternative.stub(:new).and_call_original }
197
+
198
+ def should_finish_experiment(experiment_name, should_finish=true)
199
+ alts = Split.configuration.experiments[experiment_name][:alternatives]
200
+ experiment = Split::Experiment.find_or_create(experiment_name, *alts)
201
+ alt_name = ab_user[experiment.key] = alts.first
202
+ alt = mock('alternative')
203
+ alt.stub(:name).and_return(alt_name)
204
+ Split::Alternative.stub(:new).with(alt_name, experiment_name).and_return(alt)
205
+ if should_finish
206
+ alt.should_receive(:increment_completion)
207
+ else
208
+ alt.should_not_receive(:increment_completion)
209
+ end
210
+ end
211
+
212
+ it "completes the test" do
213
+ Split.configuration.experiments[:my_experiment] = {
214
+ :alternatives => [ "control_opt", "other_opt" ],
215
+ :metric => :my_metric
216
+ }
217
+ should_finish_experiment :my_experiment
218
+ finished :my_metric
219
+ end
220
+
221
+ it "completes all relevant tests" do
222
+ Split.configuration.experiments = {
223
+ :exp_1 => {
224
+ :alternatives => [ "1-1", "1-2" ],
225
+ :metric => :my_metric
226
+ },
227
+ :exp_2 => {
228
+ :alternatives => [ "2-1", "2-2" ],
229
+ :metric => :another_metric
230
+ },
231
+ :exp_3 => {
232
+ :alternatives => [ "3-1", "3-2" ],
233
+ :metric => :my_metric
234
+ },
235
+ }
236
+ should_finish_experiment :exp_1
237
+ should_finish_experiment :exp_2, false
238
+ should_finish_experiment :exp_3
239
+ finished :my_metric
240
+ end
241
+
242
+ it "passes reset option" do
243
+ Split.configuration.experiments[@experiment_name] = {
244
+ :alternatives => @alternatives,
245
+ :metric => :my_metric,
246
+ :resettable => false,
247
+ }
248
+ finished :my_metric
249
+ ab_user.should eql(@experiment.key => @alternative_name, @experiment.finished_key => true)
250
+ end
251
+
252
+ it "passes through options" do
253
+ Split.configuration.experiments[@experiment_name] = {
254
+ :alternatives => @alternatives,
255
+ :metric => :my_metric,
256
+ }
257
+ finished :my_metric, :reset => false
258
+ ab_user.should eql(@experiment.key => @alternative_name, @experiment.finished_key => true)
259
+ end
260
+ end
158
261
  end
159
262
 
160
263
  describe 'conversions' do
@@ -198,6 +301,7 @@ describe Split::Helper do
198
301
  (new_red_count + new_blue_count).should eql(previous_red_count + previous_blue_count)
199
302
  end
200
303
  end
304
+
201
305
  describe 'finished' do
202
306
  it "should not increment the completed count" do
203
307
  experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
@@ -213,6 +317,7 @@ describe Split::Helper do
213
317
  end
214
318
  end
215
319
  end
320
+
216
321
  describe 'when ip address is ignored' do
217
322
  before(:each) do
218
323
  @request = OpenStruct.new(:ip => '81.19.48.130')
@@ -242,6 +347,7 @@ describe Split::Helper do
242
347
  (new_red_count + new_blue_count).should eql(previous_red_count + previous_blue_count)
243
348
  end
244
349
  end
350
+
245
351
  describe 'finished' do
246
352
  it "should not increment the completed count" do
247
353
  experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
@@ -263,7 +369,7 @@ describe Split::Helper do
263
369
  experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
264
370
  alternative_name = ab_test('link_color', 'blue', 'red')
265
371
  experiment.version.should eql(0)
266
- session[:split].should eql({'link_color' => alternative_name})
372
+ ab_user.should eql({'link_color' => alternative_name})
267
373
  end
268
374
 
269
375
  it "should save the version of the experiment to the session" do
@@ -271,7 +377,7 @@ describe Split::Helper do
271
377
  experiment.reset
272
378
  experiment.version.should eql(1)
273
379
  alternative_name = ab_test('link_color', 'blue', 'red')
274
- session[:split].should eql({'link_color:1' => alternative_name})
380
+ ab_user.should eql({'link_color:1' => alternative_name})
275
381
  end
276
382
 
277
383
  it "should load the experiment even if the version is not 0" do
@@ -279,7 +385,7 @@ describe Split::Helper do
279
385
  experiment.reset
280
386
  experiment.version.should eql(1)
281
387
  alternative_name = ab_test('link_color', 'blue', 'red')
282
- session[:split].should eql({'link_color:1' => alternative_name})
388
+ ab_user.should eql({'link_color:1' => alternative_name})
283
389
  return_alternative_name = ab_test('link_color', 'blue', 'red')
284
390
  return_alternative_name.should eql(alternative_name)
285
391
  end
@@ -287,7 +393,7 @@ describe Split::Helper do
287
393
  it "should reset the session of a user on an older version of the experiment" do
288
394
  experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
289
395
  alternative_name = ab_test('link_color', 'blue', 'red')
290
- session[:split].should eql({'link_color' => alternative_name})
396
+ ab_user.should eql({'link_color' => alternative_name})
291
397
  alternative = Split::Alternative.new(alternative_name, 'link_color')
292
398
  alternative.participant_count.should eql(1)
293
399
 
@@ -297,7 +403,7 @@ describe Split::Helper do
297
403
  alternative.participant_count.should eql(0)
298
404
 
299
405
  new_alternative_name = ab_test('link_color', 'blue', 'red')
300
- session[:split]['link_color:1'].should eql(new_alternative_name)
406
+ ab_user['link_color:1'].should eql(new_alternative_name)
301
407
  new_alternative = Split::Alternative.new(new_alternative_name, 'link_color')
302
408
  new_alternative.participant_count.should eql(1)
303
409
  end
@@ -305,7 +411,7 @@ describe Split::Helper do
305
411
  it "should cleanup old versions of experiments from the session" do
306
412
  experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
307
413
  alternative_name = ab_test('link_color', 'blue', 'red')
308
- session[:split].should eql({'link_color' => alternative_name})
414
+ ab_user.should eql({'link_color' => alternative_name})
309
415
  alternative = Split::Alternative.new(alternative_name, 'link_color')
310
416
  alternative.participant_count.should eql(1)
311
417
 
@@ -315,13 +421,13 @@ describe Split::Helper do
315
421
  alternative.participant_count.should eql(0)
316
422
 
317
423
  new_alternative_name = ab_test('link_color', 'blue', 'red')
318
- session[:split].should eql({'link_color:1' => new_alternative_name})
424
+ ab_user.should eql({'link_color:1' => new_alternative_name})
319
425
  end
320
426
 
321
427
  it "should only count completion of users on the current version" do
322
428
  experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
323
429
  alternative_name = ab_test('link_color', 'blue', 'red')
324
- session[:split].should eql({'link_color' => alternative_name})
430
+ ab_user.should eql({'link_color' => alternative_name})
325
431
  alternative = Split::Alternative.new(alternative_name, 'link_color')
326
432
 
327
433
  experiment.reset
@@ -466,10 +572,109 @@ describe Split::Helper do
466
572
  finished('link_color')
467
573
  end
468
574
  end
469
-
470
-
471
575
  end
576
+ end
472
577
 
578
+ context "with preloaded config" do
579
+ before { Split.configuration.experiments = {}}
580
+
581
+ it "pulls options from config file" do
582
+ Split.configuration.experiments[:my_experiment] = {
583
+ :alternatives => [ "control_opt", "other_opt" ],
584
+ }
585
+ ab_test :my_experiment
586
+ Split::Experiment.find(:my_experiment).alternative_names.should == [ "control_opt", "other_opt" ]
587
+ end
588
+
589
+ it "can be called multiple times" do
590
+ Split.configuration.experiments[:my_experiment] = {
591
+ :alternatives => [ "control_opt", "other_opt" ],
592
+ }
593
+ 5.times { ab_test :my_experiment }
594
+ experiment = Split::Experiment.find(:my_experiment)
595
+ experiment.alternative_names.should == [ "control_opt", "other_opt" ]
596
+ experiment.participant_count.should == 1
597
+ end
598
+
599
+ it "accepts multiple alternatives" do
600
+ Split.configuration.experiments[:my_experiment] = {
601
+ :alternatives => [ "control_opt", "second_opt", "third_opt" ],
602
+ }
603
+ ab_test :my_experiment
604
+ experiment = Split::Experiment.find(:my_experiment)
605
+ experiment.alternative_names.should == [ "control_opt", "second_opt", "third_opt" ]
606
+ end
607
+
608
+ it "accepts probability on alternatives" do
609
+ Split.configuration.experiments[:my_experiment] = {
610
+ :alternatives => [
611
+ { :name => "control_opt", :percent => 67 },
612
+ { :name => "second_opt", :percent => 10 },
613
+ { :name => "third_opt", :percent => 23 },
614
+ ],
615
+ }
616
+ ab_test :my_experiment
617
+ experiment = Split::Experiment.find(:my_experiment)
618
+ experiment.alternatives.collect{|a| [a.name, a.weight]}.should == [['control_opt', 0.67], ['second_opt', 0.1], ['third_opt', 0.23]]
619
+
620
+ end
621
+
622
+ it "accepts probability on some alternatives" do
623
+ Split.configuration.experiments[:my_experiment] = {
624
+ :alternatives => [
625
+ { :name => "control_opt", :percent => 34 },
626
+ "second_opt",
627
+ { :name => "third_opt", :percent => 23 },
628
+ "fourth_opt",
629
+ ],
630
+ }
631
+ ab_test :my_experiment
632
+ experiment = Split::Experiment.find(:my_experiment)
633
+ names_and_weights = experiment.alternatives.collect{|a| [a.name, a.weight]}
634
+ names_and_weights.should == [['control_opt', 0.34], ['second_opt', 0.215], ['third_opt', 0.23], ['fourth_opt', 0.215]]
635
+ names_and_weights.inject(0){|sum, nw| sum + nw[1]}.should == 1.0
636
+ end
637
+
638
+ it "allows name param without probability" do
639
+ Split.configuration.experiments[:my_experiment] = {
640
+ :alternatives => [
641
+ { :name => "control_opt" },
642
+ "second_opt",
643
+ { :name => "third_opt", :percent => 64 },
644
+ ],
645
+ }
646
+ ab_test :my_experiment
647
+ experiment = Split::Experiment.find(:my_experiment)
648
+ names_and_weights = experiment.alternatives.collect{|a| [a.name, a.weight]}
649
+ names_and_weights.should == [['control_opt', 0.18], ['second_opt', 0.18], ['third_opt', 0.64]]
650
+ names_and_weights.inject(0){|sum, nw| sum + nw[1]}.should == 1.0
651
+ end
652
+
653
+ it "fails gracefully if config is missing experiment" do
654
+ Split.configuration.experiments = { :other_experiment => { :foo => "Bar" } }
655
+ lambda { ab_test :my_experiment }.should raise_error(/not found/i)
656
+ end
657
+
658
+ it "fails gracefully if config is missing" do
659
+ Split.configuration.experiments = nil
660
+ lambda { ab_test :my_experiment }.should raise_error(/not found/i)
661
+ end
662
+
663
+ it "fails gracefully if config is missing alternatives" do
664
+ Split.configuration.experiments[:my_experiment] = { :foo => "Bar" }
665
+ lambda { ab_test :my_experiment }.should raise_error(/alternatives/i)
666
+ end
473
667
  end
474
668
 
669
+ it 'should handle multiple experiments correctly' do
670
+ experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
671
+ experiment2 = Split::Experiment.find_or_create('link_color2', 'blue', 'red')
672
+ alternative_name = ab_test('link_color', 'blue', 'red')
673
+ alternative_name2 = ab_test('link_color2', 'blue', 'red')
674
+ finished('link_color2')
675
+
676
+ experiment2.alternatives.each do |alt|
677
+ alt.unfinished_count.should eq(0)
678
+ end
679
+ end
475
680
  end
@@ -0,0 +1,30 @@
1
+ require 'spec_helper'
2
+ require 'split/metric'
3
+
4
+ describe Split::Metric do
5
+ describe 'possible experiments' do
6
+ it "should load the experiment if there is one, but no metric" do
7
+ experiment = Split::Experiment.find_or_create('color', 'red', 'blue')
8
+ Split::Metric.possible_experiments('color').should == [experiment]
9
+ end
10
+
11
+ it "should load the experiments in a metric" do
12
+ experiment1 = Split::Experiment.find_or_create('color', 'red', 'blue')
13
+ experiment2 = Split::Experiment.find_or_create('size', 'big', 'small')
14
+
15
+ metric = Split::Metric.new(:name => 'purchase', :experiments => [experiment1, experiment2])
16
+ metric.save
17
+ Split::Metric.possible_experiments('purchase').should include(experiment1, experiment2)
18
+ end
19
+
20
+ it "should load both the metric experiments and an experiment with the same name" do
21
+ experiment1 = Split::Experiment.find_or_create('purchase', 'red', 'blue')
22
+ experiment2 = Split::Experiment.find_or_create('size', 'big', 'small')
23
+
24
+ metric = Split::Metric.new(:name => 'purchase', :experiments => [experiment2])
25
+ metric.save
26
+ Split::Metric.possible_experiments('purchase').should include(experiment1, experiment2)
27
+ end
28
+ end
29
+
30
+ end
@@ -0,0 +1,31 @@
1
+ require "spec_helper"
2
+
3
+ describe Split::Persistence::CookieAdapter do
4
+
5
+ let(:context) { mock(:cookies => CookiesMock.new) }
6
+ subject { Split::Persistence::CookieAdapter.new(context) }
7
+
8
+ describe "#[] and #[]=" do
9
+ it "should set and return the value for given key" do
10
+ subject["my_key"] = "my_value"
11
+ subject["my_key"].should eq("my_value")
12
+ end
13
+ end
14
+
15
+ describe "#delete" do
16
+ it "should delete the given key" do
17
+ subject["my_key"] = "my_value"
18
+ subject.delete("my_key")
19
+ subject["my_key"].should be_nil
20
+ end
21
+ end
22
+
23
+ describe "#keys" do
24
+ it "should return an array of the session's stored keys" do
25
+ subject["my_key"] = "my_value"
26
+ subject["my_second_key"] = "my_second_value"
27
+ subject.keys.should =~ ["my_key", "my_second_key"]
28
+ end
29
+ end
30
+
31
+ end