vanity 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG ADDED
@@ -0,0 +1,8 @@
1
+ 0.1.1
2
+ * Added: experiment method on object, used to define and access experiments.
3
+ * Added: playground configuration (Vanity.playground.namespace = , etc).
4
+ * Added: use_vanity now accepts block instead of symbol.
5
+ * Changed: Vanity::Helpers becomes Vanity::Rails.
6
+ * Changed: A/B test experiments alternatives now handled using Alternatives
7
+ object.
8
+ * Removed: A/B test measure method no longer in use.
data/README.rdoc ADDED
@@ -0,0 +1,153 @@
1
+ Vanity is an Experience Driven-Development framework for Rails.
2
+
3
+ Requires Ruby 1.9 and Redis 1.0 or later.
4
+
5
+
6
+ == A/B Testing with Rails (in 5 easy steps)
7
+
8
+ Add Vanity to your Rails app:
9
+
10
+ class ApplicationController < ActionController::Base
11
+ use_vanity :current_user
12
+ end
13
+
14
+ Define an A/B test. This test compares three pricing options:
15
+
16
+ experiment "Price options" do
17
+ description "Mirror, mirror on the wall, who's the better price of them all?"
18
+ alternatives 19, 25, 29
19
+ end
20
+
21
+ Present different options to the user:
22
+
23
+ <h2>Get started for only $<%= ab_test :pricing_options %> a month!</h2>
24
+
25
+ Measure conversion:
26
+
27
+ class SignupController < ApplicationController
28
+ def signup
29
+ @account = Account.new(params[:account])
30
+ if @account.save
31
+ ab_goal! :pricing_options # <- conversion
32
+ redirect_to @acccount
33
+ else
34
+ render action: :offer
35
+ end
36
+ end
37
+ end
38
+
39
+ Check the report:
40
+
41
+ vanity
42
+
43
+
44
+ == A/B Tests
45
+
46
+ Each A/B experiment represents several (two or more) alternatives. Use the
47
+ ab_test method to choose an alternative. Call ab_test without a block to return
48
+ the value of the chosen alternative. Call ab_test with a block to yield with
49
+ the value.
50
+
51
+ Here are some examples:
52
+
53
+ def index
54
+ if ab_test(:new_page) # classic true/false test
55
+ render action: "new_page"
56
+ else
57
+ render action: "index"
58
+ end
59
+ end
60
+
61
+ def index
62
+ # alternatives are names of templates
63
+ render template: ab_test(:new_page)
64
+ end
65
+
66
+ <%= if ab_test(:banner) %>100% less complexity!<% end %>
67
+
68
+ <%= ab_test(:greeting) %> <%= current_user.name %>
69
+
70
+ <% ab_test :features do |count| %>
71
+ <%= count %> features to choose from!
72
+ <% end %>
73
+
74
+ To measure conversion, call ab_goal! with the experiment's name. Typically,
75
+ you would do that from a controller action, for example:
76
+
77
+ def create
78
+ ab_goal! :new_page
79
+ ...
80
+ end
81
+
82
+ To measure conversion, simply call ab_goal! with the experiment name. From the
83
+ Vanity identity set by the filter we know which alternative was presented by
84
+ ab_test, and can correlate conversions to alternative. It's that simple!
85
+
86
+
87
+ == Managing Identity
88
+
89
+ For effective A/B tests, you want to:
90
+ - Randomly show different alternatives to different people
91
+ - Consistently show the same alternatives to the same person
92
+ - Know which alternative caused a conversion
93
+ - When running multiple tests at once, keep them independent
94
+
95
+ If you don't use any other mechanism, Vanity will assign a random value to a
96
+ persistent cookie and use it to track the same visitor on subsequent visits.
97
+ Cookie tracking is enabled by use_vanity.
98
+
99
+ If you keep track of users, you would want to use the user's identity instead.
100
+ Using user identity is more reliable than a cookie tied to a single Web
101
+ browser.
102
+
103
+ To do that, call use_vanity with the name of a method which returns an object
104
+ with the desired id attribute. Alternatively, you can use a proc. These two
105
+ examples are equivalent:
106
+
107
+ use_vanity :current_user
108
+ use_vanity { |controller| controller.current_user.id }
109
+
110
+ There are times when you would want to use a different identity to distinguish
111
+ test alternatives. For example, your application may have groups and you may
112
+ want to A/B test an option that will be available (or not) to all people in the
113
+ same group.
114
+
115
+ You can tell Vanity to use a different identity on a particular controller
116
+ using use_vanity. Alternatively, you can configure the experiment to extract
117
+ the identity. The following example will apply to all controllers that have a
118
+ project attribute (without affecting other experiments):
119
+
120
+ example "New feature" do
121
+ description "New feature only available to some groups"
122
+ identify { |controller| controller.project.id }
123
+ end
124
+
125
+
126
+ == Configuring Vanity
127
+
128
+ Vanity will work out of the box on a default configuration. Assuming you're
129
+ using Redis on localhost, post 6379, there's nothing special to do.
130
+
131
+ If you run a different setup, use the playground object to configure Vanity.
132
+ For example:
133
+
134
+ Vanity.playground.host = "redis.local"
135
+ Vanity.playground.password = "supersecret"
136
+
137
+
138
+ == Credits
139
+
140
+ EDD was all Nathaniel Talbott's idea, I had experience tests to finish for
141
+ Apartly, there was coffee involved and out came the idea for Vanity.
142
+
143
+ First experiment, A/B tests, heavily influenced by Patrick McKenzie's awesome
144
+ A/Bingo (http://www.bingocardcreator.com/abingo)
145
+
146
+ Pain points courtesy of Google Analytics's stylish graphs and too-many-clicks
147
+ goal tracking process.
148
+
149
+
150
+
151
+ == License
152
+
153
+ Vanity, copyright (C) 2009 Assaf Arkin, released under the "Use for good, not evil" license (www.json.org/license.html)
data/lib/vanity.rb ADDED
@@ -0,0 +1,23 @@
1
+ require "redis"
2
+ require "openssl"
3
+
4
+ # All the cool stuff happens in other places:
5
+ # - Vanity::Helpers
6
+ # - Vanity::Playground
7
+ # - Experiment::AbTest
8
+ module Vanity
9
+ # Version number.
10
+ module Version
11
+ version = Gem::Specification.load(File.expand_path("../vanity.gemspec", File.dirname(__FILE__))).version.to_s.split(".").map { |i| i.to_i }
12
+ MAJOR = version[0]
13
+ MINOR = version[1]
14
+ PATCH = version[2]
15
+ STRING = "#{MAJOR}.#{MINOR}.#{PATCH}"
16
+ end
17
+ end
18
+
19
+
20
+ require File.join(File.dirname(__FILE__), "vanity/playground")
21
+ require File.join(File.dirname(__FILE__), "vanity/experiment/base")
22
+ require File.join(File.dirname(__FILE__), "vanity/experiment/ab_test")
23
+ require File.join(File.dirname(__FILE__), "vanity/rails") if defined?(Rails)
@@ -0,0 +1,170 @@
1
+ module Vanity
2
+ module Experiment
3
+
4
+ # Experiment alternative. See AbTest#alternatives.
5
+ class Alternative
6
+
7
+ def initialize(experiment, id, value) #:nodoc:
8
+ @experiment = experiment
9
+ @id = id
10
+ @value = value
11
+ end
12
+
13
+ # Alternative id, only unique for this experiment.
14
+ attr_reader :id
15
+
16
+ # Alternative value.
17
+ attr_reader :value
18
+
19
+ # Number of participants who viewed this alternative.
20
+ def participants
21
+ redis.scard(key("participants")).to_i
22
+ end
23
+
24
+ # Number of participants who converted on this alternative.
25
+ def converted
26
+ redis.scard(key("converted")).to_i
27
+ end
28
+
29
+ # Number of conversions for this alternative (same participant may be counted more than once).
30
+ def conversions
31
+ redis.get(key("conversions")).to_i
32
+ end
33
+
34
+ def participating!(identity)
35
+ redis.sadd key("participants"), identity
36
+ end
37
+
38
+ def conversion!(identity)
39
+ if redis.sismember(key("participants"), identity)
40
+ redis.sadd key("converted"), identity
41
+ redis.incr key("conversions")
42
+ end
43
+ end
44
+
45
+ protected
46
+
47
+ def key(name)
48
+ @experiment.key("alts:#{id}:#{name}")
49
+ end
50
+
51
+ def redis
52
+ @experiment.redis
53
+ end
54
+
55
+ end
56
+
57
+ # The meat.
58
+ class AbTest < Base
59
+ def initialize(*args) #:nodoc:
60
+ super
61
+ end
62
+
63
+ # Chooses a value for this experiment.
64
+ #
65
+ # This method returns different values for different identity (see
66
+ # #identify), and consistenly the same value for the same
67
+ # expriment/identity pair.
68
+ #
69
+ # For example:
70
+ # color = experiment(:which_blue).choose
71
+ def choose
72
+ identity = identify
73
+ alt = alternative_for(identity)
74
+ alt.participating! identity
75
+ alt.value
76
+ end
77
+
78
+ # Records a conversion.
79
+ #
80
+ # For example:
81
+ # experiment(:which_blue).conversion!
82
+ def conversion!
83
+ identity = identify
84
+ alt = alternative_for(identity)
85
+ alt.conversion! identity
86
+ alt.id
87
+ end
88
+
89
+ # Call this method once to specify values for the A/B test. At least two
90
+ # values are required.
91
+ #
92
+ # Call without argument to previously defined alternatives (see Alternative).
93
+ #
94
+ # For example:
95
+ # experiment "Background color" do
96
+ # alternatives "red", "blue", "orange"
97
+ # end
98
+ #
99
+ # alts = experiment(:background_color).alternatives
100
+ # puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
101
+ def alternatives(*args)
102
+ args = [true, false] if args.empty?
103
+ @alternatives = []
104
+ args.each_with_index do |arg, i|
105
+ @alternatives << Alternative.new(self, i, arg)
106
+ end
107
+ class << self ; self ; end.send(:define_method, :alternatives) { @alternatives }
108
+ alternatives
109
+ end
110
+
111
+ # Sets this test to two alternatives: true and false.
112
+ def true_false
113
+ alternatives true, false
114
+ end
115
+
116
+ def report
117
+ alts = alternatives.map { |alt|
118
+ "<dt>Option #{(65 + alt.id).chr}</dt><dd><code>#{CGI.escape_html alt.value.inspect}</code> viewed #{alt.participants} times, converted #{alt.conversions}<dd>"
119
+ }
120
+ %{<dl class="data">#{alts.join}</dl>}
121
+ end
122
+
123
+ # Forces this experiment to use a particular alternative. Useful for
124
+ # tests, e.g.
125
+ #
126
+ # setup do
127
+ # experiment(:green_button).select(true)
128
+ # end
129
+ #
130
+ # def test_shows_green_button
131
+ # . . .
132
+ # end
133
+ #
134
+ # Use nil to clear out selection:
135
+ # teardown do
136
+ # experiment(:green_button).select(nil)
137
+ # end
138
+ def chooses(value)
139
+ alternative = alternatives.find { |alt| alt.value == value }
140
+ raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless alternative
141
+ Vanity.context.session[:vanity] ||= {}
142
+ Vanity.context.session[:vanity][id] = alternative.id
143
+ end
144
+
145
+ def humanize
146
+ "A/B Test"
147
+ end
148
+
149
+ def save #:nodoc:
150
+ fail "Experiment #{name} needs at least two alternatives" unless alternatives.count >= 2
151
+ super
152
+ end
153
+
154
+ private
155
+
156
+ # Chooses an alternative for the identity and returns its index. This
157
+ # method always returns the same alternative for a given experiment and
158
+ # identity, and randomly distributed alternatives for each identity (in the
159
+ # same experiment).
160
+ def alternative_for(identity)
161
+ session = Vanity.context.session[:vanity]
162
+ index = session && session[id]
163
+ index ||= Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % alternatives.count
164
+ alternatives[index]
165
+ end
166
+
167
+ end
168
+ end
169
+ end
170
+
@@ -0,0 +1,98 @@
1
+ module Vanity
2
+ module Experiment
3
+
4
+ # Base class that all experiment types are derived from.
5
+ class Base
6
+
7
+ class << self
8
+
9
+ # Returns the type of this class as a symbol (e.g. AbTest becomes
10
+ # ab_test).
11
+ def type
12
+ name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{$1}_#{$2}" }.gsub(/([A-Z])([A-Z][a-z])/) { "#{$1}_#{$2}" }.downcase
13
+ end
14
+
15
+ end
16
+
17
+ def initialize(playground, id, name, &block)
18
+ @playground = playground
19
+ @id, @name = id.to_sym, name
20
+ @namespace = "#{@playground.namespace}:#{@id}"
21
+ created = redis.get(key(:created_at)) || (redis.setnx(key(:created_at), Time.now.to_i) ; redis.get(key(:created_at)))
22
+ @created_at = Time.at(created.to_i)
23
+ @identify_block = ->(context){ context.vanity_identity }
24
+ end
25
+
26
+ # Human readable experiment name, supplied during creation.
27
+ attr_reader :name
28
+
29
+ # Unique identifier, derived from name, e.g. "Green Button" become :green_button.
30
+ attr_reader :id
31
+
32
+ # Experiment creation timestamp.
33
+ attr_reader :created_at
34
+
35
+ # Sets or returns description. For example
36
+ # experiment :simple do
37
+ # description "Simple experiment"
38
+ # end
39
+ #
40
+ # puts "Just defined: " + experiment(:simple).description
41
+ def description(text = nil)
42
+ @description = text if text
43
+ @description
44
+ end
45
+
46
+ def report
47
+ fail "Implement me"
48
+ end
49
+
50
+ # Called to save the experiment definition.
51
+ def save #:nodoc:
52
+ end
53
+
54
+ # Call this method with no argument or block to return an identity. Call
55
+ # this method with a block to define how to obtain an identity for the
56
+ # current experiment.
57
+ #
58
+ # For example, this experiment use the identity of the project associated
59
+ # with the controller:
60
+ #
61
+ # class ProjectController < ApplicationController
62
+ # before_filter :set_project
63
+ # attr_reader :project
64
+ #
65
+ # . . .
66
+ # end
67
+ #
68
+ # experiment "Project widget" do
69
+ # alternatives :small, :medium, :large
70
+ # identify do |controller|
71
+ # controller.project.id
72
+ # end
73
+ # end
74
+ def identify(&block)
75
+ if block_given?
76
+ @identify_block = block
77
+ self
78
+ else
79
+ @identify_block.call(Vanity.context) or fail "No identity found"
80
+ end
81
+ end
82
+
83
+ # Returns key for this experiment, or with an argument, return a key
84
+ # using the experiment as the namespace. Examples:
85
+ # key => "vanity:experiments:green_button"
86
+ # key("participants") => "vanity:experiments:green_button:participants"
87
+ def key(name = nil) #:nodoc:
88
+ name ? "#{@namespace}:#{name}" : @namespace
89
+ end
90
+
91
+ # Shortcut for Vanity.playground.redis
92
+ def redis #:nodoc:
93
+ @playground.redis
94
+ end
95
+ end
96
+ end
97
+ end
98
+
@@ -0,0 +1,127 @@
1
+ require "active_support"
2
+
3
+ module Vanity
4
+
5
+ # Vanity.playground.configuration
6
+ class Configuration
7
+ end
8
+
9
+ # Playground catalogs all your experiments, holds the Vanity configuration.
10
+ # For example:
11
+ # Vanity.playground.logger = my_logger
12
+ # puts Vanity.playground.map(&:name)
13
+ class Playground
14
+
15
+ # Created new Playground. Unless you need to, use the global Vanity.playground.
16
+ def initialize
17
+ @experiments = {}
18
+ @namespace = "vanity:#{Vanity::Version::MAJOR}"
19
+ @load_path = "experiments"
20
+ end
21
+
22
+ # Redis host name. Default is 127.0.0.1
23
+ attr_accessor :host
24
+
25
+ # Redis port number. Default is 6379.
26
+ attr_accessor :port
27
+
28
+ # Redis database number. Default is 0.
29
+ attr_accessor :db
30
+
31
+ # Redis database password.
32
+ attr_accessor :password
33
+
34
+ # Namespace for database keys. Default is vanity:n, where n is the major release number, e.g. vanity:1 for 1.0.3.
35
+ attr_accessor :namespace
36
+
37
+ # Path to load experiment files from.
38
+ attr_accessor :load_path
39
+
40
+ # Logger.
41
+ attr_accessor :logger
42
+
43
+ # Defines a new experiment. Generally, do not call this directly,
44
+ # use #experiment instead.
45
+ def define(name, options = nil, &block)
46
+ id = name.to_s.downcase.gsub(/\W/, "_")
47
+ raise "Experiment #{id} already defined once" if @experiments[id]
48
+ options ||= {}
49
+ type = options[:type] || :ab_test
50
+ klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
51
+ experiment = klass.new(self, id, name)
52
+ experiment.instance_eval &block
53
+ experiment.save
54
+ @experiments[id] = experiment
55
+ end
56
+
57
+ # Returns the named experiment. You may not have guessed, but this method
58
+ # raises an exception if it cannot load the experiment's definition.
59
+ #
60
+ # Experiment names are always mapped by downcasing them and replacing
61
+ # non-word characters with underscores, so "Green call to action" becomes
62
+ # "green_call_to_action". You can also use a symbol if you feel like it.
63
+ def experiment(name)
64
+ id = name.to_s.downcase.gsub(/\W/, "_")
65
+ unless @experiments.has_key?(id)
66
+ require File.join(load_path, id)
67
+ end
68
+ @experiments[id] or fail LoadError, "Expected experiments/#{id}.rb to define experiment #{name}"
69
+ end
70
+
71
+ # Returns list of all loaded experiments.
72
+ def experiments
73
+ Dir[File.join(load_path, "*.rb")].each do |file|
74
+ require file
75
+ end
76
+ @experiments.values
77
+ end
78
+
79
+ # Use this instance to access the Redis database.
80
+ def redis
81
+ redis = Redis.new(host: self.host, port: self.port, db: self.db,
82
+ password: self.password, logger: self.logger)
83
+ class << self ; self ; end.send(:define_method, :redis) { redis }
84
+ redis
85
+ end
86
+
87
+ end
88
+
89
+ @playground = Playground.new
90
+ # Returns the playground instance.
91
+ def self.playground
92
+ @playground
93
+ end
94
+
95
+ # Returns the Vanity context. For example, when using Rails this would be
96
+ # the current controller, which can be used to get/set the vanity identity.
97
+ def self.context
98
+ Thread.current[:vanity_context]
99
+ end
100
+
101
+ # Sets the Vanity context. For example, when using Rails this would be
102
+ # set by the set_vanity_context before filter (via use_vanity).
103
+ def self.context=(context)
104
+ Thread.current[:vanity_context] = context
105
+ end
106
+
107
+ end
108
+
109
+ class Object
110
+
111
+ # Use this method to define or access an experiment.
112
+ #
113
+ # To define an experiment, call with a name, options and a block. For
114
+ # example:
115
+ # experiment "Text size" do
116
+ # alternatives :small, :medium, :large
117
+ # end
118
+ #
119
+ # puts experiment(:text_size).alternatives
120
+ def experiment(name, options = nil, &block)
121
+ if block
122
+ Vanity.playground.define(name, options, &block)
123
+ else
124
+ Vanity.playground.experiment(name)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,13 @@
1
+ require File.join(File.dirname(__FILE__), "../vanity")
2
+ require File.join(File.dirname(__FILE__), "rails/helpers")
3
+ require File.join(File.dirname(__FILE__), "rails/testing")
4
+
5
+ # Use Rails logger by default.
6
+ Vanity.playground.logger ||= ActionController::Base.logger
7
+ Vanity.playground.load_path = "#{RAILS_ROOT}/experiments"
8
+
9
+ # Include in controller, add view helper methods.
10
+ ActionController::Base.class_eval do
11
+ include Vanity::Rails
12
+ helper Vanity::Rails
13
+ end
@@ -0,0 +1,121 @@
1
+ module Vanity
2
+ # Helper methods for use in your controllers.
3
+ #
4
+ # 1) Use Vanity from within your controller:
5
+ #
6
+ # class ApplicationController < ActionController::Base
7
+ # use_vanity :current_user end
8
+ # end
9
+ #
10
+ # 2) Present different options for an A/B test:
11
+ #
12
+ # Get started for only $<%= ab_test :pricing %> a month!
13
+ #
14
+ # 3) Measure conversion:
15
+ #
16
+ # def signup
17
+ # ab_goal! :pricing
18
+ # . . .
19
+ # end
20
+ module Rails
21
+ module ClassMethods
22
+
23
+ # Defines the vanity_identity method, and the set_identity_context before
24
+ # filter.
25
+ #
26
+ # Single argument names a method that returns an object whose identity is
27
+ # the vanity identity. Identity is used to present an experiment
28
+ # consistently to the same person or people. It can be the user's
29
+ # identity, group, project. The object must provide its identity in
30
+ # response to the method +id+.
31
+ #
32
+ # For example, if +current_user+ returns a +User+ object, then to use the
33
+ # user's id:
34
+ # class ApplicationController < ActionController::Base
35
+ # use_vanity :current_user
36
+ # end
37
+ #
38
+ # If that method returns nil (e.g. the user has not signed in), a random
39
+ # value will be used, instead. That random value is maintained using a
40
+ # cookie.
41
+ #
42
+ # Alternatively, if you call use_vanity with a block, it will yield to the
43
+ # block with controller.
44
+ #
45
+ # If there is no identity you can use, call use_vanity with the value +nil+.
46
+ #
47
+ # For example:
48
+ # class ApplicationController < ActionController::Base
49
+ # use_vanity :current_user
50
+ # end
51
+ #
52
+ # class ProjectController < ApplicationController
53
+ # use_vanity { |controller| controller.project_id }
54
+ # end
55
+ def use_vanity(symbol = nil, &block)
56
+ define_method :vanity_identity do
57
+ return @vanity_identity if @vanity_identity
58
+ if block
59
+ @vanity_identity = block.call(self)
60
+ elsif symbol && object = send(symbol)
61
+ @vanity_identity = object.id
62
+ else
63
+ @vanity_identity = cookies["vanity_id"] || OpenSSL::Random.random_bytes(16).unpack("H*")[0]
64
+ cookies["vanity_id"] = { value: @vanity_identity, expires: 1.month.from_now }
65
+ @vanity_identity
66
+ end
67
+ end
68
+ define_method :set_vanity_context do
69
+ Vanity.context = self
70
+ end
71
+ before_filter :set_vanity_context
72
+ end
73
+ end
74
+
75
+ def self.included(base) #:nodoc:
76
+ base.extend ClassMethods
77
+ end
78
+
79
+ # This method returns one of the alternative values in the named A/B test.
80
+ #
81
+ # Examples using ab_test inside controller:
82
+ # def index
83
+ # if ab_test(:new_page) # true/false test
84
+ # render action: "new_page"
85
+ # else
86
+ # render action: "index"
87
+ # end
88
+ # end
89
+ #
90
+ # def index
91
+ # render action: ab_test(:new_page) # alternatives are page names
92
+ # end
93
+ #
94
+ # Examples using ab_test inside view:
95
+ # <%= if ab_test(:banner) %>100% less complexity!<% end %>
96
+ #
97
+ # <%= ab_test(:greeting) %> <%= current_user.name %>
98
+ #
99
+ # <% ab_test :features do |count| %>
100
+ # <%= count %> features to choose from!
101
+ # <% end %>
102
+ def ab_test(name, &block)
103
+ value = Vanity.playground.experiment(name).choose
104
+ if block
105
+ content = capture(value, &block)
106
+ block_called_from_erb?(block) ? concat(content) : content
107
+ else
108
+ value
109
+ end
110
+ end
111
+
112
+ # This method records conversion on the named A/B test. For example:
113
+ # def create
114
+ # ab_goal! :call_to_action
115
+ # Acccount.create! params[:account]
116
+ # end
117
+ def ab_goal!(name)
118
+ Vanity.playground.experiment(name).conversion!
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,9 @@
1
+ module ActionController #:nodoc:
2
+ class TestCase
3
+ alias :setup_controller_request_and_response_without_vanity :setup_controller_request_and_response
4
+ def setup_controller_request_and_response
5
+ setup_controller_request_and_response_without_vanity
6
+ Vanity.context = @request
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ <style>
2
+ ol.experiments { list-style: none; margin: 1em 0; padding: 0; border-bottom: 1px solid #ccc }
3
+ ol.experiments .hide { display: none }
4
+ li.experiment h3 { margin: 0 }
5
+ li.experiment blockquote { margin: 0 }
6
+ li.experiment .meta { color: #444 }
7
+ li.experiment .data dt { float: left }
8
+ li.experiment .green { color: green }
9
+ li.experiment .red { color: red }
10
+ li.experiment .data dd { margin: 0 0 .3em 6em }
11
+ </style>
12
+ <h2>Experiments</h2>
13
+ <ol class="experiments">
14
+ <% experiments.sort_by(&:created_at).each do |exp| %>
15
+ <li class="experiment" id="experiment_<%= CGI.escape exp.id.to_s %>">
16
+ <h3><%= CGI.escape_html exp.name %></h3>
17
+ <blockquote><%= CGI.escape_html exp.description.to_s %></blockquote>
18
+ <%= exp.report %>
19
+ <p class="meta"><%= exp.humanize %> started <%= exp.created_at.strftime("%a, %b %-d %Y") %></p>
20
+ </li>
21
+ <% end %>
22
+ </ol>
@@ -0,0 +1,3 @@
1
+ <% ab_test :simple_ab do |value| %>
2
+ <%= value %>
3
+ <% end %>
@@ -0,0 +1,190 @@
1
+ require "test/test_helper"
2
+
3
+ class AbTestController < ActionController::Base
4
+ use_vanity :current_user
5
+ attr_accessor :current_user
6
+
7
+ def test_render
8
+ render text: ab_test(:simple_ab)
9
+ end
10
+
11
+ def test_view
12
+ render inline: "<%= ab_test(:simple_ab) %>"
13
+ end
14
+
15
+ def test_capture
16
+ render file: File.join(File.dirname(__FILE__), "ab_test_template.erb")
17
+ end
18
+
19
+ def goal
20
+ ab_goal! :simple_ab
21
+ render text: ""
22
+ end
23
+ end
24
+
25
+
26
+ class AbTestTest < ActionController::TestCase
27
+ tests AbTestController
28
+ def setup
29
+ experiment(:simple_ab) { }
30
+ end
31
+
32
+ # Experiment definition
33
+
34
+ def uses_ab_test_when_type_is_ab_test
35
+ experiment(:ab, type: :ab_test) { }
36
+ assert_instance_of Vanity::Experiment::AbTest, experiment(:ab)
37
+ end
38
+
39
+ def requires_at_least_two_alternatives_per_experiment
40
+ assert_raises RuntimeError do
41
+ experiment :none, type: :ab_test do
42
+ alternatives []
43
+ end
44
+ end
45
+ assert_raises RuntimeError do
46
+ experiment :one, type: :ab_test do
47
+ alternatives "foo"
48
+ end
49
+ end
50
+ experiment :two, type: :ab_test do
51
+ alternatives "foo", "bar"
52
+ end
53
+ end
54
+
55
+ # Running experiment
56
+
57
+ def returns_the_same_alternative_consistently
58
+ experiment :foobar do
59
+ alternatives "foo", "bar"
60
+ identify { "6e98ec" }
61
+ end
62
+ assert value = experiment(:foobar).choose
63
+ assert_match /foo|bar/, value
64
+ 1000.times do
65
+ assert_equal value, experiment(:foobar).choose
66
+ end
67
+ end
68
+
69
+ def returns_different_alternatives_for_each_participant
70
+ experiment :foobar do
71
+ alternatives "foo", "bar"
72
+ identify { rand(1000).to_s }
73
+ end
74
+ alts = Array.new(1000) { experiment(:foobar).choose }
75
+ assert_equal %w{bar foo}, alts.uniq.sort
76
+ assert_in_delta alts.select { |a| a == "foo" }.count, 500, 100 # this may fail, such is propability
77
+ end
78
+
79
+ def records_all_participants_in_each_alternative
80
+ ids = (Array.new(200) { |i| i.to_s } * 5).shuffle
81
+ experiment :foobar do
82
+ alternatives "foo", "bar"
83
+ identify { ids.pop }
84
+ end
85
+ 1000.times { experiment(:foobar).choose }
86
+ alts = experiment(:foobar).alternatives
87
+ assert_equal 200, alts.inject(0) { |total,alt| total + alt.participants }
88
+ assert_in_delta alts.first.participants, 100, 20
89
+ end
90
+
91
+ def records_each_converted_participant_only_once
92
+ ids = (Array.new(100) { |i| i.to_s } * 5).shuffle
93
+ test = self
94
+ experiment :foobar do
95
+ alternatives "foo", "bar"
96
+ identify { test.identity ||= ids.pop }
97
+ end
98
+ 500.times do
99
+ test.identity = nil
100
+ experiment(:foobar).choose
101
+ experiment(:foobar).conversion!
102
+ end
103
+ alts = experiment(:foobar).alternatives
104
+ assert_equal 100, alts.inject(0) { |total,alt| total + alt.converted }
105
+ end
106
+
107
+ def test_records_conversion_only_for_participants
108
+ test = self
109
+ experiment :foobar do
110
+ alternatives "foo", "bar"
111
+ identify { test.identity ||= rand(100).to_s }
112
+ end
113
+ 1000.times do
114
+ test.identity = nil
115
+ experiment(:foobar).choose
116
+ experiment(:foobar).conversion!
117
+ test.identity << "!"
118
+ experiment(:foobar).conversion!
119
+ end
120
+ alts = experiment(:foobar).alternatives
121
+ assert_equal 100, alts.inject(0) { |t,a| t + a.converted }
122
+ end
123
+
124
+
125
+ # A/B helper methods
126
+
127
+ def test_fail_if_no_experiment
128
+ new_playground
129
+ assert_raise MissingSourceFile do
130
+ get :test_render
131
+ end
132
+ end
133
+
134
+ def test_ab_test_chooses_in_render
135
+ responses = Array.new(100) do
136
+ @controller = nil ; setup_controller_request_and_response
137
+ get :test_render
138
+ @response.body
139
+ end
140
+ assert_equal %w{false true}, responses.uniq.sort
141
+ end
142
+
143
+ def test_ab_test_chooses_view_helper
144
+ responses = Array.new(100) do
145
+ @controller = nil ; setup_controller_request_and_response
146
+ get :test_view
147
+ @response.body
148
+ end
149
+ assert_equal %w{false true}, responses.uniq.sort
150
+ end
151
+
152
+ def test_ab_test_with_capture
153
+ responses = Array.new(100) do
154
+ @controller = nil ; setup_controller_request_and_response
155
+ get :test_capture
156
+ @response.body
157
+ end
158
+ assert_equal %w{false true}, responses.map(&:strip).uniq.sort
159
+ end
160
+
161
+ def test_ab_test_goal
162
+ responses = Array.new(100) do
163
+ @controller.send(:cookies).clear
164
+ get :goal
165
+ @response.body
166
+ end
167
+ end
168
+
169
+
170
+ # Testing with tests
171
+
172
+ def test_with_given_choice
173
+ 100.times do
174
+ @controller = nil ; setup_controller_request_and_response
175
+ experiment(:simple_ab).chooses(true)
176
+ get :test_render
177
+ post :goal
178
+ end
179
+ alts = experiment(:simple_ab).alternatives
180
+ assert_equal [100,0], alts.map { |alt| alt.participants }
181
+ assert_equal [100,0], alts.map { |alt| alt.conversions }
182
+ end
183
+
184
+ def test_which_chooses_non_existent_alternative
185
+ assert_raises ArgumentError do
186
+ experiment(:simple_ab).chooses(404)
187
+ end
188
+ end
189
+
190
+ end
@@ -0,0 +1,45 @@
1
+ require "test/test_helper"
2
+
3
+ class ExperimentTest < MiniTest::Spec
4
+ it "creates ID from name" do
5
+ exp = experiment("Green Button/Alert") { }
6
+ assert_equal "Green Button/Alert", exp.name
7
+ assert_equal :green_button_alert, exp.id
8
+ end
9
+
10
+ it "evalutes definition block at creation" do
11
+ experiment :green_button do
12
+ expects(:xmts).returns("x")
13
+ end
14
+ assert_equal "x", experiment(:green_button).xmts
15
+ end
16
+
17
+ it "saves experiments after defining it" do
18
+ experiment :green_button do
19
+ expects(:save)
20
+ end
21
+ end
22
+
23
+ it "stores when experiment created" do
24
+ experiment(:simple) { }
25
+ assert_instance_of Time, experiment(:simple).created_at
26
+ assert_in_delta experiment(:simple).created_at.to_i, Time.now.to_i, 1
27
+ end
28
+
29
+ it "keeps creation timestamp across definitions" do
30
+ early = Time.now - 1.day
31
+ Time.expects(:now).once.returns(early)
32
+ experiment(:simple) { }
33
+ assert_equal early.to_i, experiment(:simple).created_at.to_i
34
+ new_playground
35
+ experiment(:simple) { }
36
+ assert_equal early.to_i, experiment(:simple).created_at.to_i
37
+ end
38
+
39
+ it "has description" do
40
+ experiment :simple do
41
+ description "Simple experiment"
42
+ end
43
+ assert_equal "Simple experiment", experiment(:simple).description
44
+ end
45
+ end
@@ -0,0 +1,67 @@
1
+ require "test/test_helper"
2
+
3
+ class PlaygroundTest < MiniTest::Spec
4
+ before do
5
+ @namespace = "vanity:0"
6
+ end
7
+
8
+ it "has one global instance" do
9
+ assert instance = Vanity.playground
10
+ assert_equal instance, Vanity.playground
11
+ end
12
+
13
+ it "use vanity-{major} as default namespace" do
14
+ assert @namespace, Vanity.playground.namespace
15
+ end
16
+
17
+ it "fails if it cannot load named experiment from file" do
18
+ assert_raises MissingSourceFile do
19
+ experiment("Green button")
20
+ end
21
+ end
22
+
23
+ it "loads named experiment from experiments directory" do
24
+ Vanity.playground.expects(:require).with("experiments/green_button")
25
+ begin
26
+ experiment("Green button")
27
+ rescue LoadError=>ex
28
+ end
29
+ end
30
+
31
+ it "complains if experiment not defined in expected filed" do
32
+ Vanity.playground.expects(:require).with("experiments/green_button")
33
+ assert_raises LoadError do
34
+ experiment("Green button")
35
+ end
36
+ end
37
+
38
+ it "returns experiment defined in file" do
39
+ playground = class << Vanity.playground ; self ; end
40
+ playground.send :define_method, :require do |file|
41
+ Vanity.playground.define "Green Button" do
42
+ def xmts ; "x" ; end
43
+ end
44
+ end
45
+ assert_equal "x", experiment("Green button").xmts
46
+ end
47
+
48
+ it "can define and access experiment using symbol" do
49
+ assert green = experiment("Green Button") { }
50
+ assert_equal green, experiment(:green_button)
51
+ assert red = experiment(:red_button) { }
52
+ assert_equal red, experiment("Red Button")
53
+ end
54
+
55
+ it "detect and fail when defining the same experiment twice" do
56
+ experiment("Green Button") { }
57
+ assert_raises RuntimeError do
58
+ experiment(:green_button) { }
59
+ end
60
+ end
61
+
62
+ it "uses playground namespace in experiment" do
63
+ experiment(:green_button) { }
64
+ assert_equal "#{@namespace}:green_button", experiment(:green_button).send(:key)
65
+ assert_equal "#{@namespace}:green_button:participants", experiment(:green_button).send(:key, "participants")
66
+ end
67
+ end
@@ -0,0 +1,76 @@
1
+ require "test/test_helper"
2
+
3
+ class UseVanityController < ActionController::Base
4
+ attr_accessor :current_user
5
+
6
+ def index
7
+ render text: ab_test(:simple)
8
+ end
9
+ end
10
+
11
+ # Pages accessible to everyone, e.g. sign in, community search.
12
+ class UseVanityTest < ActionController::TestCase
13
+ tests UseVanityController
14
+
15
+ def setup
16
+ experiment :simple do
17
+ end
18
+ UseVanityController.class_eval do
19
+ use_vanity :current_user
20
+ end
21
+ end
22
+
23
+ def test_vanity_cookie_is_persistent
24
+ get :index
25
+ assert cookie = @response["Set-Cookie"].find { |c| c[/^vanity_id=/] }
26
+ assert expires = cookie[/vanity_id=[a-f0-9]{32}; path=\/; expires=(.*)(;|$)/, 1]
27
+ assert_in_delta Time.parse(expires), Time.now + 1.month, 1.minute
28
+ end
29
+
30
+ def test_vanity_cookie_default_id
31
+ get :index
32
+ assert_match cookies['vanity_id'], /^[a-f0-9]{32}$/
33
+ end
34
+
35
+ def test_vanity_cookie_retains_id
36
+ @request.cookies['vanity_id'] = "from_last_time"
37
+ get :index
38
+ assert_equal "from_last_time", cookies['vanity_id']
39
+ end
40
+
41
+ def test_vanity_identity_set_from_cookie
42
+ @request.cookies['vanity_id'] = "from_last_time"
43
+ get :index
44
+ assert_equal "from_last_time", @controller.send(:vanity_identity)
45
+ end
46
+
47
+ def test_vanity_identity_set_from_user
48
+ @controller.current_user = mock("user", id: "user_id")
49
+ get :index
50
+ assert_equal "user_id", @controller.send(:vanity_identity)
51
+ end
52
+
53
+ def test_vanity_identity_with_no_user_model
54
+ UseVanityController.class_eval do
55
+ use_vanity nil
56
+ end
57
+ @controller.current_user = Object.new
58
+ get :index
59
+ assert_match cookies['vanity_id'], /^[a-f0-9]{32}$/
60
+ end
61
+
62
+ def test_vanity_identity_set_with_block
63
+ UseVanityController.class_eval do
64
+ attr_accessor :project_id
65
+ use_vanity { |controller| controller.project_id }
66
+ end
67
+ @controller.project_id = "576"
68
+ get :index
69
+ assert_equal "576", @controller.send(:vanity_identity)
70
+ end
71
+
72
+ def teardown
73
+ UseVanityController.send(:filter_chain).clear
74
+ nuke_playground
75
+ end
76
+ end
@@ -0,0 +1,35 @@
1
+ $LOAD_PATH.delete_if { |path| path[/gems\/vanity-\d/] }
2
+ $LOAD_PATH.unshift File.expand_path("../lib", File.dirname(__FILE__))
3
+ RAILS_ROOT = File.expand_path("..")
4
+ require "minitest/spec"
5
+ require "mocha"
6
+ require "action_controller"
7
+ require "action_controller/test_case"
8
+ require "lib/vanity/rails"
9
+ MiniTest::Unit.autorun
10
+
11
+ class MiniTest::Unit::TestCase
12
+ # Call this on teardown. It wipes put the playground and any state held in it
13
+ # (mostly experiments), resets vanity ID, and clears Redis of all experiments.
14
+ def nuke_playground
15
+ Vanity.playground.redis.flushdb
16
+ new_playground
17
+ self.identity = nil
18
+ end
19
+
20
+ # Call this if you need a new playground, e.g. to re-define the same experiment,
21
+ # or reload an experiment (saved by the previous playground).
22
+ def new_playground
23
+ Vanity.instance_variable_set :@playground, Vanity::Playground.new
24
+ end
25
+
26
+ attr_accessor :identity # pass identity to/from experiment/test case
27
+
28
+ def teardown
29
+ nuke_playground
30
+ end
31
+ end
32
+
33
+ ActionController::Routing::Routes.draw do |map|
34
+ map.connect ':controller/:action/:id'
35
+ end
data/vanity.gemspec ADDED
@@ -0,0 +1,19 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "vanity"
3
+ spec.version = "0.2.0"
4
+ spec.author = "Assaf Arkin"
5
+ spec.email = "assaf@labnotes.org"
6
+ spec.homepage = "http://github.com/assaf/vanity"
7
+ spec.summary = "Experience Driven Development framework for Rails"
8
+ spec.description = ""
9
+ #spec.post_install_message = "To get started run vanity --help"
10
+
11
+ spec.files = Dir["{bin,lib,rails,test}/**/*", "CHANGELOG", "README.rdoc", "vanity.gemspec"]
12
+
13
+ spec.has_rdoc = true
14
+ spec.extra_rdoc_files = "README.rdoc", "CHANGELOG"
15
+ spec.rdoc_options = "--title", "Vanity #{spec.version}", "--main", "README.rdoc",
16
+ "--webcvs", "http://github.com/assaf/#{spec.name}"
17
+
18
+ spec.add_dependency "redis", "0.1"
19
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vanity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Assaf Arkin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-11-10 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: redis
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - "="
22
+ - !ruby/object:Gem::Version
23
+ version: "0.1"
24
+ version:
25
+ description: ""
26
+ email: assaf@labnotes.org
27
+ executables: []
28
+
29
+ extensions: []
30
+
31
+ extra_rdoc_files:
32
+ - README.rdoc
33
+ - CHANGELOG
34
+ files:
35
+ - lib/vanity/experiment/ab_test.rb
36
+ - lib/vanity/experiment/base.rb
37
+ - lib/vanity/playground.rb
38
+ - lib/vanity/rails/helpers.rb
39
+ - lib/vanity/rails/testing.rb
40
+ - lib/vanity/rails.rb
41
+ - lib/vanity/report.erb
42
+ - lib/vanity.rb
43
+ - test/ab_test_template.erb
44
+ - test/ab_test_test.rb
45
+ - test/experiment_test.rb
46
+ - test/playground_test.rb
47
+ - test/rails_test.rb
48
+ - test/test_helper.rb
49
+ - CHANGELOG
50
+ - README.rdoc
51
+ - vanity.gemspec
52
+ has_rdoc: true
53
+ homepage: http://github.com/assaf/vanity
54
+ licenses: []
55
+
56
+ post_install_message:
57
+ rdoc_options:
58
+ - --title
59
+ - Vanity 0.2.0
60
+ - --main
61
+ - README.rdoc
62
+ - --webcvs
63
+ - http://github.com/assaf/vanity
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: "0"
71
+ version:
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: "0"
77
+ version:
78
+ requirements: []
79
+
80
+ rubyforge_project:
81
+ rubygems_version: 1.3.5
82
+ signing_key:
83
+ specification_version: 3
84
+ summary: Experience Driven Development framework for Rails
85
+ test_files: []
86
+