lookout-vanity 1.8.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (128) hide show
  1. data/.autotest +22 -0
  2. data/.gitignore +10 -0
  3. data/.rvmrc +3 -0
  4. data/.travis.yml +15 -0
  5. data/Appraisals +15 -0
  6. data/CHANGELOG +381 -0
  7. data/Gemfile +25 -0
  8. data/Gemfile.lock +110 -0
  9. data/MIT-LICENSE +21 -0
  10. data/README.rdoc +109 -0
  11. data/Rakefile +169 -0
  12. data/bin/vanity +16 -0
  13. data/doc/_config.yml +2 -0
  14. data/doc/_layouts/_header.html +34 -0
  15. data/doc/_layouts/page.html +47 -0
  16. data/doc/_metrics.textile +12 -0
  17. data/doc/ab_testing.textile +210 -0
  18. data/doc/configuring.textile +45 -0
  19. data/doc/contributing.textile +93 -0
  20. data/doc/credits.textile +23 -0
  21. data/doc/css/page.css +83 -0
  22. data/doc/css/print.css +43 -0
  23. data/doc/css/syntax.css +7 -0
  24. data/doc/email.textile +129 -0
  25. data/doc/experimental.textile +31 -0
  26. data/doc/faq.textile +8 -0
  27. data/doc/identity.textile +43 -0
  28. data/doc/images/ab_in_dashboard.png +0 -0
  29. data/doc/images/clear_winner.png +0 -0
  30. data/doc/images/price_options.png +0 -0
  31. data/doc/images/sidebar_test.png +0 -0
  32. data/doc/images/signup_metric.png +0 -0
  33. data/doc/images/vanity.png +0 -0
  34. data/doc/index.textile +91 -0
  35. data/doc/metrics.textile +231 -0
  36. data/doc/rails.textile +89 -0
  37. data/doc/site.js +27 -0
  38. data/gemfiles/rails3.gemfile +20 -0
  39. data/gemfiles/rails3.gemfile.lock +135 -0
  40. data/gemfiles/rails31.gemfile +20 -0
  41. data/gemfiles/rails31.gemfile.lock +146 -0
  42. data/gemfiles/rails32.gemfile +20 -0
  43. data/gemfiles/rails32.gemfile.lock +144 -0
  44. data/generators/templates/vanity_migration.rb +53 -0
  45. data/generators/vanity_generator.rb +8 -0
  46. data/lib/generators/templates/vanity_migration.rb +53 -0
  47. data/lib/generators/vanity_generator.rb +15 -0
  48. data/lib/vanity.rb +36 -0
  49. data/lib/vanity/adapters/abstract_adapter.rb +145 -0
  50. data/lib/vanity/adapters/active_record_adapter.rb +263 -0
  51. data/lib/vanity/adapters/mock_adapter.rb +157 -0
  52. data/lib/vanity/adapters/mongodb_adapter.rb +178 -0
  53. data/lib/vanity/adapters/redis_adapter.rb +160 -0
  54. data/lib/vanity/backport.rb +26 -0
  55. data/lib/vanity/commands/list.rb +21 -0
  56. data/lib/vanity/commands/report.rb +64 -0
  57. data/lib/vanity/commands/upgrade.rb +34 -0
  58. data/lib/vanity/experiment/ab_test.rb +582 -0
  59. data/lib/vanity/experiment/base.rb +218 -0
  60. data/lib/vanity/frameworks.rb +16 -0
  61. data/lib/vanity/frameworks/rails.rb +325 -0
  62. data/lib/vanity/helpers.rb +71 -0
  63. data/lib/vanity/images/x.gif +0 -0
  64. data/lib/vanity/metric/active_record.rb +93 -0
  65. data/lib/vanity/metric/base.rb +244 -0
  66. data/lib/vanity/metric/google_analytics.rb +83 -0
  67. data/lib/vanity/metric/remote.rb +53 -0
  68. data/lib/vanity/playground.rb +408 -0
  69. data/lib/vanity/templates/_ab_test.erb +28 -0
  70. data/lib/vanity/templates/_experiment.erb +5 -0
  71. data/lib/vanity/templates/_experiments.erb +7 -0
  72. data/lib/vanity/templates/_metric.erb +14 -0
  73. data/lib/vanity/templates/_metrics.erb +13 -0
  74. data/lib/vanity/templates/_report.erb +27 -0
  75. data/lib/vanity/templates/_vanity.js.erb +20 -0
  76. data/lib/vanity/templates/flot.min.js +1 -0
  77. data/lib/vanity/templates/jquery.min.js +19 -0
  78. data/lib/vanity/templates/vanity.css +26 -0
  79. data/lib/vanity/templates/vanity.js +82 -0
  80. data/lib/vanity/version.rb +11 -0
  81. data/lookout-vanity.gemspec +26 -0
  82. data/test/adapters/redis_adapter_test.rb +17 -0
  83. data/test/dummy/Rakefile +7 -0
  84. data/test/dummy/app/controllers/application_controller.rb +3 -0
  85. data/test/dummy/app/helpers/application_helper.rb +2 -0
  86. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  87. data/test/dummy/config.ru +4 -0
  88. data/test/dummy/config/application.rb +44 -0
  89. data/test/dummy/config/boot.rb +10 -0
  90. data/test/dummy/config/database.yml +5 -0
  91. data/test/dummy/config/environment.rb +5 -0
  92. data/test/dummy/config/environments/development.rb +26 -0
  93. data/test/dummy/config/environments/production.rb +49 -0
  94. data/test/dummy/config/environments/test.rb +35 -0
  95. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  96. data/test/dummy/config/initializers/inflections.rb +10 -0
  97. data/test/dummy/config/initializers/mime_types.rb +5 -0
  98. data/test/dummy/config/initializers/secret_token.rb +7 -0
  99. data/test/dummy/config/initializers/session_store.rb +8 -0
  100. data/test/dummy/config/locales/en.yml +5 -0
  101. data/test/dummy/config/routes.rb +58 -0
  102. data/test/dummy/public/favicon.ico +0 -0
  103. data/test/dummy/public/stylesheets/.gitkeep +0 -0
  104. data/test/dummy/script/rails +6 -0
  105. data/test/experiment/ab_test.rb +859 -0
  106. data/test/experiment/base_test.rb +150 -0
  107. data/test/experiments/age_and_zipcode.rb +19 -0
  108. data/test/experiments/metrics/cheers.rb +3 -0
  109. data/test/experiments/metrics/signups.rb +2 -0
  110. data/test/experiments/metrics/yawns.rb +3 -0
  111. data/test/experiments/null_abc.rb +5 -0
  112. data/test/metric/active_record_test.rb +307 -0
  113. data/test/metric/base_test.rb +293 -0
  114. data/test/metric/google_analytics_test.rb +104 -0
  115. data/test/metric/remote_test.rb +109 -0
  116. data/test/myapp/app/controllers/application_controller.rb +2 -0
  117. data/test/myapp/app/controllers/main_controller.rb +7 -0
  118. data/test/myapp/config/boot.rb +110 -0
  119. data/test/myapp/config/environment.rb +10 -0
  120. data/test/myapp/config/environments/production.rb +0 -0
  121. data/test/myapp/config/routes.rb +3 -0
  122. data/test/passenger_test.rb +45 -0
  123. data/test/playground_test.rb +26 -0
  124. data/test/rails_dashboard_test.rb +37 -0
  125. data/test/rails_helper_test.rb +38 -0
  126. data/test/rails_test.rb +412 -0
  127. data/test/test_helper.rb +168 -0
  128. metadata +268 -0
