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

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.
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
+