banana-split
Version:
Split testing for Node.js and MongoDB
793 lines (756 loc) • 24.2 kB
JavaScript
;
var async = require('async'),
_ = require('underscore'),
Random = require('random-js'),
mongoose = require('mongoose'),
mockgoose = require('mockgoose');
var Banana = require('../lib/Banana');
var db, // fake mockgoose database
banana; // banana instance
// use deterministic random number generator to avoid sporadic test failures
var random = new Random(Random.engines.mt19937().seed(1234));
var deterministicRandom = function () {
return random.real(0, 1);
};
var roughlyEqual = function (a, b, relativeTolerance, absoluteTolerance) {
relativeTolerance = relativeTolerance || 0.1; // 10% default
absoluteTolerance = absoluteTolerance || 0.0001; // default
if (a === b) {
return true;
} else if (Math.abs(a - b) <= absoluteTolerance) {
return true;
} else if (Math.abs(a - b) / Math.min(Math.abs(a), Math.abs(b)) <= relativeTolerance) {
return true;
} else {
return false;
}
};
mockgoose(mongoose);
// to catch errors that happen in an async function called from a test
// see https://github.com/caolan/nodeunit/pull/245
process.on('uncaughtException', function (err) {
console.error(err.stack);
process.exit(1);
});
exports.setUp = function (callback) {
if (db) {
callback();
} else {
db = mongoose.createConnection("test-banana");
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function () {
banana = Banana({db: db, mongoose: mongoose, unitTest: true});
banana.setRandomFunction(deterministicRandom);
callback();
});
}
};
exports.tearDown = function (callback) {
banana.excludeIPs([]);
mockgoose.reset();
callback();
};
exports.roughlyEqual = function (test) {
test.equal(roughlyEqual(1, 1.05), true);
test.equal(roughlyEqual(1, 1.09999), true);
test.equal(roughlyEqual(1, 1.11), false);
test.equal(roughlyEqual(1, 1.11, 0.2), true);
test.equal(roughlyEqual(20, 19), true);
test.equal(roughlyEqual(20, 20), true);
test.equal(roughlyEqual(-20, -21), true);
test.equal(roughlyEqual(-20, -23), false);
// not relatively close, but absolutely close
test.equal(roughlyEqual(0.00001, 0.00002), true);
test.done();
};
exports.initExperiment = function (test) {
async.waterfall([
// check 0 experiements
function (callback) {
banana.listExperiments(function (err, experiments) {
test.equal(experiments.length, 0);
callback();
});
},
// create experiment
function (callback) {
banana.initExperiment({
name: 'colors',
variations: ['red', 'blue']
}, function (err) {
test.ok(!err);
callback();
});
},
// check experiement
function (callback) {
banana.listExperiments(function (err, experiments) {
test.equal(experiments.length, 1);
test.equal(experiments[0].name, 'colors');
callback();
});
},
// add extra variation by re-initializing
// (this will update the existing 'colors' experiment)
function (callback) {
banana.initExperiment({
name: 'colors',
variations: ['red', 'blue', 'green'],
events: ['signup', 'upgrade']
}, function (err) {
test.ok(!err);
callback();
});
},
// check still only 1 experiement
function (callback) {
banana.listExperiments(function (err, experiments) {
test.equal(experiments.length, 1);
test.equal(experiments[0].name, 'colors');
callback();
});
},
// check events
function (callback) {
banana.getExperiment('colors', function (err, experiment) {
test.equal(experiment.events.length, 2);
callback();
});
},
// add new experiment
function (callback) {
banana.initExperiment({
name: 'sizes',
variations: ['small', 'large', 'control', 'massive']
}, function (err) {
callback();
});
},
// check 2 experiements
function (callback) {
banana.listExperiments(function (err, experiments) {
test.equal(experiments.length, 2);
var names = _.pluck(experiments, 'name');
test.ok(_.contains(names, 'colors'));
test.ok(_.contains(names, 'sizes'));
callback();
});
}
], function (err) {
if (err) throw err;
test.done();
});
};
exports.oneParticipant = function (test) {
async.waterfall([
// create experiment
function (callback) {
banana.initExperiment({
name: 'exp1',
variations: ['red', 'blue', 'green']
}, function (err) {
callback(err);
});
},
// participate
function (callback) {
banana.participate({
experiment: 'exp1',
user: 'user1'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err, variationName);
});
},
// re-participate multiple times
function (variationName, callback) {
// subsequent participate calls by same participant...
async.each(_.range(20), function (index, callback) {
banana.participate({
experiment: 'exp1',
user: 'user1',
alternatives: ['red', 'blue', 'green']
}, function (err, newVariation) {
// ...should all return the same variation
test.equal(newVariation, variationName);
callback(err);
});
}, function (err) {
callback(err, variationName);
});
},
function (variationName, callback) {
banana.getResults({experiment: 'exp1', event: 'event1'}, function (err, result) {
test.equal(result.experiment.name, 'exp1');
test.equal(result.variations.length, 3);
var variation = _.findWhere(result.variations, {name: variationName});
test.equal(variation.result.participants, 1);
test.equal(variation.result.conversions, 0);
test.equal(variation.result.conversionRate, 0);
callback(null, variationName);
});
},
// convert
function (variationName, callback) {
banana.trackEvent({
event: 'event1',
user: 'user1'
}, function (err) {
callback(err, variationName);
});
},
function (variationName, callback) {
banana.getResults({experiment: 'exp1', event: 'event1'}, function (err, result) {
var variation = _.findWhere(result.variations, {name: variationName});
test.equal(variation.result.participants, 1);
test.equal(variation.result.conversions, 1);
test.equal(variation.result.conversionRate, 1);
callback(err, variationName);
});
},
// multiple event count condition
function (variationName, callback) {
banana.getResults({experiment: 'exp1', event: 'event1', eventCount: 5}, function (err, result) {
var variation = _.findWhere(result.variations, {name: variationName});
test.equal(variation.result.participants, 1);
test.equal(variation.result.conversions, 0);
test.equal(variation.result.conversionRate, 0);
callback(err, variationName);
});
},
// add 4 more events...
function (variationName, callback) {
async.each(_.range(4), function (number, callback) {
banana.trackEvent({
event: 'event1',
user: 'user1'
}, function (err) {
callback(err);
});
}, function (err) {
callback(err, variationName);
});
},
// multiple event count condition
function (variationName, callback) {
banana.getResults({experiment: 'exp1', event: 'event1', eventCount: 5}, function (err, result) {
var variation = _.findWhere(result.variations, {name: variationName});
test.equal(variation.result.participants, 1);
test.equal(variation.result.conversions, 1);
test.equal(variation.result.conversionRate, 1);
callback();
});
},
// opt out
function (callback) {
banana.optOut({
user: 'user1'
}, function (err) {
callback(err);
});
},
function (callback) {
banana.getResults({experiment: 'exp1', event: 'event1'}, function (err, result) {
_.each(result.variations, function (variation) {
test.equal(variation.result.participants, 0);
test.equal(variation.result.conversions, 0);
test.equal(variation.result.conversionRate, 0);
});
callback();
});
}
], function (err) {
test.equal(err, null, err);
test.done();
});
};
exports.excludeIPs = function (test) {
async.waterfall([
// create experiment
function (callback) {
banana.initExperiment({
name: 'exp1',
variations: ['red', 'blue', 'green']
}, function (err) {
callback(err);
});
},
// participate user1 from ip1
function (callback) {
banana.participate({
experiment: 'exp1',
user: 'user1',
ip: 'ip1'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err);
});
},
// participate user2 from ip2
function (callback) {
banana.participate({
experiment: 'exp1',
user: 'user2',
ip: 'ip2'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err);
});
},
function (callback) {
banana.getResults({experiment: 'exp1', event: 'event1'}, function (err, result) {
var totalParticipants = _.reduce(result.variations, function (memo, variation) {
return memo + variation.result.participants;
}, 0);
test.equal(totalParticipants, 2);
test.equal(totalParticipants, result.combined.participants);
callback(null);
});
},
// exclude some ip addresses and try again
function (callback) {
banana.excludeIPs(['ip2']);
banana.getResults({experiment: 'exp1', event: 'event1'}, function (err, result) {
var totalParticipants = _.reduce(result.variations, function (memo, variation) {
return memo + variation.result.participants;
}, 0);
test.equal(totalParticipants, 1);
callback(null);
});
},
function (callback) {
banana.excludeIPs(['ip1', 'ip2']);
banana.getResults({experiment: 'exp1', event: 'event1'}, function (err, result) {
var totalParticipants = _.reduce(result.variations, function (memo, variation) {
return memo + variation.result.participants;
}, 0);
test.equal(totalParticipants, 0);
callback(null);
});
}
], function (err) {
test.equal(err, null, err);
test.done();
});
};
exports.optOutIfNotConverted = function (test) {
async.waterfall([
// create experiment
function (callback) {
banana.initExperiment({
name: 'colors',
variations: ['red', 'blue', 'green']
}, function (err) {
test.ok(!err, err);
callback();
});
},
// participate
function (callback) {
banana.participate({
experiment: 'colors',
user: 'user1',
}, function (err, variationName) {
test.ok(!err, err);
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err);
});
},
// check there's one participant
function (callback) {
banana.getResults({experiment: 'colors', event: 'event1'}, function (err, experiment) {
test.ok(!err, err);
var totalParticipants = 0;
_.each(experiment.variations, function (variation) {
totalParticipants += variation.result.participants;
});
test.equal(totalParticipants, 1);
callback();
});
},
// opt out
function (callback) {
banana.optOut({
user: 'user1'
}, function (err) {
callback(err);
});
},
// check there's no participants
function (callback) {
banana.getResults({experiment: 'colors', event: 'event1'}, function (err, result) {
var totalParticipants = 0;
_.each(result.variations, function (variation) {
totalParticipants += variation.result.participants;
});
test.equal(totalParticipants, 0);
callback(err);
});
},
// participate again
function (callback) {
banana.participate({
experiment: 'colors',
user: 'user2'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err);
});
},
// convert
function (callback) {
banana.trackEvent({
event: 'event1',
user: 'user2',
}, function (err) {
callback(err);
});
},
// opt out
function (callback) {
banana.optOut({
user: 'user2',
}, function (err) {
callback(err);
});
},
// should be 0 participants
function (callback) {
banana.getResults({experiment: 'colors', event: 'event1'}, function (err, experiment) {
var totalParticipants = 0;
_.each(experiment.variations, function (variation) {
totalParticipants += variation.result.participants;
});
test.equal(totalParticipants, 0);
callback();
});
}
], function (err) {
if (err) throw err;
test.done();
});
};
exports.manyParticipants = function (test) {
// This test simulates many participants with different conversion rates
// and checks whether the results match
// Note - conversion rates only works to granularity of 10%
var EXPERIMENTS = [
{
name: 'colors',
variations: [
{
name: 'red',
conversionRate: 0.7
},
{
name: 'blue',
conversionRate: 0.4
},
{
name: 'green',
conversionRate: 0.3
}
]
},
{
name: 'sizes',
variations: [
{
name: 'small',
conversionRate: 0.6
},
{
name: 'large',
conversionRate: 0.3
}
]
}
];
var TOTAL_PARTICIPANTS = 100;
async.eachSeries(EXPERIMENTS, function (experimentSpec, callback) {
async.waterfall([
// create experiments
function (callback) {
banana.initExperiment({
name: experimentSpec.name,
variations: _.pluck(experimentSpec.variations, 'name')
}, function (err) {
callback(err);
});
},
// add lots of participants
function (callback) {
var participants = [];
var variationCounts = {};
_.each(experimentSpec.variations, function (variation) {
variationCounts[variation.name] = 0;
});
async.each(_.range(TOTAL_PARTICIPANTS), function (index, callback) {
var userID = "user" + index;
banana.participate({
experiment: experimentSpec.name,
user: userID,
}, function (err, variationName) {
variationCounts[variationName]++;
participants.push({
user: userID,
variation: variationName
});
callback(err, variationName);
});
}, function (err) {
callback(err, participants, variationCounts);
});
},
// check the variation counts, which should be a roughly equal split
function (participants, variationCounts, callback) {
banana.getResults({experiment: experimentSpec.name, event: 'event-' + experimentSpec.name}, function (err, result) {
_.each(result.variations, function (variation) {
test.ok(roughlyEqual(variation.result.participants, TOTAL_PARTICIPANTS / result.variations.length, 0.4, 0.1),
"roughly equal split: " + variation.name + ", " + variation.result.participants);
});
callback(err, participants, variationCounts);
});
},
// simulate the conversion rate
function (participants, variationCounts, callback) {
var converted = {};
_.each(experimentSpec.variations, function (variation) {
converted[variation.name] = 0;
});
async.eachSeries(_.range(participants.length), function (index, callback) {
var participant = participants[index];
var variationSpec = _.findWhere(experimentSpec.variations, {name: participant.variation});
// convert based on experimentSpec rates
if (converted[variationSpec.name] < variationSpec.conversionRate * variationCounts[variationSpec.name]) {
banana.trackEvent({
event: 'event-' + experimentSpec.name,
user: participant.user
}, function (err) {
converted[variationSpec.name]++;
callback(err);
});
} else {
callback();
}
}, function (err) {
callback(err, participants);
});
},
// check the conversion rates are correct (allowing for rounding error)
function (participants, callback) {
banana.getResults({experiment: experimentSpec.name, event: 'event-' + experimentSpec.name}, function (err, result) {
// check total participants number
var totalParticipants = _.reduce(_.pluck(_.pluck(result.variations, 'result'), 'participants'), function (a, m) {return a + m;}, 0);
test.equal(totalParticipants, TOTAL_PARTICIPANTS);
_.each(experimentSpec.variations, function (variationSpec) {
var variation = _.findWhere(result.variations, {name: variationSpec.name});
test.equal(variation.name, variationSpec.name);
test.ok(roughlyEqual(variation.result.conversionRate, variationSpec.conversionRate, 0.2),
experimentSpec.name + ", " + variationSpec.name + ": " +
variation.result.conversionRate + " (actual), " + variationSpec.conversionRate + " (expected)");
// just check a valid confidence interval exists
test.ok(variation.result.confidenceInterval > 0);
test.ok(variation.result.confidenceInterval < 1);
});
callback(err, participants);
});
},
// opt out 50% of participants
function (participants, callback) {
async.each(participants.slice(0, participants.length / 2), function (participant, callback) {
banana.optOut({
user: participant.user
}, function (err) {
callback(err);
});
}, function (err) {
callback(err);
});
},
// check total has decreased to 50%
function (callback) {
banana.getResults({experiment: experimentSpec.name, event: 'event-' + experimentSpec.name}, function (err, experiment) {
test.equal(
_.reduce(_.pluck(_.pluck(experiment.variations, 'result'), 'participants'), function (a, m) {return a + m;}, 0),
TOTAL_PARTICIPANTS / 2);
callback();
});
}
], function (err) {
callback(err);
});
}, function (err) {
test.ok(!err, err);
test.done();
});
};
exports.testGetVariation = function (test) {
async.waterfall([
// create experiment
function (callback) {
banana.initExperiment({
name: 'exp1',
variations: ['red', 'blue', 'green']
}, function (err) {
callback(err);
});
},
// participate user1 from ip1
function (callback) {
banana.participate({
experiment: 'exp1',
user: 'user1',
ip: 'ip1'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err, variationName);
});
},
// get variation for user 1
function (variationName, callback) {
banana.getVariation({
experiment: 'exp1',
user: 'user1'
}, function (err, fetchedVariationName) {
test.equal(fetchedVariationName, variationName);
callback();
});
},
// get variation for unknown user
function (callback) {
banana.getVariation({
experiment: 'exp1',
user: 'user2'
}, function (err, fetchedVariationName) {
test.equal(fetchedVariationName, null);
callback();
});
}
], function (err) {
test.ok(!err, err);
test.done();
});
};
// WARNING: this will fail if run on the boudary between two days at exactly midnight,
// since unique IPs are only enforced *within* each day
exports.testMultipleIPUsers = function (test) {
async.series([
// create experiment
function (callback) {
banana.initExperiment({
name: 'exp1',
variations: ['red', 'blue', 'green']
}, function (err) {
callback(err);
});
},
// participate user1 from ip1
function (callback) {
banana.participate({
experiment: 'exp1',
user: 'user1',
ip: 'ip1'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err);
});
},
// participate user2, also from ip1
function (callback) {
banana.participate({
experiment: 'exp1',
user: 'user2',
ip: 'ip1'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err);
});
},
// get result
function (callback) {
banana.getResults({experiment: 'exp1', event: 'no-event'}, function (err, result) {
test.equal(result.combined.participants, 1);
callback(err);
});
},
// participate user3, from different ip: ip2
function (callback) {
banana.participate({
experiment: 'exp1',
user: 'user3',
ip: 'ip2'
}, function (err, variationName) {
test.ok(_.contains(['red', 'blue', 'green'], variationName));
callback(err);
});
},
// get result
function (callback) {
banana.getResults({experiment: 'exp1', event: 'no-event'}, function (err, result) {
test.equal(result.combined.participants, 2);
callback(err);
});
}
], function (err) {
test.ok(!err, err);
test.done();
});
};
exports.testWeightedVariations = function (test) {
async.series([
// create experiment
function (callback) {
banana.initExperiment({
name: 'exp1',
variations: [
{
name: 'red',
weight: 5
},
{
name: 'green',
weight: 4
},
{
name: 'blue',
weight: 1
}
]
}, function (err) {
callback(err);
});
},
// add lots of participants
function (callback) {
var variationCounts = {
red: 0,
green: 0,
blue: 0
};
async.each(_.range(1000), function (index, callback) {
banana.participate({
experiment: 'exp1',
user: 'user-' + index
}, function (err, variationName) {
variationCounts[variationName]++;
callback(err, variationName);
});
}, function (err) {
callback(err);
});
},
// check the variation counts, which should be roughly proportional to the weights
function (callback) {
banana.getResults({experiment: 'exp1', event: 'no-event'}, function (err, result) {
test.ok(roughlyEqual(_.findWhere(result.variations, {name: 'red'}).result.participants, 500));
test.ok(roughlyEqual(_.findWhere(result.variations, {name: 'green'}).result.participants, 400));
test.ok(roughlyEqual(_.findWhere(result.variations, {name: 'blue'}).result.participants, 100));
callback();
});
}
], function (err) {
test.ok(!err, err);
test.done();
});
};