@@ -0,0 +1,49 @@
1
+ Dummy::Application.configure do
2
+ # Settings specified here will take precedence over those in config/application.rb
3
+
4
+ # The production environment is meant for finished, "live" apps.
5
+ # Code is not reloaded between requests
6
+ config.cache_classes = true
7
+
8
+ # Full error reports are disabled and caching is turned on
9
+ config.consider_all_requests_local = false
10
+ config.action_controller.perform_caching = true
11
+
12
+ # Specifies the header that your server uses for sending files
13
+ config.action_dispatch.x_sendfile_header = "X-Sendfile"
14
+
15
+ # For nginx:
16
+ # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'
17
+
18
+ # If you have no front-end server that supports something like X-Sendfile,
19
+ # just comment this out and Rails will serve the files
20
+
21
+ # See everything in the log (default is :info)
22
+ # config.log_level = :debug
23
+
24
+ # Use a different logger for distributed setups
25
+ # config.logger = SyslogLogger.new
26
+
27
+ # Use a different cache store in production
28
+ # config.cache_store = :mem_cache_store
29
+
30
+ # Disable Rails's static asset server
31
+ # In production, Apache or nginx will already do this
32
+ config.serve_static_assets = false
33
+
34
+ # Enable serving of images, stylesheets, and javascripts from an asset server
35
+ # config.action_controller.asset_host = "http://assets.example.com"
36
+
37
+ # Disable delivery errors, bad email addresses will be ignored
38
+ # config.action_mailer.raise_delivery_errors = false
39
+
40
+ # Enable threaded mode
41
+ # config.threadsafe!
42
+
43
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
44
+ # the I18n.default_locale when a translation can not be found)
45
+ config.i18n.fallbacks = true
46
+
47
+ # Send deprecation notices to registered listeners
48
+ config.active_support.deprecation = :notify
49
+ end
@@ -0,0 +1,35 @@
1
+ Dummy::Application.configure do
2
+ # Settings specified here will take precedence over those in config/application.rb
3
+
4
+ # The test environment is used exclusively to run your application's
5
+ # test suite. You never need to work with it otherwise. Remember that
6
+ # your test database is "scratch space" for the test suite and is wiped
7
+ # and recreated between test runs. Don't rely on the data there!
8
+ config.cache_classes = true
9
+
10
+ # Log error messages when you accidentally call methods on nil.
11
+ config.whiny_nils = true
12
+
13
+ # Show full error reports and disable caching
14
+ config.consider_all_requests_local = true
15
+ config.action_controller.perform_caching = false
16
+
17
+ # Raise exceptions instead of rendering exception templates
18
+ config.action_dispatch.show_exceptions = false
19
+
20
+ # Disable request forgery protection in test environment
21
+ config.action_controller.allow_forgery_protection = false
22
+
23
+ # Tell Action Mailer not to deliver emails to the real world.
24
+ # The :test delivery method accumulates sent emails in the
25
+ # ActionMailer::Base.deliveries array.
26
+ config.action_mailer.delivery_method = :test
27
+
28
+ # Use SQL instead of Active Record's schema dumper when creating the test database.
29
+ # This is necessary if your schema can't be completely dumped by the schema dumper,
30
+ # like if you have constraints or database-specific column types
31
+ # config.active_record.schema_format = :sql
32
+
33
+ # Print deprecation notices to the stderr
34
+ config.active_support.deprecation = :stderr
35
+ end
@@ -0,0 +1,7 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
4
+ # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
5
+
6
+ # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
7
+ # Rails.backtrace_cleaner.remove_silencers!
@@ -0,0 +1,10 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Add new inflection rules using the following format
4
+ # (all these examples are active by default):
5
+ # ActiveSupport::Inflector.inflections do |inflect|
6
+ # inflect.plural /^(ox)$/i, '\1en'
7
+ # inflect.singular /^(ox)en/i, '\1'
8
+ # inflect.irregular 'person', 'people'
9
+ # inflect.uncountable %w( fish sheep )
10
+ # end
@@ -0,0 +1,5 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Add new mime types for use in respond_to blocks:
4
+ # Mime::Type.register "text/richtext", :rtf
5
+ # Mime::Type.register_alias "text/html", :iphone
@@ -0,0 +1,7 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ # Your secret key for verifying the integrity of signed cookies.
4
+ # If you change this key, all old signed cookies will become invalid!
5
+ # Make sure the secret is at least 30 characters and all random,
6
+ # no regular words or you'll be exposed to dictionary attacks.
7
+ Dummy::Application.config.secret_token = '33ccbc9a29f3b02e87c08904505b1c9a3a1e97dd01f02e598e65ee9e7b96fff2ca4a6d0dd7c4a8d3682d8c64f84d372661e141264e70697dc576c722c72d80d0'
@@ -0,0 +1,8 @@
1
+ # Be sure to restart your server when you modify this file.
2
+
3
+ Dummy::Application.config.session_store :cookie_store, :key => '_dummy_session'
4
+
5
+ # Use the database for sessions instead of the cookie-based default,
6
+ # which shouldn't be used to store highly confidential information
7
+ # (create the session table with "rails generate session_migration")
8
+ # Dummy::Application.config.session_store :active_record_store
@@ -0,0 +1,5 @@
1
+ # Sample localization file for English. Add more files in this directory for other locales.
2
+ # See http://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
3
+
4
+ en:
5
+ hello: "Hello world"
@@ -0,0 +1,58 @@
1
+ Dummy::Application.routes.draw do
2
+ # The priority is based upon order of creation:
3
+ # first created -> highest priority.
4
+
5
+ # Sample of regular route:
6
+ # match 'products/:id' => 'catalog#view'
7
+ # Keep in mind you can assign values other than :controller and :action
8
+
9
+ # Sample of named route:
10
+ # match 'products/:id/purchase' => 'catalog#purchase', :as => :purchase
11
+ # This route can be invoked with purchase_url(:id => product.id)
12
+
13
+ # Sample resource route (maps HTTP verbs to controller actions automatically):
14
+ # resources :products
15
+
16
+ # Sample resource route with options:
17
+ # resources :products do
18
+ # member do
19
+ # get 'short'
20
+ # post 'toggle'
21
+ # end
22
+ #
23
+ # collection do
24
+ # get 'sold'
25
+ # end
26
+ # end
27
+
28
+ # Sample resource route with sub-resources:
29
+ # resources :products do
30
+ # resources :comments, :sales
31
+ # resource :seller
32
+ # end
33
+
34
+ # Sample resource route with more complex sub-resources
35
+ # resources :products do
36
+ # resources :comments
37
+ # resources :sales do
38
+ # get 'recent', :on => :collection
39
+ # end
40
+ # end
41
+
42
+ # Sample resource route within a namespace:
43
+ # namespace :admin do
44
+ # # Directs /admin/products/* to Admin::ProductsController
45
+ # # (app/controllers/admin/products_controller.rb)
46
+ # resources :products
47
+ # end
48
+
49
+ # You can have the root of your site routed with "root"
50
+ # just remember to delete public/index.html.
51
+ # root :to => "welcome#index"
52
+
53
+ # See how all your routes lay out with "rake routes"
54
+
55
+ # This is a legacy wild controller route that's not recommended for RESTful applications.
56
+ # Note: This route will make all actions in every controller accessible via GET requests.
57
+ match ':controller(/:action(/:id(.:format)))'
58
+ end
File without changes
File without changes
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application.
3
+
4
+ APP_PATH = File.expand_path('../../config/application', __FILE__)
5
+ require File.expand_path('../../config/boot', __FILE__)
6
+ require 'rails/commands'
@@ -0,0 +1,859 @@
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)
9
+ end
10
+
11
+ def test_view
12
+ render :inline=>"<%= ab_test(:simple) %>"
13
+ end
14
+
15
+ def test_capture
16
+ if Rails.version.to_i == 3
17
+ render :inline=>"<%= ab_test :simple do |value| %><%= value %><% end %>"
18
+ else
19
+ render :inline=>"<% ab_test :simple do |value| %><%= value %><% end %>"
20
+ end
21
+ end
22
+
23
+ def track
24
+ track! :coolness
25
+ render :text=>""
26
+ end
27
+ end
28
+
29
+
30
+ class AbTestTest < ActionController::TestCase
31
+ tests AbTestController
32
+
33
+ def setup
34
+ super
35
+ metric "Coolness"
36
+ end
37
+
38
+ # -- Experiment definition --
39
+
40
+ def test_requires_at_least_two_alternatives_per_experiment
41
+ assert_raises RuntimeError do
42
+ new_ab_test :none do
43
+ alternatives []
44
+ end
45
+ end
46
+ assert_raises RuntimeError do
47
+ new_ab_test :one do
48
+ alternatives "foo"
49
+ end
50
+ end
51
+ new_ab_test :two do
52
+ alternatives "foo", "bar"
53
+ metrics :coolness
54
+ end
55
+ end
56
+
57
+ def test_returning_alternative_by_value
58
+ new_ab_test :abcd do
59
+ alternatives :a, :b, :c, :d
60
+ metrics :coolness
61
+ end
62
+ assert_equal experiment(:abcd).alternatives[1], experiment(:abcd).alternative(:b)
63
+ assert_equal experiment(:abcd).alternatives[3], experiment(:abcd).alternative(:d)
64
+ end
65
+
66
+ def test_alternative_name
67
+ new_ab_test :abcd do
68
+ alternatives :a, :b
69
+ metrics :coolness
70
+ end
71
+ assert_equal "option A", experiment(:abcd).alternative(:a).name
72
+ assert_equal "option B", experiment(:abcd).alternative(:b).name
73
+ end
74
+
75
+ def test_alternative_fingerprint_is_unique
76
+ new_ab_test :ab do
77
+ metrics :coolness
78
+ alternatives :a, :b
79
+ end
80
+ new_ab_test :cd do
81
+ metrics :coolness
82
+ alternatives :a, :b
83
+ end
84
+ fingerprints = Vanity.playground.experiments.map { |id, exp| exp.alternatives.map { |alt| exp.fingerprint(alt) } }.flatten
85
+ assert_equal 4, fingerprints.uniq.size
86
+ end
87
+
88
+ def test_alternative_fingerprint_is_consistent
89
+ new_ab_test :ab do
90
+ alternatives :a, :b
91
+ metrics :coolness
92
+ end
93
+ fingerprints = experiment(:ab).alternatives.map { |alt| experiment(:ab).fingerprint(alt) }
94
+ fingerprints.each do |fingerprint|
95
+ assert_match /^[0-9a-f]{10}$/i, fingerprint
96
+ end
97
+ assert_equal fingerprints.first, experiment(:ab).fingerprint(experiment(:ab).alternatives.first)
98
+ end
99
+
100
+
101
+ # -- Experiment metric --
102
+
103
+ def test_explicit_metric
104
+ new_ab_test :abcd do
105
+ metrics :coolness
106
+ end
107
+ assert_equal [Vanity.playground.metric(:coolness)], experiment(:abcd).metrics
108
+ end
109
+
110
+ def test_implicit_metric
111
+ new_ab_test :abcd do
112
+ end
113
+ assert_equal [Vanity.playground.metric(:abcd)], experiment(:abcd).metrics
114
+ end
115
+
116
+ def test_metric_tracking_into_alternative
117
+ metric "Coolness"
118
+ new_ab_test :abcd do
119
+ metrics :coolness
120
+ end
121
+ Vanity.playground.track! :coolness
122
+ assert_equal 1, experiment(:abcd).alternatives.sum(&:conversions)
123
+ end
124
+
125
+ # -- use_js! --
126
+
127
+ def test_does_not_record_participant_when_using_js
128
+ Vanity.playground.use_js!
129
+ ids = (0...10).to_a
130
+ new_ab_test :foobar do
131
+ alternatives "foo", "bar"
132
+ identify { ids.pop }
133
+ metrics :coolness
134
+ end
135
+ 10.times { experiment(:foobar).choose }
136
+ alts = experiment(:foobar).alternatives
137
+ assert_equal 0, alts.map(&:participants).sum
138
+ end
139
+
140
+
141
+ # -- Running experiment --
142
+
143
+ def test_returns_the_same_alternative_consistently
144
+ new_ab_test :foobar do
145
+ alternatives "foo", "bar"
146
+ identify { "6e98ec" }
147
+ metrics :coolness
148
+ end
149
+ assert value = experiment(:foobar).choose.value
150
+ assert_match /foo|bar/, value
151
+ 1000.times do
152
+ assert_equal value, experiment(:foobar).choose.value
153
+ end
154
+ end
155
+
156
+ def test_returns_different_alternatives_for_each_participant
157
+ new_ab_test :foobar do
158
+ alternatives "foo", "bar"
159
+ identify { rand }
160
+ metrics :coolness
161
+ end
162
+ alts = Array.new(1000) { experiment(:foobar).choose.value }
163
+ assert_equal %w{bar foo}, alts.uniq.sort
164
+ assert_in_delta alts.select { |a| a == "foo" }.size, 500, 100 # this may fail, such is propability
165
+ end
166
+
167
+ def test_records_all_participants_in_each_alternative
168
+ ids = (Array.new(200) { |i| i } * 5).shuffle
169
+ new_ab_test :foobar do
170
+ alternatives "foo", "bar"
171
+ identify { ids.pop }
172
+ metrics :coolness
173
+ end
174
+ 1000.times { experiment(:foobar).choose }
175
+ alts = experiment(:foobar).alternatives
176
+ assert_equal 200, alts.map(&:participants).sum
177
+ assert_in_delta alts.first.participants, 100, 20
178
+ end
179
+
180
+ def test_records_each_converted_participant_only_once
181
+ ids = ((1..100).map { |i| [i,i] } * 5).shuffle.flatten # 3,3,1,1,7,7 etc
182
+ new_ab_test :foobar do
183
+ alternatives "foo", "bar"
184
+ identify { ids.pop }
185
+ metrics :coolness
186
+ end
187
+ 500.times do
188
+ experiment(:foobar).choose
189
+ metric(:coolness).track!
190
+ end
191
+ alts = experiment(:foobar).alternatives
192
+ assert_equal 100, alts.map(&:converted).sum
193
+ end
194
+
195
+ def test_records_conversion_only_for_participants
196
+ ids = ((1..100).map { |i| [-i,i,i] } * 5).shuffle.flatten # -3,3,3,-1,1,1,-7,7,7 etc
197
+ new_ab_test :foobar do
198
+ alternatives "foo", "bar"
199
+ identify { ids.pop }
200
+ metrics :coolness
201
+ end
202
+ 500.times do
203
+ experiment(:foobar).choose
204
+ metric(:coolness).track!
205
+ metric(:coolness).track!
206
+ end
207
+ alts = experiment(:foobar).alternatives
208
+ assert_equal 100, alts.map(&:converted).sum
209
+ end
210
+
211
+ TEST_SIZE = 10000
212
+
213
+ def self.should_choose_correct_proportions(delta=1, test_pct=100, control=nil)
214
+ should "choose correct proportions" do
215
+ assert_equal TEST_SIZE*(100-test_pct)/100, @results.count(control) if control
216
+ expected_size = @alternatives.size + (test_pct == 100 || @alternatives.include?(control) ? 0 : 1)
217
+ assert_equal expected_size, @experiment.alternatives.size
218
+ @alternatives.delete(control)
219
+ @alternatives.each do |alt|
220
+ assert_in_delta TEST_SIZE*test_pct/100/@alternatives.size, @results.count(alt), delta
221
+ end
222
+ end
223
+ end
224
+
225
+ def run_test(experiment)
226
+ (0..TEST_SIZE-1).collect do |hash|
227
+ index = experiment.send(:hash_to_alternative, hash) # access protected method
228
+ experiment.alternatives[index].value
229
+ end
230
+ end
231
+
232
+ context "An experiment with three alternatives" do
233
+ setup do
234
+ @experiment = Vanity::Experiment::AbTest.new(Vanity.playground, :test, "test")
235
+ @alternatives = [1, 2, 3]
236
+ @experiment.alternatives(*@alternatives)
237
+ end
238
+
239
+ context "with an out-of-set control value" do
240
+ setup do
241
+ @experiment.control_value(222)
242
+ @experiment.test_percent(15)
243
+ @results = run_test(@experiment)
244
+ end
245
+
246
+ should_choose_correct_proportions(TEST_SIZE/500, 15, 222)
247
+ end
248
+
249
+ context "with an in-set control value" do
250
+ setup do
251
+ @experiment.control_value(3)
252
+ @experiment.test_percent(10)
253
+ @results = run_test(@experiment)
254
+ end
255
+
256
+ should_choose_correct_proportions(TEST_SIZE/500, 10, 3)
257
+ end
258
+
259
+ context "with no control value" do
260
+ setup do
261
+ @results = run_test(@experiment)
262
+ end
263
+
264
+ should_choose_correct_proportions
265
+ end
266
+ end
267
+
268
+ context "An experiment setting alternatives after control value" do
269
+ setup do
270
+ @experiment = Vanity::Experiment::AbTest.new(Vanity.playground, :test, "test")
271
+ @experiment.test_percent(25)
272
+ @alternatives = [1, 2, 3]
273
+ end
274
+
275
+ context "with an out-of-set control value" do
276
+ setup do
277
+ @experiment.control_value(22)
278
+ @experiment.alternatives(*@alternatives)
279
+ @results = run_test(@experiment)
280
+ end
281
+
282
+ should_choose_correct_proportions(TEST_SIZE/500, 25, 22)
283
+ end
284
+
285
+ context "with an in-set control value" do
286
+ setup do
287
+ @experiment.control_value(3)
288
+ @experiment.alternatives(*@alternatives)
289
+ @results = run_test(@experiment)
290
+ end
291
+
292
+ should_choose_correct_proportions(TEST_SIZE/500, 25, 3)
293
+ end
294
+ end
295
+
296
+ def test_destroy_experiment
297
+ new_ab_test :simple do
298
+ identify { "me" }
299
+ metrics :coolness
300
+ complete_if { alternatives.map(&:converted).sum >= 1 }
301
+ outcome_is { alternative(true) }
302
+ end
303
+ experiment(:simple).choose
304
+ metric(:coolness).track!
305
+ assert !experiment(:simple).active?
306
+ assert_equal true, experiment(:simple).outcome.value
307
+
308
+ experiment(:simple).destroy
309
+ assert experiment(:simple).active?
310
+ assert_nil experiment(:simple).outcome
311
+ assert_nil experiment(:simple).completed_at
312
+ assert_equal 0, experiment(:simple).alternatives.map(&:participants).sum
313
+ assert_equal 0, experiment(:simple).alternatives.map(&:conversions).sum
314
+ assert_equal 0, experiment(:simple).alternatives.map(&:converted).sum
315
+ end
316
+
317
+
318
+ # -- A/B helper methods --
319
+
320
+ def test_fail_if_no_experiment
321
+ assert_raise Vanity::NoExperimentError do
322
+ get :test_render
323
+ end
324
+ end
325
+
326
+ def test_ab_test_chooses_in_render
327
+ new_ab_test :simple do
328
+ metrics :coolness
329
+ end
330
+ responses = Array.new(100) do
331
+ @controller = nil ; setup_controller_request_and_response
332
+ get :test_render
333
+ @response.body
334
+ end
335
+ assert_equal %w{false true}, responses.uniq.sort
336
+ end
337
+
338
+ def test_ab_test_chooses_view_helper
339
+ new_ab_test :simple do
340
+ metrics :coolness
341
+ end
342
+ responses = Array.new(100) do
343
+ @controller = nil ; setup_controller_request_and_response
344
+ get :test_view
345
+ @response.body
346
+ end
347
+ assert_equal %w{false true}, responses.uniq.sort
348
+ end
349
+
350
+ def test_ab_test_with_capture
351
+ new_ab_test :simple do
352
+ metrics :coolness
353
+ end
354
+ responses = Array.new(100) do
355
+ @controller = nil ; setup_controller_request_and_response
356
+ get :test_capture
357
+ @response.body
358
+ end
359
+ assert_equal %w{false true}, responses.map(&:strip).uniq.sort
360
+ end
361
+
362
+ def test_ab_test_track
363
+ new_ab_test :simple do
364
+ metrics :coolness
365
+ end
366
+ responses = Array.new(100) do
367
+ @controller.send(:cookies).each{ |cookie| @controller.send(:cookies).delete(cookie.first) }
368
+ get :track
369
+ @response.body
370
+ end
371
+ end
372
+
373
+
374
+ # -- Testing with tests --
375
+
376
+ def test_with_given_choice
377
+ new_ab_test :simple do
378
+ alternatives :a, :b, :c
379
+ metrics :coolness
380
+ end
381
+ 100.times do |i|
382
+ @controller = nil ; setup_controller_request_and_response
383
+ experiment(:simple).chooses(:b)
384
+ get :test_render
385
+ assert "b", @response.body
386
+ end
387
+ end
388
+
389
+ def test_which_chooses_non_existent_alternative
390
+ new_ab_test :simple do
391
+ metrics :coolness
392
+ end
393
+ assert_raises ArgumentError do
394
+ experiment(:simple).chooses(404)
395
+ end
396
+ end
397
+
398
+ def test_chooses_cleared_with_nil
399
+ new_ab_test :simple do
400
+ identify { rand }
401
+ alternatives :a, :b, :c
402
+ metrics :coolness
403
+ end
404
+ responses = Array.new(100) { |i|
405
+ @controller = nil ; setup_controller_request_and_response
406
+ experiment(:simple).chooses(:b)
407
+ experiment(:simple).chooses(nil)
408
+ get :test_render
409
+ @response.body
410
+ }
411
+ assert responses.uniq.size == 3
412
+ end
413
+
414
+
415
+ # -- Scoring --
416
+
417
+ def test_scoring
418
+ new_ab_test :abcd do
419
+ alternatives :a, :b, :c, :d
420
+ metrics :coolness
421
+ end
422
+ # participating, conversions, rate, z-score
423
+ # Control: 182 35 19.23% N/A
424
+ # Treatment A: 180 45 25.00% 1.33
425
+ # treatment B: 189 28 14.81% -1.13
426
+ # treatment C: 188 61 32.45% 2.94
427
+ fake :abcd, :a=>[182, 35], :b=>[180, 45], :c=>[189,28], :d=>[188, 61]
428
+
429
+ z_scores = experiment(:abcd).score.alts.map { |alt| "%.2f" % alt.z_score }
430
+ assert_equal %w{-1.33 0.00 -2.46 1.58}, z_scores
431
+ probabilities = experiment(:abcd).score.alts.map(&:probability)
432
+ assert_equal [90, 0, 99, 90], probabilities
433
+
434
+ diff = experiment(:abcd).score.alts.map { |alt| alt.difference && alt.difference.round }
435
+ assert_equal [30, 69, nil, 119], diff
436
+ assert_equal 3, experiment(:abcd).score.best.id
437
+ assert_equal 3, experiment(:abcd).score.choice.id
438
+
439
+ assert_equal 1, experiment(:abcd).score.base.id
440
+ assert_equal 2, experiment(:abcd).score.least.id
441
+ end
442
+
443
+ def test_scoring_with_no_performers
444
+ new_ab_test :abcd do
445
+ alternatives :a, :b, :c, :d
446
+ metrics :coolness
447
+ end
448
+ assert experiment(:abcd).score.alts.all? { |alt| alt.z_score.nan? }
449
+ assert experiment(:abcd).score.alts.all? { |alt| alt.probability == 0 }
450
+ assert experiment(:abcd).score.alts.all? { |alt| alt.difference.nil? }
451
+ assert_nil experiment(:abcd).score.best
452
+ assert_nil experiment(:abcd).score.choice
453
+ assert_nil experiment(:abcd).score.least
454
+ end
455
+
456
+ def test_scoring_with_one_performer
457
+ new_ab_test :abcd do
458
+ alternatives :a, :b, :c, :d
459
+ metrics :coolness
460
+ end
461
+ fake :abcd, :b=>[10,8]
462
+ assert experiment(:abcd).score.alts.all? { |alt| alt.z_score.nan? }
463
+ assert experiment(:abcd).score.alts.all? { |alt| alt.probability == 0 }
464
+ assert experiment(:abcd).score.alts.all? { |alt| alt.difference.nil? }
465
+ assert_equal 1, experiment(:abcd).score.best.id
466
+ assert_nil experiment(:abcd).score.choice
467
+ assert_equal 2, experiment(:abcd).score.base.id
468
+ assert_equal 1, experiment(:abcd).score.least.id
469
+ end
470
+
471
+ def test_scoring_with_some_performers
472
+ new_ab_test :abcd do
473
+ alternatives :a, :b, :c, :d
474
+ metrics :coolness
475
+ end
476
+ fake :abcd, :b=>[10,8], :d=>[12,5]
477
+
478
+ z_scores = experiment(:abcd).score.alts.map { |alt| "%.2f" % alt.z_score }.map(&:downcase)
479
+ assert_equal %w{nan 2.01 nan 0.00}, z_scores
480
+ probabilities = experiment(:abcd).score.alts.map(&:probability)
481
+ assert_equal [0, 95, 0, 0], probabilities
482
+ diff = experiment(:abcd).score.alts.map { |alt| alt.difference && alt.difference.round }
483
+ assert_equal [nil, 92, nil, nil], diff
484
+ assert_equal 1, experiment(:abcd).score.best.id
485
+ assert_equal 1, experiment(:abcd).score.choice.id
486
+ assert_equal 3, experiment(:abcd).score.base.id
487
+ assert_equal 3, experiment(:abcd).score.least.id
488
+ end
489
+
490
+ def test_scoring_with_different_probability
491
+ new_ab_test :abcd do
492
+ alternatives :a, :b, :c, :d
493
+ metrics :coolness
494
+ end
495
+ fake :abcd, :b=>[10,8], :d=>[12,5]
496
+
497
+ assert_equal 1, experiment(:abcd).score(90).choice.id
498
+ assert_equal 1, experiment(:abcd).score(95).choice.id
499
+ assert_nil experiment(:abcd).score(99).choice
500
+ end
501
+
502
+
503
+ # -- Conclusion --
504
+
505
+ def test_conclusion
506
+ new_ab_test :abcd do
507
+ alternatives :a, :b, :c, :d
508
+ metrics :coolness
509
+ end
510
+ # participating, conversions, rate, z-score
511
+ # Control: 182 35 19.23% N/A
512
+ # Treatment A: 180 45 25.00% 1.33
513
+ # treatment B: 189 28 14.81% -1.13
514
+ # treatment C: 188 61 32.45% 2.94
515
+ fake :abcd, :a=>[182, 35], :b=>[180, 45], :c=>[189,28], :d=>[188, 61]
516
+
517
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
518
+ There are 739 participants in this experiment.
519
+ The best choice is option D: it converted at 32.4% (30% better than option B).
520
+ With 90% probability this result is statistically significant.
521
+ Option B converted at 25.0%.
522
+ Option A converted at 19.2%.
523
+ Option C converted at 14.8%.
524
+ Option D selected as the best alternative.
525
+ TEXT
526
+ end
527
+
528
+ def test_conclusion_with_some_performers
529
+ new_ab_test :abcd do
530
+ alternatives :a, :b, :c, :d
531
+ metrics :coolness
532
+ end
533
+ fake :abcd, :b=>[180, 45], :d=>[188, 61]
534
+
535
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
536
+ There are 368 participants in this experiment.
537
+ The best choice is option D: it converted at 32.4% (30% better than option B).
538
+ With 90% probability this result is statistically significant.
539
+ Option B converted at 25.0%.
540
+ Option A did not convert.
541
+ Option C did not convert.
542
+ Option D selected as the best alternative.
543
+ TEXT
544
+ end
545
+
546
+ def test_conclusion_without_clear_winner
547
+ new_ab_test :abcd do
548
+ alternatives :a, :b, :c, :d
549
+ metrics :coolness
550
+ end
551
+ fake :abcd, :b=>[180, 58], :d=>[188, 61]
552
+
553
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
554
+ There are 368 participants in this experiment.
555
+ The best choice is option D: it converted at 32.4% (1% better than option B).
556
+ This result is not statistically significant, suggest you continue this experiment.
557
+ Option B converted at 32.2%.
558
+ Option A did not convert.
559
+ Option C did not convert.
560
+ TEXT
561
+ end
562
+
563
+ def test_conclusion_without_close_performers
564
+ new_ab_test :abcd do
565
+ alternatives :a, :b, :c, :d
566
+ metrics :coolness
567
+ end
568
+ fake :abcd, :b=>[186, 60], :d=>[188, 61]
569
+
570
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
571
+ There are 374 participants in this experiment.
572
+ The best choice is option D: it converted at 32.4% (1% better than option B).
573
+ This result is not statistically significant, suggest you continue this experiment.
574
+ Option B converted at 32.3%.
575
+ Option A did not convert.
576
+ Option C did not convert.
577
+ TEXT
578
+ end
579
+
580
+ def test_conclusion_without_equal_performers
581
+ new_ab_test :abcd do
582
+ alternatives :a, :b, :c, :d
583
+ metrics :coolness
584
+ end
585
+ fake :abcd, :b=>[188, 61], :d=>[188, 61]
586
+
587
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
588
+ There are 376 participants in this experiment.
589
+ Option D converted at 32.4%.
590
+ Option B converted at 32.4%.
591
+ Option A did not convert.
592
+ Option C did not convert.
593
+ TEXT
594
+ end
595
+
596
+ def test_conclusion_with_one_performers
597
+ new_ab_test :abcd do
598
+ alternatives :a, :b, :c, :d
599
+ metrics :coolness
600
+ end
601
+ fake :abcd, :b=>[180, 45]
602
+
603
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
604
+ There are 180 participants in this experiment.
605
+ This experiment did not run long enough to find a clear winner.
606
+ TEXT
607
+ end
608
+
609
+ def test_conclusion_with_no_performers
610
+ new_ab_test :abcd do
611
+ alternatives :a, :b, :c, :d
612
+ metrics :coolness
613
+ end
614
+ assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
615
+ There are no participants in this experiment yet.
616
+ This experiment did not run long enough to find a clear winner.
617
+ TEXT
618
+ end
619
+
620
+
621
+ # -- Completion --
622
+
623
+ def test_completion_if
624
+ new_ab_test :simple do
625
+ identify { rand }
626
+ complete_if { true }
627
+ metrics :coolness
628
+ end
629
+ experiment(:simple).choose
630
+ assert !experiment(:simple).active?
631
+ end
632
+
633
+ def test_completion_if_fails
634
+ new_ab_test :simple do
635
+ identify { rand }
636
+ complete_if { fail "Testing complete_if raises exception" }
637
+ metrics :coolness
638
+ end
639
+ experiment(:simple).choose
640
+ assert experiment(:simple).active?
641
+ end
642
+
643
+ def test_completion
644
+ ids = Array.new(100) { |i| i.to_s }.shuffle
645
+ new_ab_test :simple do
646
+ identify { ids.pop }
647
+ complete_if { alternatives.map(&:participants).sum >= 100 }
648
+ metrics :coolness
649
+ end
650
+ 99.times do |i|
651
+ experiment(:simple).choose
652
+ assert experiment(:simple).active?
653
+ end
654
+
655
+ experiment(:simple).choose
656
+ assert !experiment(:simple).active?
657
+ end
658
+
659
+ def test_ab_methods_after_completion
660
+ ids = Array.new(200) { |i| [i, i] }.shuffle.flatten
661
+ new_ab_test :simple do
662
+ identify { ids.pop }
663
+ complete_if { alternatives.map(&:participants).sum >= 100 }
664
+ outcome_is { alternatives[1] }
665
+ metrics :coolness
666
+ end
667
+ # Run experiment to completion (100 participants)
668
+ results = Set.new
669
+ 100.times do
670
+ results << experiment(:simple).choose.value
671
+ metric(:coolness).track!
672
+ end
673
+ assert results.include?(true) && results.include?(false)
674
+ assert !experiment(:simple).active?
675
+
676
+ # Test that we always get the same choice (true)
677
+ 100.times do
678
+ assert_equal true, experiment(:simple).choose.value
679
+ metric(:coolness).track!
680
+ end
681
+ # We don't get to count the 100 participant's conversion, but that's ok.
682
+ assert_equal 99, experiment(:simple).alternatives.map(&:converted).sum
683
+ assert_equal 99, experiment(:simple).alternatives.map(&:conversions).sum
684
+ end
685
+
686
+
687
+ # -- Outcome --
688
+
689
+ def test_completion_outcome
690
+ new_ab_test :quick do
691
+ outcome_is { alternatives[1] }
692
+ metrics :coolness
693
+ end
694
+ experiment(:quick).complete!
695
+ assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
696
+ end
697
+
698
+ def test_error_in_completion
699
+ new_ab_test :quick do
700
+ outcome_is { raise RuntimeError }
701
+ metrics :coolness
702
+ end
703
+ e = experiment(:quick)
704
+ e.expects(:warn)
705
+ assert_nothing_raised do
706
+ e.complete!
707
+ end
708
+ end
709
+
710
+ def test_outcome_is_returns_nil
711
+ new_ab_test :quick do
712
+ outcome_is { nil }
713
+ metrics :coolness
714
+ end
715
+ experiment(:quick).complete!
716
+ assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
717
+ end
718
+
719
+ def test_outcome_is_returns_something_else
720
+ new_ab_test :quick do
721
+ outcome_is { "error" }
722
+ metrics :coolness
723
+ end
724
+ experiment(:quick).complete!
725
+ assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
726
+ end
727
+
728
+ def test_outcome_is_fails
729
+ new_ab_test :quick do
730
+ outcome_is { fail "Testing outcome_is raising exception" }
731
+ metrics :coolness
732
+ end
733
+ experiment(:quick).complete!
734
+ assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
735
+ end
736
+
737
+ def test_outcome_choosing_best_alternative
738
+ new_ab_test :quick do
739
+ metrics :coolness
740
+ end
741
+ fake :quick, false=>[2,0], true=>10
742
+ experiment(:quick).complete!
743
+ assert_equal experiment(:quick).alternative(true), experiment(:quick).outcome
744
+ end
745
+
746
+ def test_outcome_only_performing_alternative
747
+ new_ab_test :quick do
748
+ metrics :coolness
749
+ end
750
+ fake :quick, true=>2
751
+ experiment(:quick).complete!
752
+ assert_equal experiment(:quick).alternative(true), experiment(:quick).outcome
753
+ end
754
+
755
+ def test_outcome_choosing_equal_alternatives
756
+ new_ab_test :quick do
757
+ metrics :coolness
758
+ end
759
+ fake :quick, false=>8, true=>8
760
+ experiment(:quick).complete!
761
+ assert_equal experiment(:quick).alternative(true), experiment(:quick).outcome
762
+ end
763
+
764
+
765
+ # -- No collection --
766
+
767
+ def test_no_collection_does_not_track
768
+ not_collecting!
769
+ metric "Coolness"
770
+ new_ab_test :abcd do
771
+ metrics :coolness
772
+ end
773
+ Vanity.playground.track! :coolness
774
+ assert_equal 0, experiment(:abcd).alternatives.sum(&:conversions)
775
+ end
776
+
777
+ def test_no_collection_and_completion
778
+ not_collecting!
779
+ new_ab_test :quick do
780
+ outcome_is { alternatives[1] }
781
+ metrics :coolness
782
+ end
783
+ experiment(:quick).complete!
784
+ assert_nil experiment(:quick).outcome
785
+ end
786
+
787
+ def test_chooses_records_participants
788
+ new_ab_test :simple do
789
+ alternatives :a, :b, :c
790
+ metrics :coolness
791
+ end
792
+ experiment(:simple).chooses(:b)
793
+ assert_equal experiment(:simple).alternatives[1].participants, 1
794
+ end
795
+
796
+ def test_chooses_moves_participant_to_new_alternative
797
+ new_ab_test :simple do
798
+ alternatives :a, :b, :c
799
+ metrics :coolness
800
+ identify { "1" }
801
+ end
802
+ val = experiment(:simple).choose.value
803
+ alternative = experiment(:simple).alternatives.detect {|a| a.value != val }
804
+ experiment(:simple).chooses(alternative.value)
805
+ assert_equal experiment(:simple).choose.value, alternative.value
806
+ experiment(:simple).chooses(val)
807
+ assert_equal experiment(:simple).choose.value, val
808
+ end
809
+
810
+ def test_chooses_records_participants_only_once
811
+ new_ab_test :simple do
812
+ alternatives :a, :b, :c
813
+ metrics :coolness
814
+ end
815
+ 2.times { experiment(:simple).chooses(:b) }
816
+ assert_equal experiment(:simple).alternatives[1].participants, 1
817
+ end
818
+
819
+ def test_chooses_records_participants_for_new_alternatives
820
+ new_ab_test :simple do
821
+ alternatives :a, :b, :c
822
+ metrics :coolness
823
+ end
824
+ experiment(:simple).chooses(:b)
825
+ experiment(:simple).chooses(:c)
826
+ assert_equal experiment(:simple).alternatives[2].participants, 1
827
+ end
828
+
829
+ def test_no_collection_and_chooses
830
+ not_collecting!
831
+ new_ab_test :simple do
832
+ alternatives :a, :b, :c
833
+ metrics :coolness
834
+ end
835
+ assert !experiment(:simple).showing?(experiment(:simple).alternatives[1])
836
+ experiment(:simple).chooses(:b)
837
+ assert experiment(:simple).showing?(experiment(:simple).alternatives[1])
838
+ assert !experiment(:simple).showing?(experiment(:simple).alternatives[2])
839
+ end
840
+
841
+ def test_no_collection_chooses_without_database
842
+ not_collecting!
843
+ new_ab_test :simple do
844
+ alternatives :a, :b, :c
845
+ metrics :coolness
846
+ end
847
+ choice = experiment(:simple).choose.value
848
+ assert [:a, :b, :c].include?(choice)
849
+ assert_equal choice, experiment(:simple).choose.value
850
+ end
851
+
852
+
853
+ # -- Helper methods --
854
+
855
+ def fake(name, args)
856
+ experiment(name).instance_eval { fake args }
857
+ end
858
+
859
+ end