vanity 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+