the-official-groupme-ab-testing-solution 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source :rubygems
2
+
3
+ gem "redis"
4
+
5
+ group :test do
6
+ gem "rspec"
7
+ end
@@ -0,0 +1,20 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ diff-lcs (1.1.2)
5
+ redis (2.2.0)
6
+ rspec (2.5.0)
7
+ rspec-core (~> 2.5.0)
8
+ rspec-expectations (~> 2.5.0)
9
+ rspec-mocks (~> 2.5.0)
10
+ rspec-core (2.5.1)
11
+ rspec-expectations (2.5.0)
12
+ diff-lcs (~> 1.1.2)
13
+ rspec-mocks (2.5.0)
14
+
15
+ PLATFORMS
16
+ ruby
17
+
18
+ DEPENDENCIES
19
+ redis
20
+ rspec
@@ -0,0 +1,77 @@
1
+ # The Official GroupMe AB Testing Solution
2
+
3
+ Simple AB testing with Redis.
4
+
5
+ ## USAGE
6
+
7
+ Define an AB test:
8
+
9
+ AB.define :new_twitter, "Show new Twitter or old Twitter to logged in users" do
10
+ alternative(:new) { "new-twitter.erb" }
11
+ alternative(:old) { "old-twitter.erb" }
12
+ end
13
+
14
+ Currently, identity of experiment participants is determined by an integer you
15
+ pass when the participant views the experiment. Use `AB.test` to show always show
16
+ a participant a consistent experience.
17
+
18
+ class TwitterController
19
+ def show
20
+ render :template => AB.test(:new_twitter, current_user.id)
21
+ end
22
+ end
23
+
24
+ If you don't have a current user, you can set a cookie or something:
25
+
26
+ class HomepageController
27
+ def index
28
+ identifier = session[:ab_identifier] ||= rand(100)
29
+ render :template => AB.test(:home_page, identifier)
30
+ end
31
+ end
32
+
33
+ To track conversions, use `AB.track`, passing the participant's identifier:
34
+
35
+ class TwitterController
36
+ def spend_dollahs
37
+ AB.track(:new_twitter, current_user.id)
38
+ end
39
+ end
40
+
41
+ or with a cookie:
42
+
43
+ class HomepageController
44
+ def signup
45
+ AB.test(:home_page, session[:ab_identifier])
46
+ end
47
+ end
48
+
49
+ To get at your results, you can get a test, and call `results`.
50
+
51
+ AB.get(:home_page).results
52
+
53
+ ## Testing
54
+
55
+ In tests, if you want to guarantee a certain alternative will be shown,
56
+ you can do so by specifying it with `use`
57
+
58
+ it "should say 'NEW TWITTER' showing new Twitter" do
59
+ AB.test(:new_twitter).use(:new) do
60
+ get "/twitter/show"
61
+ page.should have_content("NEW TWITTER")
62
+ end
63
+ end
64
+
65
+ ## Redis
66
+
67
+ This gem relies on the redis gem, it looks for a connection by just
68
+ booting up a Redis object with `Redis.new`. If you want to connect to
69
+ an alternative redis, you can assign it to `AB.redis`.
70
+
71
+ AB.redis = Redis.new('redis://something-weird')
72
+
73
+ ### TODO
74
+
75
+ * More interesting statistics, including relevance information
76
+ * Pretty printing of stats to the command line
77
+ * Pretty web interface
@@ -0,0 +1,8 @@
1
+ require "rspec/core/rake_task"
2
+
3
+ RSpec::Core::RakeTask.new(:spec) do |t|
4
+ t.rspec_opts = "--color"
5
+ t.pattern = FileList['spec/**/*_spec.rb']
6
+ end
7
+
8
+ task :default => :spec
@@ -0,0 +1,17 @@
1
+ $LOAD_PATH << File.dirname(__FILE__)
2
+
3
+ require "ab/test"
4
+ require "ab/interface"
5
+ require "ab/alternative"
6
+
7
+ module AB
8
+ VERSION = "0.0.1"
9
+
10
+ def self.redis=(redis)
11
+ @redis = redis
12
+ end
13
+
14
+ def self.redis
15
+ @redis ||= Redis.new
16
+ end
17
+ end
@@ -0,0 +1,66 @@
1
+ module AB
2
+ class Alternative
3
+ attr_reader :name
4
+ attr_accessor :range
5
+
6
+ def initialize(test, name, options = {}, &value_proc)
7
+ @test, @name, @options, @value_proc = test, name, options, value_proc
8
+ @test.update_percentage_range(self) if percent
9
+ end
10
+
11
+ def percent
12
+ @options[:percent]
13
+ end
14
+
15
+ def value
16
+ @value ||= @value_proc.call
17
+ end
18
+
19
+ def views_key
20
+ @views_key ||= "ab:#{@test.name}:#{@name}"
21
+ end
22
+
23
+ def view!(identity)
24
+ return if identity.nil?
25
+ return if redis.zscore(views_key, identity)
26
+ score = Time.now.to_i
27
+ redis.zadd(views_key, score, identity)
28
+ end
29
+
30
+ def views
31
+ redis.zcard(views_key)
32
+ end
33
+
34
+ def conversions_key
35
+ @conversions_key ||= views_key + ":conversions"
36
+ end
37
+
38
+ def track_conversion!(identity)
39
+ return if identity.nil?
40
+ return if redis.zscore(conversions_key, identity)
41
+ return unless redis.zscore(views_key, identity)
42
+ score = Time.now.to_i
43
+ redis.zadd(conversions_key, score, identity)
44
+ end
45
+
46
+ def conversions
47
+ redis.zcard(conversions_key)
48
+ end
49
+
50
+ def results
51
+ {
52
+ :name => name,
53
+ :value => value,
54
+ :views => views,
55
+ :conversions => conversions,
56
+ :percent => percent
57
+ }
58
+ end
59
+
60
+ private
61
+
62
+ def redis
63
+ AB.redis
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,42 @@
1
+ module AB
2
+ class << self
3
+ def define(name, description, &block)
4
+ raise ArgumentError, "#{name.inspect} is already an AB test" if tests.has_key?(name)
5
+ t = AB::Test.new(name, description)
6
+ t.instance_eval(&block) if block_given?
7
+ t.verify_percentages
8
+ tests[name] = t
9
+ end
10
+
11
+ def get(name)
12
+ tests[name]
13
+ end
14
+
15
+ def track(name, identity)
16
+ test = get(name)
17
+ alternative = test.get_alternative(identity)
18
+ begin
19
+ alternative.track_conversion!(identity)
20
+ rescue Timeout::Error
21
+ # Don't track data if redis is down
22
+ end
23
+ end
24
+
25
+ def test(name, identity)
26
+ test = get(name)
27
+ alternative = test.get_alternative(identity)
28
+ begin
29
+ alternative.view!(identity)
30
+ rescue Timeout::Error
31
+ # Don't track data if redis is down
32
+ end
33
+ alternative.value
34
+ end
35
+
36
+ private
37
+
38
+ def tests
39
+ @tests ||= {}
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,54 @@
1
+ module AB
2
+ class Test
3
+ attr_reader :name, :description, :alternatives
4
+
5
+ def initialize(name, description)
6
+ @name, @description, @alternatives, @last_percent= name, description, [], 0
7
+ end
8
+
9
+ def alternative(name, options={}, &value_proc)
10
+ alternative = AB::Alternative.new(self, name, options, &value_proc)
11
+ alternatives << alternative
12
+ alternative
13
+ end
14
+
15
+ def use(name)
16
+ begin
17
+ @using = alternatives.detect { |alternative| alternative.name == name }
18
+ yield
19
+ ensure
20
+ @using = nil
21
+ end
22
+ end
23
+
24
+ def get_alternative(identity)
25
+ return @using if @using
26
+
27
+ if identity.nil?
28
+ return alternatives.first
29
+ end
30
+
31
+ if alternatives.any?(&:percent)
32
+ alternatives.detect { |alternative| alternative.range.include?(identity % 100) }
33
+ else
34
+ alternatives[identity % alternatives.size]
35
+ end
36
+ end
37
+
38
+ def update_percentage_range(alternative)
39
+ percent = alternative.percent - 1
40
+ alternative.range = @last_percent..(@last_percent + percent)
41
+ @last_percent = alternative.range.last + 1
42
+ end
43
+
44
+ def verify_percentages
45
+ return unless alternatives.any?(&:percent)
46
+ raise ArgumentError.new("Some alternatives are missing percentages") unless alternatives.all?(&:percent)
47
+ raise ArgumentError.new("Percentages must add up to 100") unless @last_percent == 100
48
+ end
49
+
50
+ def results
51
+ alternatives.map(&:results)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,100 @@
1
+ require 'spec_helper'
2
+
3
+ describe AB::Alternative do
4
+ before do
5
+ @test = AB::Test.new(:home_page, "Red or blue home page")
6
+ AB.redis.flushall
7
+ end
8
+
9
+ describe "#value" do
10
+ it "returns the result of the value_proc" do
11
+ alternative = AB::Alternative.new(@test, :red) { "red.erb" }
12
+ alternative.value.should == "red.erb"
13
+ end
14
+ end
15
+
16
+ describe "views_key" do
17
+ it "returns the experiments namespace with the test name and alternative name" do
18
+ alternative = AB::Alternative.new(@test, :red) { "red.erb" }
19
+ alternative.views_key.should == "ab:home_page:red"
20
+ end
21
+ end
22
+
23
+ describe "view!" do
24
+ it "tracks the view (once per identity)" do
25
+ alternative = AB::Alternative.new(@test, :red) { "red.erb" }
26
+
27
+ running {
28
+ alternative.view! 123
29
+ }.should change { alternative.views }
30
+
31
+ running {
32
+ alternative.view! 123
33
+ }.should_not change { alternative.views }
34
+ end
35
+
36
+ it "does not update timestamp" do
37
+ alternative = AB::Alternative.new(@test, :red) { "red.erb" }
38
+ AB.redis.zadd(alternative.views_key, 1000, 123)
39
+ alternative.view! 123
40
+ AB.redis.zscore(alternative.views_key, 123).should == "1000"
41
+ end
42
+
43
+ it "does not track the view if the identity is nil" do
44
+ alternative = AB::Alternative.new(@test, :red) { "red.erb" }
45
+ running {
46
+ alternative.view! nil
47
+ }.should_not change { AB.redis.dbsize }
48
+ end
49
+ end
50
+
51
+ describe "views" do
52
+ it "returns the number of views tracked" do
53
+ alternative = AB::Alternative.new(@test, :red) { "red.erb" }
54
+ alternative.views.should == 0
55
+ alternative.view! 123
56
+ alternative.views.should == 1
57
+ alternative.view! 789
58
+ alternative.views.should == 2
59
+ alternative.view! 123
60
+ alternative.views.should == 2
61
+ end
62
+ end
63
+
64
+ describe "conversions_key" do
65
+ it "returns views_key with :conversions suffix" do
66
+ alternative = AB::Alternative.new(@test, :red) { "red.erb" }
67
+ alternative.conversions_key.should == "ab:home_page:red:conversions"
68
+ end
69
+ end
70
+
71
+ describe "track_conversion!" do
72
+ before do
73
+ @alternative = AB::Alternative.new(@test, :red) { "red.erb" }
74
+ end
75
+
76
+ it "tracks the conversion (once per identity)" do
77
+ @alternative.view! 123
78
+
79
+ running {
80
+ @alternative.track_conversion! 123
81
+ }.should change { @alternative.conversions }.by(1)
82
+
83
+ running {
84
+ @alternative.track_conversion! 123
85
+ }.should_not change { @alternative.conversions }
86
+ end
87
+
88
+ it "does not track conversion if identity did not view alternative" do
89
+ running {
90
+ @alternative.track_conversion! 123
91
+ }.should_not change { @alternative.conversions }
92
+ end
93
+
94
+ it "does not track the conversion if the identity is nil" do
95
+ running {
96
+ @alternative.track_conversion! nil
97
+ }.should_not change { AB.redis.dbsize }
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,114 @@
1
+ require "spec/spec_helper"
2
+
3
+ describe AB do
4
+ after do
5
+ AB.send(:tests).delete(:home_page)
6
+ AB.redis.flushall
7
+ end
8
+
9
+ describe ".define" do
10
+ it "defines an AB test" do
11
+ AB.define :home_page, "Big red button or big blue button versions" do
12
+ alternative(:red) { "red.erb" }
13
+ alternative(:blue) { "blue.erb" }
14
+ end
15
+
16
+ test = AB.get(:home_page)
17
+ test.name.should == :home_page
18
+ test.description.should == "Big red button or big blue button versions"
19
+ test.should have(2).alternatives
20
+ red_alternative = test.alternatives.first
21
+ red_alternative.name.should == :red
22
+ red_alternative.value.should == "red.erb"
23
+ end
24
+
25
+ it "raises when you try to define the same test twice" do
26
+ AB.define(:home_page, "Big red button")
27
+ running {
28
+ AB.define(:home_page, "Other big red button")
29
+ }.should raise_error(ArgumentError)
30
+ end
31
+
32
+ it "can set a percentage for alternatives" do
33
+ AB.define :home_page, "Red button or blue button" do
34
+ alternative(:red, :percent => 10) { "red.erb" }
35
+ alternative(:red, :percent => 90) { "blue.erb" }
36
+ end
37
+
38
+ 0.upto(9) { |i| AB.test(:home_page, i).should == "red.erb" }
39
+ 10.upto(99) { |i| AB.test(:home_page, i).should == "blue.erb" }
40
+ end
41
+
42
+ it "explodes if percentage sum does not equal 100" do
43
+ running {
44
+ AB.define :home_page, "Red button or blue button" do
45
+ alternative(:red, :percent => 10) { "red.erb" }
46
+ alternative(:red, :percent => 80) { "blue.erb" }
47
+ end
48
+ }.should raise_error(ArgumentError)
49
+ end
50
+
51
+ it "explodes if percentages are only set on some of the alternatives" do
52
+ running {
53
+ AB.define :home_page, "Red button or blue button" do
54
+ alternative(:red, :percent => 10) { "red.erb" }
55
+ alternative(:red) { "blue.erb" }
56
+ end
57
+ }.should raise_error(ArgumentError)
58
+ end
59
+ end
60
+
61
+ describe ".test" do
62
+ before do
63
+ @test = AB.define :home_page, "Red or Blue home page" do
64
+ alternative(:red) { "red.erb" }
65
+ alternative(:blue) { "blue.erb" }
66
+ end
67
+ end
68
+
69
+ it "returns an alternative value" do
70
+ value = AB.test(:home_page, 123)
71
+ ["red.erb", "blue.erb"].should include(value)
72
+ end
73
+
74
+ it "increases the views for the alternative" do
75
+ alternative = @test.get_alternative(123)
76
+ running {
77
+ AB.test(:home_page, 123)
78
+ }.should change { alternative.views }.by(1)
79
+ end
80
+
81
+ it "rescues Redis failures and still returns a value" do
82
+ AB.redis.should_receive(:zadd).and_raise(Timeout::Error.new("time's up!"))
83
+ running {
84
+ value = AB.test(:home_page, 123)
85
+ ["red.erb", "blue.erb"].should include(value)
86
+ }.should_not raise_error
87
+ end
88
+ end
89
+
90
+ describe ".track" do
91
+ before do
92
+ @test = AB.define :home_page, "Red or blue home page" do
93
+ alternative(:red) { "red.erb" }
94
+ alternative(:blue) { "blue.erb" }
95
+ end
96
+
97
+ @alternative = @test.get_alternative(123)
98
+ @alternative.view! 123
99
+ end
100
+
101
+ it "increases the conversions for the alternative" do
102
+ running {
103
+ AB.track :home_page, 123
104
+ }.should change { @alternative.conversions }.by(1)
105
+ end
106
+
107
+ it "rescues Redis timeout failures" do
108
+ AB.redis.should_receive(:zscore).and_raise(Timeout::Error.new("time's up!"))
109
+ running {
110
+ AB.track :home_page, 123
111
+ }.should_not raise_error
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,82 @@
1
+ require 'spec_helper'
2
+
3
+ describe AB::Test do
4
+ describe "#get_alternative" do
5
+ before do
6
+ @test = AB::Test.new(:home_page, "Red or blue home page")
7
+ @red = @test.alternative(:red) { "red.erb" }
8
+ @blue = @test.alternative(:blue) { "blue.erb" }
9
+ end
10
+
11
+ it "returns the value of an alternative based on the identity (50/50)" do
12
+ @test.get_alternative(0).should == @red
13
+ @test.get_alternative(1).should == @blue
14
+ end
15
+
16
+ it "returns the value of an alternative based on the identity (33/33/33)" do
17
+ @green = @test.alternative(:green) { "green.erb" } # add an additional alternative
18
+ @test.get_alternative(0).should == @red
19
+ @test.get_alternative(1).should == @blue
20
+ @test.get_alternative(2).should == @green
21
+ end
22
+
23
+ it "returns the first alternative when identity is nil" do
24
+ @test.get_alternative(nil).should == @red
25
+ end
26
+ end
27
+
28
+ describe "#verify_percentages" do
29
+ before do
30
+ @test = AB::Test.new(:home_page, "Red or blue home page")
31
+ end
32
+
33
+ it "raises ArgumentError when some alternatives do not have percentages and some do" do
34
+ @test.alternative(:red, :percent => 10) { "red.erb" }
35
+ @test.alternative(:blue) { "blue.erb" }
36
+ running {
37
+ @test.verify_percentages
38
+ }.should raise_error(ArgumentError)
39
+ end
40
+
41
+ it "raises ArgumentError when percentages do not add up to 100" do
42
+ @test.alternative(:red, :percent => 10) { "red.erb" }
43
+ @test.alternative(:blue, :percent => 10) { "blue.erb" }
44
+ running {
45
+ @test.verify_percentages
46
+ }.should raise_error(ArgumentError)
47
+ end
48
+ end
49
+
50
+ describe "#results" do
51
+ before do
52
+ @test = AB::Test.new(:home_page, "Red or blue home page")
53
+ @red = @test.alternative(:red) { "red.erb" }
54
+ @blue = @test.alternative(:blue) { "blue.erb" }
55
+ end
56
+
57
+ it "returns list of alternatives with stats" do
58
+ @red.view! 123
59
+ @red.track_conversion! 123
60
+ @test.results.should == [
61
+ { :name => :red, :value => "red.erb", :views => 1, :conversions => 1, :percent => nil },
62
+ { :name => :blue, :value => "blue.erb", :views => 0, :conversions => 0, :percent => nil }
63
+ ]
64
+ end
65
+ end
66
+
67
+ describe "#use" do
68
+ it "always returns the alternative passed in the block" do
69
+ test = AB::Test.new(:home_page, "Red or blue home page")
70
+ red = test.alternative(:red) { "red.erb" }
71
+ blue = test.alternative(:blue) { "blue.erb" }
72
+
73
+ test.use :red do
74
+ test.get_alternative(0).should == red
75
+ test.get_alternative(1).should == red
76
+ end
77
+
78
+ test.get_alternative(0).should == red
79
+ test.get_alternative(1).should == blue
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,6 @@
1
+ require "redis"
2
+ require 'yaml'
3
+
4
+ require File.join(File.dirname(__FILE__), *%w[.. lib ab])
5
+
6
+ alias running proc
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: the-official-groupme-ab-testing-solution
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Dave Yeu and Pat Nakajima
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-04-28 00:00:00 -04:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: redis
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 3
30
+ segments:
31
+ - 0
32
+ version: "0"
33
+ type: :runtime
34
+ version_requirements: *id001
35
+ description:
36
+ email: pat@groupme.com
37
+ executables: []
38
+
39
+ extensions: []
40
+
41
+ extra_rdoc_files: []
42
+
43
+ files:
44
+ - Gemfile
45
+ - Gemfile.lock
46
+ - README.md
47
+ - Rakefile
48
+ - lib/ab.rb
49
+ - lib/ab/alternative.rb
50
+ - lib/ab/interface.rb
51
+ - lib/ab/test.rb
52
+ - spec/ab/alternative_spec.rb
53
+ - spec/ab/interface_spec.rb
54
+ - spec/ab/test_spec.rb
55
+ - spec/spec_helper.rb
56
+ has_rdoc: true
57
+ homepage: http://github.com/groupme/the-official-groupme-ab-testing-solution
58
+ licenses: []
59
+
60
+ post_install_message:
61
+ rdoc_options: []
62
+
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ none: false
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ hash: 3
71
+ segments:
72
+ - 0
73
+ version: "0"
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ none: false
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ hash: 3
80
+ segments:
81
+ - 0
82
+ version: "0"
83
+ requirements: []
84
+
85
+ rubyforge_project:
86
+ rubygems_version: 1.6.2
87
+ signing_key:
88
+ specification_version: 3
89
+ summary: The Official GroupMe AB Testing Solution
90
+ test_files: []
91
+