require "test/test_helper" class AbTestController < ActionController::Base use_vanity :current_user attr_accessor :current_user def test_render render text: ab_test(:simple) end def test_view render inline: "<%= ab_test(:simple) %>" end def test_capture render inline: "<% ab_test :simple do |value| %><%= value %><% end %>" end def goal ab_goal! :simple render text: "" end end class AbTestTest < ActionController::TestCase tests AbTestController # -- Experiment definition -- def test_uses_ab_test_when_type_is_ab_test experiment(:ab, type: :ab_test) { } assert_instance_of Vanity::Experiment::AbTest, experiment(:ab) end def test_requires_at_least_two_alternatives_per_experiment assert_raises RuntimeError do experiment :none, type: :ab_test do alternatives [] end end assert_raises RuntimeError do experiment :one, type: :ab_test do alternatives "foo" end end experiment :two, type: :ab_test do alternatives "foo", "bar" end end def test_returning_alternative_by_value experiment :abcd do alternatives :a, :b, :c, :d end assert_equal experiment(:abcd).alternatives[1], experiment(:abcd).alternative(:b) assert_equal experiment(:abcd).alternatives[3], experiment(:abcd).alternative(:d) end def test_alternative_name experiment :abcd do alternatives :a, :b end assert_equal "option A", experiment(:abcd).alternative(:a).name assert_equal "option B", experiment(:abcd).alternative(:b).name end # -- Running experiment -- def test_returns_the_same_alternative_consistently experiment :foobar do alternatives "foo", "bar" identify { "6e98ec" } end assert value = experiment(:foobar).choose assert_match /foo|bar/, value 1000.times do assert_equal value, experiment(:foobar).choose end end def test_returns_different_alternatives_for_each_participant experiment :foobar do alternatives "foo", "bar" identify { rand } end alts = Array.new(1000) { experiment(:foobar).choose } assert_equal %w{bar foo}, alts.uniq.sort assert_in_delta alts.select { |a| a == "foo" }.count, 500, 100 # this may fail, such is propability end def test_records_all_participants_in_each_alternative ids = (Array.new(200) { |i| i } * 5).shuffle experiment :foobar do alternatives "foo", "bar" identify { ids.pop } end 1000.times { experiment(:foobar).choose } alts = experiment(:foobar).alternatives assert_equal 200, alts.map(&:participants).sum assert_in_delta alts.first.participants, 100, 20 end def test_records_each_converted_participant_only_once ids = ((1..100).map { |i| [i,i] } * 5).shuffle.flatten # 3,3,1,1,7,7 etc experiment :foobar do alternatives "foo", "bar" identify { ids.pop } end 500.times do experiment(:foobar).choose experiment(:foobar).conversion! end alts = experiment(:foobar).alternatives assert_equal 100, alts.map(&:converted).sum end def test_records_conversion_only_for_participants ids = ((1..100).map { |i| [-i,i,i] } * 5).shuffle.flatten # -3,3,3,-1,1,1,-7,7,7 etc experiment :foobar do alternatives "foo", "bar" identify { ids.pop } end 500.times do experiment(:foobar).choose experiment(:foobar).conversion! experiment(:foobar).conversion! end alts = experiment(:foobar).alternatives assert_equal 100, alts.map(&:converted).sum end def test_reset_experiment experiment :simple do identify { "me" } complete_if { alternatives.map(&:converted).sum >= 1 } outcome_is { alternative(true) } end experiment(:simple).choose experiment(:simple).conversion! refute experiment(:simple).active? assert_equal true, experiment(:simple).outcome.value experiment(:simple).reset! assert experiment(:simple).active? assert_nil experiment(:simple).outcome assert_nil experiment(:simple).completed_at assert_equal 0, experiment(:simple).alternatives.map(&:participants).sum assert_equal 0, experiment(:simple).alternatives.map(&:conversions).sum assert_equal 0, experiment(:simple).alternatives.map(&:converted).sum end # -- A/B helper methods -- def test_fail_if_no_experiment assert_raise MissingSourceFile do get :test_render end end def test_ab_test_chooses_in_render experiment(:simple) { } responses = Array.new(100) do @controller = nil ; setup_controller_request_and_response get :test_render @response.body end assert_equal %w{false true}, responses.uniq.sort end def test_ab_test_chooses_view_helper experiment(:simple) { } responses = Array.new(100) do @controller = nil ; setup_controller_request_and_response get :test_view @response.body end assert_equal %w{false true}, responses.uniq.sort end def test_ab_test_with_capture experiment(:simple) { } responses = Array.new(100) do @controller = nil ; setup_controller_request_and_response get :test_capture @response.body end assert_equal %w{false true}, responses.map(&:strip).uniq.sort end def test_ab_test_goal experiment(:simple) { } responses = Array.new(100) do @controller.send(:cookies).clear get :goal @response.body end end # -- Testing with tests -- def test_with_given_choice experiment(:simple) { alternatives :a, :b, :c } 100.times do |i| @controller = nil ; setup_controller_request_and_response experiment(:simple).chooses(:b) get :test_render assert "b", @response.body end end def test_which_chooses_non_existent_alternative experiment(:simple) { } assert_raises ArgumentError do experiment(:simple).chooses(404) end end # -- Scoring -- def test_scoring experiment(:abcd) { alternatives :a, :b, :c, :d } # participating, conversions, rate, z-score # Control: 182 35 19.23% N/A 182.times { |i| experiment(:abcd).count i, :a, :participant } 35.times { |i| experiment(:abcd).count i, :a, :conversion } # Treatment A: 180 45 25.00% 1.33 180.times { |i| experiment(:abcd).count i, :b, :participant } 45.times { |i| experiment(:abcd).count i, :b, :conversion } # treatment B: 189 28 14.81% -1.13 189.times { |i| experiment(:abcd).count i, :c, :participant } 28.times { |i| experiment(:abcd).count i, :c, :conversion } # treatment C: 188 61 32.45% 2.94 188.times { |i| experiment(:abcd).count i, :d, :participant } 61.times { |i| experiment(:abcd).count i, :d, :conversion } z_scores = experiment(:abcd).score.alts.map { |alt| "%.2f" % alt.z_score } assert_equal %w{-1.33 0.00 -2.47 1.58}, z_scores confidences = experiment(:abcd).score.alts.map(&:confidence) assert_equal [90, 0, 99, 90], confidences diff = experiment(:abcd).score.alts.map { |alt| alt.difference && alt.difference.round } assert_equal [30, 69, nil, 119], diff assert_equal 3, experiment(:abcd).score.best.id assert_equal 3, experiment(:abcd).score.choice.id assert_equal 1, experiment(:abcd).score.base.id assert_equal 2, experiment(:abcd).score.least.id end def test_scoring_with_no_performers experiment(:abcd) { alternatives :a, :b, :c, :d } assert experiment(:abcd).score.alts.all? { |alt| alt.z_score.nan? } assert experiment(:abcd).score.alts.all? { |alt| alt.confidence == 0 } assert experiment(:abcd).score.alts.all? { |alt| alt.difference.nil? } assert_nil experiment(:abcd).score.best assert_nil experiment(:abcd).score.choice assert_nil experiment(:abcd).score.least end def test_scoring_with_one_performer experiment(:abcd) { alternatives :a, :b, :c, :d } 10.times { |i| experiment(:abcd).count i, :b, :participant } 8.times { |i| experiment(:abcd).count i, :b, :conversion } assert experiment(:abcd).score.alts.all? { |alt| alt.z_score.nan? } assert experiment(:abcd).score.alts.all? { |alt| alt.confidence == 0 } assert experiment(:abcd).score.alts.all? { |alt| alt.difference.nil? } assert 1, experiment(:abcd).score.best.id assert_nil experiment(:abcd).score.choice assert 1, experiment(:abcd).score.base.id assert 1, experiment(:abcd).score.least.id end def test_scoring_with_some_performers experiment(:abcd) { alternatives :a, :b, :c, :d } 10.times { |i| experiment(:abcd).count i, :b, :participant } 8.times { |i| experiment(:abcd).count i, :b, :conversion } 12.times { |i| experiment(:abcd).count i, :d, :participant } 5.times { |i| experiment(:abcd).count i, :d, :conversion } z_scores = experiment(:abcd).score.alts.map { |alt| "%.2f" % alt.z_score } assert_equal %w{NaN 2.01 NaN 0.00}, z_scores confidences = experiment(:abcd).score.alts.map(&:confidence) assert_equal [0, 95, 0, 0], confidences diff = experiment(:abcd).score.alts.map { |alt| alt.difference && alt.difference.round } assert_equal [nil, 92, nil, nil], diff assert_equal 1, experiment(:abcd).score.best.id assert_equal 1, experiment(:abcd).score.choice.id assert_equal 3, experiment(:abcd).score.base.id assert_equal 3, experiment(:abcd).score.least.id end # -- Conclusion -- def test_conclusion experiment(:abcd) { alternatives :a, :b, :c, :d } # participating, conversions, rate, z-score # Control: 182 35 19.23% N/A 182.times { |i| experiment(:abcd).count i, :a, :participant } 35.times { |i| experiment(:abcd).count i, :a, :conversion } # Treatment A: 180 45 25.00% 1.33 180.times { |i| experiment(:abcd).count i, :b, :participant } 45.times { |i| experiment(:abcd).count i, :b, :conversion } # treatment B: 189 28 14.81% -1.13 189.times { |i| experiment(:abcd).count i, :c, :participant } 28.times { |i| experiment(:abcd).count i, :c, :conversion } # treatment C: 188 61 32.45% 2.94 188.times { |i| experiment(:abcd).count i, :d, :participant } 61.times { |i| experiment(:abcd).count i, :d, :conversion } assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n" The best choice is option D: it converted at 32.4% (30% better than option B). With 90% probability this result is statistically significant. Option B converted at 25.0%. Option A converted at 19.2%. Option C converted at 14.8%. Option D selected as the best alternative. TEXT end def test_conclusion_with_some_performers experiment(:abcd) { alternatives :a, :b, :c, :d } # Treatment A: 180 45 25.00% 1.33 180.times { |i| experiment(:abcd).count i, :b, :participant } 45.times { |i| experiment(:abcd).count i, :b, :conversion } # treatment C: 188 61 32.45% 2.94 188.times { |i| experiment(:abcd).count i, :d, :participant } 61.times { |i| experiment(:abcd).count i, :d, :conversion } assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n" The best choice is option D: it converted at 32.4% (30% better than option B). With 90% probability this result is statistically significant. Option B converted at 25.0%. Option A did not convert. Option C did not convert. Option D selected as the best alternative. TEXT end def test_conclusion_without_clear_winner experiment(:abcd) { alternatives :a, :b, :c, :d } # Treatment A: 180 45 25.00% 1.33 180.times { |i| experiment(:abcd).count i, :b, :participant } 58.times { |i| experiment(:abcd).count i, :b, :conversion } # treatment C: 188 61 32.45% 2.94 188.times { |i| experiment(:abcd).count i, :d, :participant } 61.times { |i| experiment(:abcd).count i, :d, :conversion } assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n" The best choice is option D: it converted at 32.4% (1% better than option B). This result is not statistically significant, suggest you continue this experiment. Option B converted at 32.2%. Option A did not convert. Option C did not convert. TEXT end def test_conclusion_without_close_performers experiment(:abcd) { alternatives :a, :b, :c, :d } # Treatment A: 180 45 25.00% 1.33 186.times { |i| experiment(:abcd).count i, :b, :participant } 60.times { |i| experiment(:abcd).count i, :b, :conversion } # treatment C: 188 61 32.45% 2.94 188.times { |i| experiment(:abcd).count i, :d, :participant } 61.times { |i| experiment(:abcd).count i, :d, :conversion } assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n" The best choice is option D: it converted at 32.4%. This result is not statistically significant, suggest you continue this experiment. Option B converted at 32.3%. Option A did not convert. Option C did not convert. TEXT end def test_conclusion_without_equal_performers experiment(:abcd) { alternatives :a, :b, :c, :d } # Treatment A: 180 45 25.00% 1.33 188.times { |i| experiment(:abcd).count i, :b, :participant } 61.times { |i| experiment(:abcd).count i, :b, :conversion } # treatment C: 188 61 32.45% 2.94 188.times { |i| experiment(:abcd).count i, :d, :participant } 61.times { |i| experiment(:abcd).count i, :d, :conversion } assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n" Option D converted at 32.4%. Option B converted at 32.4%. Option A did not convert. Option C did not convert. TEXT end def test_conclusion_with_one_performers experiment(:abcd) { alternatives :a, :b, :c, :d } # Treatment A: 180 45 25.00% 1.33 180.times { |i| experiment(:abcd).count i, :b, :participant } 45.times { |i| experiment(:abcd).count i, :b, :conversion } assert_equal "This experiment did not run long enough to find a clear winner.", experiment(:abcd).conclusion.join("\n") end def test_conclusion_with_no_performers experiment(:abcd) { alternatives :a, :b, :c, :d } assert_equal "This experiment did not run long enough to find a clear winner.", experiment(:abcd).conclusion.join("\n") end # -- Completion -- def test_completion_if experiment :simple do identify { rand } complete_if { true } end experiment(:simple).choose refute experiment(:simple).active? end def test_completion_if_fails experiment :simple do identify { rand } complete_if { fail } end experiment(:simple).choose assert experiment(:simple).active? end def test_completion ids = Array.new(100) { |i| i.to_s }.shuffle experiment :simple do identify { ids.pop } complete_if { alternatives.map(&:participants).sum >= 100 } end 99.times do |i| experiment(:simple).choose assert experiment(:simple).active? end experiment(:simple).choose refute experiment(:simple).active? end def test_ab_methods_after_completion ids = Array.new(200) { |i| [i, i] }.shuffle.flatten experiment :simple do identify { ids.pop } complete_if { alternatives.map(&:participants).sum >= 100 } outcome_is { alternatives[1] } end # Run experiment to completion (100 participants) results = Set.new 100.times do results << experiment(:simple).choose experiment(:simple).conversion! end assert results.include?(true) && results.include?(false) refute experiment(:simple).active? # Test that we always get the same choice (true) 100.times do assert_equal true, experiment(:simple).choose experiment(:simple).conversion! end # We don't get to count the 100 participant's conversion, but that's ok. assert_equal 99, experiment(:simple).alternatives.map(&:converted).sum assert_equal 99, experiment(:simple).alternatives.map(&:conversions).sum end # -- Outcome -- def test_completion_outcome experiment :quick do outcome_is { alternatives[1] } end experiment(:quick).complete! assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome end def test_outcome_is_returns_nil experiment :quick do outcome_is { nil } end experiment(:quick).complete! assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome end def test_outcome_is_returns_something_else experiment :quick do outcome_is { "error" } end experiment(:quick).complete! assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome end def test_outcome_is_fails experiment :quick do outcome_is { fail } end experiment(:quick).complete! assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome end def test_outcome_choosing_best_alternative experiment :quick do end 2.times { |i| experiment(:quick).count i, false, :participant } 10.times { |i| experiment(:quick).count i, true } experiment(:quick).complete! assert_equal experiment(:quick).alternative(true), experiment(:quick).outcome end def test_outcome_only_performing_alternative experiment :quick do end 2.times { |i| experiment(:quick).count i, true } experiment(:quick).complete! assert_equal experiment(:quick).alternative(true), experiment(:quick).outcome end def test_outcome_choosing_equal_alternatives experiment :quick do end 8.times { |i| experiment(:quick).count i, false } 8.times { |i| experiment(:quick).count i, true } experiment(:quick).complete! assert_equal experiment(:quick).alternative(true), experiment(:quick).outcome end end