split 0.4.6 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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