├── .gitignore ├── Resources ├── config │ ├── session.yml │ ├── service.yml │ ├── twig.yml │ ├── manager.yml │ ├── ab-db_driver-orm.yml │ └── ab-db_driver-odm.yml ├── doc │ ├── config.yml.sample │ └── index.rst └── meta │ └── LICENSE ├── Model ├── ErrorNoVersionAvailable.php ├── ErrorUnavailableVersion.php ├── SessionInterface.php ├── ManagerInterface.php └── TestSuiteInterface.php ├── Service ├── ErrorTestSuiteNotFound.php ├── ErrorNoCurrentTestSuite.php ├── ServiceInterface.php └── Service.php ├── ABBundle.php ├── Tests ├── bootstrap.php ├── Mock │ ├── Session.php │ ├── Manager.php │ └── TestSuite.php └── TestCases │ └── MockTest.php ├── README.md ├── phpunit.xml.dist ├── Extension └── ABTwigExtension.php ├── Document └── TestSuite.php ├── Entity └── TestSuite.php ├── DependencyInjection ├── Configuration.php └── ABExtension.php └── Base ├── HttpSession.php ├── DoctrineManager.php └── TestSuite.php /.gitignore: -------------------------------------------------------------------------------- 1 | phpunit.xml 2 | build.xml 3 | build 4 | -------------------------------------------------------------------------------- /Resources/config/session.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ab.session: 3 | class: "%ab.session_class%" 4 | arguments: 5 | - "@session" 6 | -------------------------------------------------------------------------------- /Resources/config/service.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ab: 3 | class: "%ab.service_class%" 4 | arguments: 5 | - "@ab.manager" 6 | - "@ab.session" 7 | -------------------------------------------------------------------------------- /Resources/config/twig.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ab.twig: 3 | class: "AB\\ABBundle\\Extension\\ABTwigExtension" 4 | tags: 5 | - { name: twig.extension } 6 | arguments: 7 | - "@ab" 8 | -------------------------------------------------------------------------------- /Resources/config/manager.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ab.manager: 3 | class: "%ab.manager_class%" 4 | arguments: 5 | - "@ab.persistence_service" 6 | - "%ab.model_repository%" 7 | - "%ab.model_class%" 8 | -------------------------------------------------------------------------------- /Resources/config/ab-db_driver-orm.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | ab.model_repository: "ABBundle:TestSuite" 3 | ab.model_class: "AB\\ABBundle\\Entity\\TestSuite" 4 | ab.persistence_service: "doctrine.orm.default_entity_manager" 5 | -------------------------------------------------------------------------------- /Resources/config/ab-db_driver-odm.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | ab.model_repository: "ABBundle:TestSuite" 3 | ab.model_class: "AB\\ABBundle\\Document\\TestSuite" 4 | ab.persistence_service: "doctrine.odm.mongodb.default_document_manager" 5 | -------------------------------------------------------------------------------- /Model/ErrorNoVersionAvailable.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | */ 9 | class ErrorNoVersionAvailable extends \Exception 10 | { 11 | } 12 | -------------------------------------------------------------------------------- /Model/ErrorUnavailableVersion.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | */ 9 | class ErrorUnavailableVersion extends \Exception 10 | { 11 | } 12 | -------------------------------------------------------------------------------- /Service/ErrorTestSuiteNotFound.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | */ 9 | class ErrorTestSuiteNotFound extends \Exception 10 | { 11 | } 12 | -------------------------------------------------------------------------------- /Service/ErrorNoCurrentTestSuite.php: -------------------------------------------------------------------------------- 1 | 7 | * @license MIT 8 | */ 9 | class ErrorNoCurrentTestSuite extends \Exception 10 | { 11 | } 12 | -------------------------------------------------------------------------------- /ABBundle.php: -------------------------------------------------------------------------------- 1 | 9 | * @license MIT 10 | */ 11 | class ABBundle extends Bundle 12 | { 13 | } 14 | -------------------------------------------------------------------------------- /Resources/doc/config.yml.sample: -------------------------------------------------------------------------------- 1 | # MongoDB 2 | ab: 3 | db_driver: odm 4 | 5 | # Using embedded document 6 | doctrine_mongo_db: 7 | document_managers: 8 | default: 9 | mappings: 10 | ... 11 | ABBundle: ~ 12 | 13 | 14 | # ORM 15 | ab: 16 | db_driver: odm 17 | 18 | # Using embedded entity 19 | doctrine: 20 | orm: 21 | entity_managers: 22 | default: 23 | mappings: 24 | ... 25 | ABBundle: ~ 26 | -------------------------------------------------------------------------------- /Tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | registerNamespace('Symfony', $_SERVER['SYMFONY']); 9 | $loader->register(); 10 | 11 | spl_autoload_register(function($class) 12 | { 13 | if (0 === strpos($class, 'AB\\ABBundle\\')) { 14 | $path = implode('/', array_slice(explode('\\', $class), 2)).'.php'; 15 | require_once __DIR__.'/../'.$path; 16 | return true; 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ABBundle is a Symfony2 bundle that will help you implement A/B testing in your application. 2 | 3 | ### Documentation 4 | 5 | See [`Resources/doc/index.rst`](https://github.com/naholyr/ABBundle/blob/master/Resources/doc/index.rst) 6 | 7 | ### License 8 | 9 | See [`Resources/meta/LICENSE`](https://github.com/naholyr/ABBundle/blob/master/Resources/meta/LICENSE) 10 | 11 | ABBundle has been written by [Nicolas Chambrier a.k.a. naholyr](http://naholyr.fr). 12 | 13 | ### TODO 14 | 15 | * Full documentation (with use cases which will help stabilize API) 16 | * Twig helper (filter) 17 | * Assetic integration (assets replacements) 18 | * Backoffice: UI to manager test suites, and view scores 19 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ./Tests 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Extension/ABTwigExtension.php: -------------------------------------------------------------------------------- 1 | ab_service = $ab_service; 15 | } 16 | 17 | public function getName() 18 | { 19 | return 'ab'; 20 | } 21 | 22 | public function getFilters() 23 | { 24 | return array( 25 | 'ab' => new \Twig_Filter_Method($this, 'getResource'), 26 | ); 27 | } 28 | 29 | public function getResource($resource, $uid, array $parameters = null) 30 | { 31 | $resource = $this->ab_service->getResource($resource, $uid); 32 | 33 | if (is_string($resource) && is_array($parameters)) { 34 | $resource = strtr($resource, $parameters); 35 | } 36 | 37 | return $resource; 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /Document/TestSuite.php: -------------------------------------------------------------------------------- 1 | 14 | * @license MIT 15 | */ 16 | class TestSuite extends BaseTestSuite 17 | { 18 | 19 | /** 20 | * @ODM\Id 21 | */ 22 | protected $id; 23 | 24 | /** 25 | * @ODM\String 26 | * @ODM\UniqueIndex(order="asc") 27 | */ 28 | protected $uid; 29 | 30 | /** 31 | * @ODM\String 32 | */ 33 | protected $description; 34 | 35 | /** 36 | * @ODM\Collection 37 | */ 38 | protected $versions = array(); 39 | 40 | /** 41 | * @ODM\Hash 42 | */ 43 | protected $scores = array(); 44 | 45 | /** 46 | * @ODM\Hash 47 | */ 48 | protected $replacements; 49 | 50 | /** 51 | * @ODM\Boolean 52 | */ 53 | protected $active; 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Resources/meta/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2011 Nicolas Chambrier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/Mock/Session.php: -------------------------------------------------------------------------------- 1 | 11 | * @license MIT 12 | */ 13 | class Session implements SessionInterface 14 | { 15 | 16 | private $version = array(); 17 | 18 | public function getVersion(TestSuiteInterface $test_suite) 19 | { 20 | $key = $test_suite->getUID(); 21 | if (!isset($this->version[$key])) { 22 | $possibles = $test_suite->getAvailableVersions(); 23 | if (count($possibles) == 0) { 24 | throw new ErrorNoVersionAvailable(); 25 | } else { 26 | $this->version[$key] = $possibles[array_rand($possibles)]; 27 | } 28 | } 29 | 30 | return $this->version[$key]; 31 | } 32 | 33 | public function setVersion(TestSuiteInterface $test_suite, $version) 34 | { 35 | $this->version[$test_suite->getUID()] = $version; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Entity/TestSuite.php: -------------------------------------------------------------------------------- 1 | 13 | * @license MIT 14 | */ 15 | class TestSuite extends BaseTestSuite 16 | { 17 | 18 | /** 19 | * @orm:Column(type="integer") 20 | * @orm:Id 21 | */ 22 | protected $id; 23 | 24 | /** 25 | * @orm:Column(type="string", length=32, unique=true) 26 | */ 27 | protected $uid; 28 | 29 | /** 30 | * @orm:Column(type="text", nullable=true) 31 | */ 32 | protected $description; 33 | 34 | /** 35 | * @orm:Column(type="array") 36 | */ 37 | protected $versions = array(); 38 | 39 | /** 40 | * @orm:Column(type="array") 41 | */ 42 | protected $scores = array(); 43 | 44 | /** 45 | * @orm:Column(type="array") 46 | */ 47 | protected $replacements; 48 | 49 | /** 50 | * @orm:Column(type="boolean") 51 | */ 52 | protected $active; 53 | 54 | } 55 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | root('ab'); 15 | 16 | $root->children() 17 | ->scalarNode('db_driver')->isRequired()->cannotBeEmpty()->end() 18 | ->scalarNode('model_class')->defaultNull()->end() 19 | ->scalarNode('model_repository')->defaultNull()->end() 20 | ->scalarNode('load_twig_extension')->defaultValue(true)->treatNullLike(true)->end(); 21 | 22 | return $builder->buildTree(); 23 | } 24 | 25 | public function getDefaultServiceClasses() 26 | { 27 | return array( 28 | 'ab.manager_class' => 'AB\\ABBundle\\Base\\DoctrineManager', 29 | 'ab.session_class' => 'AB\\ABBundle\\Base\\HttpSession', 30 | 'ab.service_class' => 'AB\\ABBundle\\Service\\Service', 31 | ); 32 | } 33 | 34 | public function getParameterNames() 35 | { 36 | return array('model_class', 'model_repository') + array_keys($this->getDefaultServiceClasses()); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Model/SessionInterface.php: -------------------------------------------------------------------------------- 1 | 12 | * @license MIT 13 | */ 14 | interface SessionInterface 15 | { 16 | 17 | /** 18 | * Get current version of the test suite for this session. If 19 | * no value is set yet, it should choose a random one. 20 | * 21 | * @param TestSuiteInterface $test_suite 22 | * 23 | * @return string : a value amongst $test_suite->getAvailableVersions(). 24 | * 25 | * @throws ErrorNoVersionAvailable if the test suite has no available 26 | * version. 27 | */ 28 | public function getVersion(TestSuiteInterface $test_suite); 29 | 30 | /** 31 | * Defines the version of the test suite to be used during this session. 32 | * 33 | * @param TestSuiteInterface $test_suite 34 | * 35 | * @param string $version 36 | * 37 | * @return void 38 | */ 39 | public function setVersion(TestSuiteInterface $test_suite, $version); 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Model/ManagerInterface.php: -------------------------------------------------------------------------------- 1 | 10 | * @license MIT 11 | */ 12 | interface ManagerInterface 13 | { 14 | 15 | /** 16 | * Returns all available tests suites. 17 | * 18 | * @return TestSuiteInterface[] 19 | */ 20 | public function getActiveTestSuites(); 21 | 22 | /** 23 | * Retrieves a test suite by its UID. 24 | * 25 | * @param string $uid 26 | * 27 | * @return TestSuiteInterface 28 | */ 29 | public function getTestSuite($uid); 30 | 31 | /** 32 | * Saves and persist a test suite. 33 | * 34 | * @param TestSuiteInterface $test 35 | */ 36 | public function persist(TestSuiteInterface $test_suite); 37 | 38 | /** 39 | * Removes a persisted test suite. 40 | * 41 | * @param TestSuiteInterface $test 42 | */ 43 | public function remove(TestSuiteInterface $test_suite); 44 | 45 | /** 46 | * Initializes a new test suite. 47 | * A new test suite is active by default. 48 | * 49 | * @param string $uid 50 | * @param array $versions 51 | * 52 | * @return TestSuiteInterface 53 | */ 54 | public function newTestSuite($uid, array $versions = array('A', 'B')); 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Tests/Mock/Manager.php: -------------------------------------------------------------------------------- 1 | 10 | * @license MIT 11 | */ 12 | class Manager implements ManagerInterface 13 | { 14 | 15 | private $test_suites = array(); 16 | 17 | public function getActiveTestSuites() 18 | { 19 | return $this->test_suites; 20 | } 21 | 22 | public function getTestSuite($uid) 23 | { 24 | $found = array_filter($this->test_suites, function(TestSuiteInterface $test_suite) use ($uid) { 25 | return $test_suite->getUID() == $uid; 26 | }); 27 | 28 | return count($found) > 0 ? $found[0] : null; 29 | } 30 | 31 | public function persist(TestSuiteInterface $test_suite) 32 | { 33 | $this->test_suites[] = $test_suite; 34 | } 35 | 36 | public function remove(TestSuiteInterface $test_suite) 37 | { 38 | $found = null; 39 | foreach ($this->test_suites as $i => $persisted_test_suite) { 40 | if ($persisted_test_suite->getUID() == $test_suite->getUID()) { 41 | $found = $i; 42 | break; 43 | } 44 | } 45 | if (!is_null($found)) { 46 | unset($this->test_suites[$found]); 47 | $this->test_suites = array_values($this->test_suites); // FIX keys 48 | } 49 | } 50 | 51 | public function newTestSuite($uid, array $versions = array('A', 'B')) 52 | { 53 | return new TestSuite($uid, $versions); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Base/HttpSession.php: -------------------------------------------------------------------------------- 1 | 12 | * @license MIT 13 | */ 14 | class HttpSession implements SessionInterface 15 | { 16 | 17 | protected $storage; 18 | 19 | protected $key_prefix; 20 | 21 | public function __construct(Session $storage, $key_prefix = 'ABBundle/Session/') 22 | { 23 | $this->storage = $storage; 24 | $this->key_prefix = $key_prefix; 25 | } 26 | 27 | public function getVersion(TestSuiteInterface $test_suite) 28 | { 29 | $key = $this->getKey($test_suite); 30 | if ($this->storage->has($key)) { 31 | $value = $this->storage->get($key); 32 | } else { 33 | $value = $this->randomize($test_suite); 34 | $this->storage->set($key, $value); 35 | } 36 | 37 | return $value; 38 | } 39 | 40 | private function getKey(TestSuiteInterface $test_suite) 41 | { 42 | return $this->key_prefix . $test_suite->getUID(); 43 | } 44 | 45 | private function randomize(TestSuiteInterface $test_suite) 46 | { 47 | $possibles = $test_suite->getAvailableVersions(); 48 | 49 | if (count($possibles) == 0) { 50 | throw new ErrorNoVersionAvailable(); 51 | } 52 | 53 | return $possibles[array_rand($possibles)]; 54 | } 55 | 56 | public function setVersion(TestSuiteInterface $test_suite, $version) 57 | { 58 | $this->storage->set($this->getKey($test_suite), $version); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Base/DoctrineManager.php: -------------------------------------------------------------------------------- 1 | 12 | * @license MIT 13 | */ 14 | class DoctrineManager implements ManagerInterface 15 | { 16 | 17 | /** 18 | * @var ObjectManager 19 | */ 20 | protected $object_manager; 21 | 22 | /** 23 | * @var ObjectRepository 24 | */ 25 | protected $object_repository; 26 | 27 | protected $test_suite_class_name; 28 | 29 | public function __construct(ObjectManager $object_manager, $repository_location, $test_suite_class_name) 30 | { 31 | $this->object_manager = $object_manager; 32 | $this->object_repository = $object_manager->getRepository($repository_location); 33 | $this->test_suite_class_name = $test_suite_class_name; 34 | } 35 | 36 | public function getActiveTestSuites() 37 | { 38 | return $this->object_repository->findBy(array('active' => true)); 39 | } 40 | 41 | public function getTestSuite($uid) 42 | { 43 | return $this->object_repository->findOneBy(array('uid' => $uid)); 44 | } 45 | 46 | public function persist(TestSuiteInterface $test_suite, $flush = true) 47 | { 48 | $this->object_manager->persist($test_suite); 49 | if ($flush) { 50 | $this->object_manager->flush(); 51 | } 52 | } 53 | 54 | public function remove(TestSuiteInterface $test_suite, $flush = true) 55 | { 56 | $this->object_manager->remove($test_suite); 57 | if ($flush) { 58 | $this->object_manager->flush(); 59 | } 60 | } 61 | 62 | public function newTestSuite($uid, array $versions = array('A', 'B')) 63 | { 64 | $class = $this->test_suite_class_name; 65 | 66 | return new $class($uid, $versions); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Tests/Mock/TestSuite.php: -------------------------------------------------------------------------------- 1 | 12 | * @license MIT 13 | */ 14 | class TestSuite implements TestSuiteInterface 15 | { 16 | 17 | private $uid = null; 18 | 19 | private $scores = array(); 20 | 21 | private $replace = array(); 22 | 23 | public function __construct($uid, array $versions) 24 | { 25 | $this->uid = $uid; 26 | foreach ($versions as $version) { 27 | $this->replace[$version] = array(); 28 | $this->scores[$version] = 0; 29 | } 30 | } 31 | 32 | public function addReplacements($version, array $replace) 33 | { 34 | if (!in_array($version, $this->getAvailableVersions())) { 35 | throw new ErrorUnavailableVersion(); 36 | } 37 | 38 | $this->replace[$version] = array_merge($this->replace[$version], $replace); 39 | } 40 | 41 | public function getUID() 42 | { 43 | return $this->uid; 44 | } 45 | 46 | public function getAvailableVersions() 47 | { 48 | return array_keys($this->scores); 49 | } 50 | 51 | public function getResource($version, $resource) 52 | { 53 | if (!in_array($version, $this->getAvailableVersions())) { 54 | throw new ErrorUnavailableVersion(); 55 | } 56 | 57 | return @$this->replace[$version][$resource]; 58 | } 59 | 60 | public function addScore($version, $points = +1) 61 | { 62 | if (!in_array($version, $this->getAvailableVersions())) { 63 | throw new ErrorUnavailableVersion(); 64 | } 65 | 66 | $this->scores[$version] += $points; 67 | } 68 | 69 | public function getScores() 70 | { 71 | return $this->scores; 72 | } 73 | 74 | public function isActive() 75 | { 76 | return true; 77 | } 78 | 79 | public function setActive($boolean) 80 | { 81 | throw new NotImplementedException(); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Service/ServiceInterface.php: -------------------------------------------------------------------------------- 1 | get('ab'); 13 | * $m = $ab->getManager(); 14 | * $t = $m->newTestSuite('register_label'); // Default versions: A and B 15 | * $t->addReplacements('A', array('Click here' => 'Click here to register for free')); // Version A 16 | * $t->addReplacements('B', array('Click here' => 'Register now ! It\'s free !')); // Version B 17 | * $m->persist($t); 18 | * 19 | * 2. In your source page, get the label depending on version randomly stored in user's session 20 | * 21 | * $ab = $this->get('ab'); 22 | * $label = $ab->getResource('Click here', 'register_label'); 23 | * 24 | * 3. In your target page, give points to the current version, which has brought you a user :) 25 | * 26 | * $ab = $this->get('ab'); 27 | * $ab->addScore(+1, 'register_label'); 28 | * 29 | * 4. Check the scores, and make your choice wisely ! 30 | * 31 | * $ab = $this->get('ab'); 32 | * $scores = $ab->getScores('register_label'); 33 | * $winner = $scores['A'] > $scores['B'] ? 'A' : 'B'; 34 | * $loser = $winner == 'A' ? 'B' : 'A'; 35 | * printf('%s won by %d points, against %d.', $winner, $scores[$winner], $scores[$loser]); 36 | * 37 | * 38 | * Note that if you're going to call getResource(), addScore(), or getScores() more than once and 39 | * don't want to repeat the UID each time, you can start by calling: 40 | * 41 | * $ab->setCurrentTestSuite('register_label'); 42 | * 43 | * After this call, you can omit UID in those methods. 44 | * 45 | * 46 | * @author Nicolas Chambrier 47 | * @license MIT 48 | */ 49 | interface ServiceInterface 50 | { 51 | 52 | public function setCurrentTestSuite($uid); 53 | 54 | public function getCurrentTestSuite(); 55 | 56 | public function getResource($resource, $uid = null); 57 | 58 | public function addScore($points = +1, $uid = null); 59 | 60 | public function getScores($uid = null); 61 | 62 | public function reloadActiveTestSuites(); 63 | 64 | } 65 | -------------------------------------------------------------------------------- /DependencyInjection/ABExtension.php: -------------------------------------------------------------------------------- 1 | process($configuration->getConfigTree(), $configs); 21 | 22 | // Validate persistence driver 23 | $db_driver = $config['db_driver']; 24 | if (!in_array($db_driver, array('odm', 'orm', 'custom'))) { 25 | throw new \InvalidArgumentException('Invalid DB driver for ABBundle, check "ab.db_driver" configuration.'); 26 | } 27 | 28 | // Service class names 29 | foreach ($configuration->getDefaultServiceClasses() as $name => $value) { 30 | if (!$container->hasParameter($name)) { 31 | $container->setParameter($name, $value); 32 | } 33 | } 34 | 35 | // Config = YAML files (yep, I hate XML and it won't change now :P) 36 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 37 | 38 | // Load db_driver's configuration 39 | if ($db_driver != 'custom') { 40 | $loader->load(sprintf('ab-db_driver-%s.yml', $config['db_driver'])); 41 | } 42 | 43 | // Eventually override some parameters from driver's config 44 | foreach ($configuration->getParameterNames() as $name) { 45 | if (isset($config[$name]) && !empty($config[$name])) { 46 | $container->setParameter('ab.'.$name, $config[$name]); 47 | } 48 | } 49 | 50 | // Persistence service 51 | if ($container->hasParameter('ab.persistence_service')) { 52 | $container->setAlias('ab.persistence_service', $container->getParameter('ab.persistence_service')); 53 | } 54 | if ($db_driver != 'custom') { 55 | $loader->load('manager.yml'); 56 | } 57 | 58 | // Session service 59 | $loader->load('session.yml'); 60 | 61 | // A/B service 62 | $loader->load('service.yml'); 63 | 64 | // Twig extension 65 | if (is_null($config['load_twig_extension']) || $config['load_twig_extension']) { 66 | $loader->load('twig.yml'); 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Tests/TestCases/MockTest.php: -------------------------------------------------------------------------------- 1 | 9 | * @license MIT 10 | */ 11 | class MockTestCase extends PHPUnit_Framework_TestCase 12 | { 13 | 14 | private $manager; 15 | private $session; 16 | private $test_suite; 17 | 18 | public function __construct() 19 | { 20 | $this->manager = new Manager(); 21 | 22 | $this->session = new Session(); 23 | 24 | $this->test_suite = $this->manager->newTestSuite('colors', array('red', 'blue')); 25 | $this->test_suite->addReplacements('red', array('white' => 'red')); 26 | $this->test_suite->addReplacements('blue', array('white' => 'blue')); 27 | 28 | $this->manager->persist($this->test_suite); 29 | } 30 | 31 | public function testManager() 32 | { 33 | $this->assertEquals($this->manager->getActiveTestSuites(), array($this->test_suite)); 34 | $this->assertEquals($this->manager->getTestSuite('colors'), $this->test_suite); 35 | 36 | $this->manager->remove($this->test_suite); 37 | $this->assertEquals($this->manager->getActiveTestSuites(), array()); 38 | 39 | $this->manager->persist($this->test_suite); 40 | $this->assertEquals($this->manager->getActiveTestSuites(), array($this->test_suite)); 41 | } 42 | 43 | public function testSession() 44 | { 45 | $version1 = $this->session->getVersion($this->test_suite); 46 | $this->assertTrue(in_array($version1, $this->test_suite->getAvailableVersions())); 47 | $version2 = $this->session->getVersion($this->test_suite); 48 | $this->assertEquals($version1, $version2); 49 | } 50 | 51 | public function testResources() 52 | { 53 | $this->assertEquals($this->test_suite->getResource('red', 'white'), 'red'); 54 | $this->assertEquals($this->test_suite->getResource('blue', 'white'), 'blue'); 55 | $this->assertEquals($this->test_suite->getResource('red', 'black'), null); 56 | $this->assertEquals($this->test_suite->getResource('blue', 'black'), null); 57 | } 58 | 59 | public function testInvalidVersion() 60 | { 61 | $this->setExpectedException('AB\\ABBundle\\Model\\ErrorUnavailableVersion'); 62 | $this->test_suite->getResource('orange', 'white'); 63 | } 64 | 65 | public function testScore() 66 | { 67 | $this->test_suite->addScore('red'); 68 | $this->test_suite->addScore('blue', +2); 69 | $scores = $this->test_suite->getScores(); 70 | $this->assertEquals($scores['red'], 1); 71 | $this->assertEquals($scores['blue'], 2); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /Service/Service.php: -------------------------------------------------------------------------------- 1 | 12 | * @license MIT 13 | */ 14 | class Service implements ServiceInterface 15 | { 16 | 17 | private $manager; 18 | 19 | private $session; 20 | 21 | private $active_test_suites; 22 | 23 | private $current_test_suite; 24 | 25 | public function __construct(ManagerInterface $manager, SessionInterface $session) 26 | { 27 | $this->manager = $manager; 28 | $this->session = $session; 29 | $this->reloadActiveTestSuites(); 30 | } 31 | 32 | public function reloadActiveTestSuites() 33 | { 34 | $this->active_test_suites = $this->manager->getActiveTestSuites(); 35 | $this->current_test_suite = null; 36 | } 37 | 38 | public function getManager() 39 | { 40 | return $this->manager; 41 | } 42 | 43 | public function getSession() 44 | { 45 | return $this->session; 46 | } 47 | 48 | protected function getTestSuite($uid) 49 | { 50 | foreach ($this->active_test_suites as $test_suite) { 51 | if ($uid == $test_suite->getUID()) { 52 | return $test_suite; 53 | } 54 | } 55 | 56 | throw new ErrorTestSuiteNotFound(); 57 | } 58 | 59 | public function setCurrentTestSuite($uid = false) 60 | { 61 | if (!$uid) { 62 | $this->current_test_suite = null; 63 | } elseif ($uid instanceof TestSuiteInterface) { 64 | $this->current_test_suite = $uid; 65 | } elseif (is_scalar($uid)) { 66 | $this->current_test_suite = $this->getTestSuite($uid); 67 | } else { 68 | throw new \InvalidArgumentException(); 69 | } 70 | } 71 | 72 | public function getCurrentTestSuite() 73 | { 74 | return $this->current_test_suite; 75 | } 76 | 77 | public function getResource($resource, $uid = null) 78 | { 79 | if (is_null($uid) && is_null($this->current_test_suite)) { 80 | throw new ErrorNoCurrentTestSuite(); 81 | } 82 | $test_suite = is_null($uid) ? $this->current_test_suite : $this->getTestSuite($uid); 83 | 84 | $version = $this->session->getVersion($test_suite); 85 | $replace = $test_suite->getResource($version, $resource); 86 | 87 | if (!is_null($replace)) { 88 | return $replace; 89 | } else { 90 | return $resource; 91 | } 92 | } 93 | 94 | public function addScore($points = +1, $uid = null) 95 | { 96 | if (is_null($uid) && is_null($this->current_test_suite)) { 97 | throw new ErrorNoCurrentTestSuite(); 98 | } 99 | $test_suite = is_null($uid) ? $this->current_test_suite : $this->getTestSuite($uid); 100 | 101 | $version = $this->session->getVersion($test_suite); 102 | $test_suite->addScore($version, $points); 103 | $this->manager->persist($test_suite); 104 | } 105 | 106 | public function getScores($uid = null) 107 | { 108 | if (is_null($uid) && is_null($this->current_test_suite)) { 109 | throw new ErrorNoCurrentTestSuite(); 110 | } 111 | $test_suite = is_null($uid) ? $this->current_test_suite : $this->getTestSuite($uid); 112 | 113 | return $test_suite->getScores(); 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /Model/TestSuiteInterface.php: -------------------------------------------------------------------------------- 1 | getUID() // returns "color" 21 | * - $test_suite->getAvailableVersions() // returns array("original", "new") 22 | * - $test_suite->getResource("original", "style.css") // returns "style.css" 23 | * - $test_suite->getResource("new", "style.css") // returns "new_style.css" 24 | * 25 | * When a user buys something, you just call $test_suite->addScore("new") or 26 | * $test_suite->addScore("original"), depending on what version is activated 27 | * for this user. 28 | * 29 | * @see SessionInterface 30 | * 31 | * Then, a few days later, just check $test_suite->getScores() and see which 32 | * one has the best score, if the difference is significant enough to you, and 33 | * take your decision before disabling the test. 34 | * 35 | * @author Nicolas Chambrier 36 | * @license MIT 37 | */ 38 | interface TestSuiteInterface 39 | { 40 | 41 | /** 42 | * @return string : the Unique ID of this test suite. 43 | * 44 | * @ensures There can't be two available test suites with same UID. 45 | */ 46 | public function getUID(); 47 | 48 | /** 49 | * @return string[] : available versions for this test suite. 50 | */ 51 | public function getAvailableVersions(); 52 | 53 | /** 54 | * @param string $version 55 | * 56 | * @param \Serializable $resource 57 | * 58 | * @return \Serializable : The corresponding resource in this test suite 59 | * for the current version, or null if nothing is specified for this resource 60 | * in this test suite. Then the original value should be used. 61 | * 62 | * @throws ErrorUnavailableVersion if the passed version does not 63 | * exist for this test suite. 64 | * 65 | * @see setVersion() 66 | */ 67 | public function getResource($version, $resource); 68 | 69 | /** 70 | * @param string $version 71 | * 72 | * @param int $points : points to add 73 | * 74 | * @return int : new score for current version. 75 | * 76 | * @throws ErrorUnavailableVersion if the passed version does not 77 | * exist for this test suite. 78 | * 79 | * @see setVersion() 80 | */ 81 | public function addScore($version, $points = +1); 82 | 83 | /** 84 | * @return array(string => int) : score of each version. 85 | */ 86 | public function getScores(); 87 | 88 | /** 89 | * Add resources replacements for this test suite. 90 | * 91 | * For each key "X", future calls to getResource("X") are expected to return 92 | * $replacements["X"]. 93 | * 94 | * Note that a simple call to this method is not expected to persist changes, 95 | * you should call $manager->persist(...). 96 | * 97 | * @param string $version 98 | * @param array $replacements 99 | */ 100 | public function addReplacements($version, array $replacements); 101 | 102 | /** 103 | * Is the test suite active (enabled) ? 104 | * 105 | * @return boolean 106 | */ 107 | public function isActive(); 108 | 109 | /** 110 | * Enable or disable test suite. 111 | * 112 | * Note that a simple call to this method is not expected to persist changes, 113 | * you should call $manager->persist(...). 114 | * 115 | * @param boolean $boolean 116 | */ 117 | public function setActive($boolean); 118 | 119 | } 120 | -------------------------------------------------------------------------------- /Base/TestSuite.php: -------------------------------------------------------------------------------- 1 | 9 | * @license MIT 10 | */ 11 | abstract class TestSuite implements TestSuiteInterface 12 | { 13 | 14 | /** 15 | * Unique UID, should not be an automatic ID to stay human-readable, as 16 | * it should be referenced in the code. 17 | * 18 | * @var string 19 | */ 20 | protected $uid; 21 | 22 | /** 23 | * @var string 24 | */ 25 | protected $description; 26 | 27 | /** 28 | * @var array 29 | */ 30 | protected $versions = array(); 31 | 32 | /** 33 | * @var array 34 | */ 35 | protected $scores = array(); 36 | 37 | /** 38 | * @var array 39 | */ 40 | protected $replacements; 41 | 42 | /** 43 | * @var boolean 44 | */ 45 | protected $active; 46 | 47 | public function __construct($uid, array $versions = array('A', 'B'), $description = "") 48 | { 49 | $this->uid = $uid; 50 | $this->description = $description; 51 | foreach ($versions as $version) { 52 | $this->addVersion($version); 53 | } 54 | $this->active = true; 55 | } 56 | 57 | public function getUID() 58 | { 59 | return $this->uid; 60 | } 61 | 62 | protected function checkVersion($version) 63 | { 64 | if (!in_array($version, $this->versions)) { 65 | throw new ABErrorUnavailableVersion(); 66 | } 67 | } 68 | 69 | public function removeVersion($version) 70 | { 71 | $this->checkVersion($version); 72 | } 73 | 74 | public function addVersion($version) 75 | { 76 | $this->versions[] = $version; 77 | $this->scores[$version] = 0; 78 | } 79 | 80 | public function getAvailableVersions() 81 | { 82 | return $this->versions; 83 | } 84 | 85 | public function addReplacements($version, array $replacements) 86 | { 87 | $this->checkVersion($version); 88 | 89 | $result = isset($this->replacements[$version]) ? $this->replacements[$version] : array(); 90 | $result = array_merge($result, $replacements); 91 | 92 | $this->replacements[$version] = $result; 93 | } 94 | 95 | public function setReplacements($version, array $replacements) 96 | { 97 | $this->checkVersion($version); 98 | 99 | $this->replacements[$version] = $replacements; 100 | } 101 | 102 | public function getReplacements($version) 103 | { 104 | $this->checkVersion($version); 105 | 106 | return isset($this->replacements[$version]) ? $this->replacements[$version] : null; 107 | } 108 | 109 | public function getResource($version, $resource) 110 | { 111 | $this->checkVersion($version); 112 | 113 | return isset($this->replacements[$version][$resource]) ? $this->replacements[$version][$resource] : null; 114 | } 115 | 116 | public function addScore($version, $points = +1) 117 | { 118 | $this->checkVersion($version); 119 | 120 | if (!isset($this->scores[$version])) { 121 | $this->scores[$version] = $points; 122 | } else { 123 | $this->scores[$version] += $points; 124 | } 125 | } 126 | 127 | public function getScores() 128 | { 129 | return $this->scores; 130 | } 131 | 132 | public function getScore($version) 133 | { 134 | $this->checkVersion($version); 135 | 136 | return isset($this->scores[$version]) ? $this->scores[$version] : 0; 137 | } 138 | 139 | public function getDescription() 140 | { 141 | return $this->description; 142 | } 143 | 144 | public function setDescription($description) 145 | { 146 | $this->description = $description; 147 | } 148 | 149 | public function setActive($boolean) 150 | { 151 | $this->active = $boolean; 152 | } 153 | 154 | public function isActive() 155 | { 156 | return $this->active; 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /Resources/doc/index.rst: -------------------------------------------------------------------------------- 1 | ########################################### 2 | A/B Testing Symfony2 Bundle - documentation 3 | ########################################### 4 | 5 | A/B testing consists of providing different presentations to your users, 6 | and choose the one that works best. 7 | 8 | See `definition on Wikipedia `_. 9 | 10 | This bundle is intended to provide you some shortcuts to ease automation of those 11 | tests in your application. 12 | 13 | ************ 14 | Installation 15 | ************ 16 | 17 | Install the bundle as you're used to. For example if your project is versionned using Git: :: 18 | 19 | git add submodule "https://github.com/naholyr/ABBundle.git" "src/AB/ABBundle" 20 | 21 | Add the bundle to your autoload: :: 22 | 23 | // app/autoload.php 24 | $loader->registerNamespace('AB', __DIR__.'/../src'); 25 | 26 | Enable the bundle in your application's kernel: :: 27 | 28 | // app/AppKernel.php 29 | // public function registerBundles() ... 30 | $bundles[] = new AB\ABBundle\ABBundle(); 31 | 32 | ************* 33 | Configuration 34 | ************* 35 | 36 | There are two main components in this bundle: 37 | #. The "manager" will handle the persistence layer. 38 | #. The "session" will handle the affectation of test suites' version to a user. 39 | 40 | Manager (persistence layer) 41 | =========================== 42 | 43 | To store your test suites, you need to provide a persistence backend. 44 | 45 | Two drivers are provided: ``odm`` (Doctrine MongoDB) or ``orm`` (Doctrine ORM). 46 | 47 | Specify your driver in your configuration: :: 48 | 49 | # app/config/config.yml 50 | ab: 51 | db_driver: odm 52 | 53 | Customize model 54 | --------------- 55 | 56 | The first thing you may want to configure is the ``TestSuite`` object itself. 57 | 58 | To achieve this goal, write your own object model, that should at least implement 59 | ``AB\ABBundle\Model\TestSuiteInterface``. You should override ``AB\ABBundle\Base\TestSuite``, 60 | a basic, persistence agnostic, implementation. The default manager expects a constructor 61 | compatible with this implementation: :: 62 | 63 | __construct($uid, array $versions = array('A', 'B')) 64 | 65 | Then you must tell the manager to use this class, and the Doctrine manager to use 66 | the corresponding repository: :: 67 | 68 | # app/config/config.yml 69 | ab: 70 | model_repository: "MyBundle:TestSuite" 71 | model_class: "Me\\MyBundle\\Entity\\TestSuite" 72 | 73 | Customize manager 74 | ----------------- 75 | 76 | To provide your own manager, just create yours implementing ``AB\ABBundle\Model\ManagerInterface``. 77 | 78 | Your constructor must be compatible with ``AB\ABBundle\Base\DoctrineManager``'s one: :: 79 | 80 | __construct(ObjectManager $object_manager, $model_repository, $model_class) 81 | 82 | Then declare its class name in your configuration: :: 83 | 84 | # app/config/config.yml 85 | ab: 86 | manager_class: "Me\\MyBundle\\MyOwnABManager" 87 | 88 | Customize the whole persistence layer 89 | ------------------------------------- 90 | 91 | If you don't use Doctrine, or wrote a manager which is incompatible with base implementation, 92 | then you can use the "custom" DB driver, which means you will have to declare yourself the 93 | ``ab.manager`` service: :: 94 | 95 | # app/config/config.yml 96 | ab: 97 | db_driver: custom # means you will provide everything 98 | persistence_service: "the.persistence.service.id" 99 | services: 100 | ab.manager: 101 | class: "Me\\MyBundle\\MyOwnABManager" 102 | arguments: 103 | - "@ab.persistence" 104 | ... 105 | 106 | Session driver 107 | ============== 108 | 109 | TODO 110 | 111 | * Introduction 112 | * Customizing the session driver 113 | * Default HttpSession 114 | 115 | ***** 116 | Usage 117 | ***** 118 | 119 | Legacy documentation 120 | ==================== 121 | 122 | :: 123 | 124 | ### Usage 125 | 126 | Standard use case: you want to test two different labels on a button, and check which one is best. 127 | 128 | 1 - Create your test suite 129 | 130 | $ab = $this->get('ab'); 131 | $m = $ab->getManager(); 132 | $t = $m->newTestSuite('register_label'); // Default versions: A and B 133 | $t->addReplacements('A', array('Click here' => 'Click here to register for free')); // Version A 134 | $t->addReplacements('B', array('Click here' => 'Register now ! It\'s free !')); // Version B 135 | $m->persist($t); 136 | 137 | 2 - In your source page, get the label depending on version randomly stored in user's session 138 | 139 | $ab = $this->get('ab'); 140 | $label = $ab->getResource('Click here', 'register_label'); 141 | 142 | 3 - In your target page, give points to the current version, which has brought you a user :) 143 | 144 | $ab = $this->get('ab'); 145 | $ab->addScore(+1, 'register_label'); 146 | 147 | 4 - Check the scores, and make your choice wisely ! 148 | 149 | $ab = $this->get('ab'); 150 | $scores = $ab->getScores('register_label'); 151 | $winner = $scores['A'] > $scores['B'] ? 'A' : 'B'; 152 | $loser = $winner == 'A' ? 'B' : 'A'; 153 | printf('%s won by %d points, against %d.', $winner, $scores[$winner], $scores[$loser]); 154 | 155 | #### Alternative usage 156 | 157 | Note that if you're going to call getResource(), addScore(), or getScores() more than once and 158 | don't want to repeat the UID each time, you can start by calling: 159 | 160 | $ab->setCurrentTestSuite('register_label'); 161 | 162 | After this call, you can omit UID in those methods. 163 | 164 | Creating test suites 165 | ==================== 166 | 167 | Using API 168 | --------- 169 | 170 | TODO 171 | 172 | Using embedded UI 173 | ----------------- 174 | 175 | TODO 176 | 177 | Using your test suites 178 | ====================== 179 | 180 | In your controller 181 | ------------------ 182 | 183 | TODO 184 | 185 | In your views 186 | ------------- 187 | 188 | Use the provided Twig filter "``ab``". :: 189 | 190 | {{ "hello" | ab("my_test_suite") }} 191 | → getResource("hello", "my_test_suite") ?> 192 | → "hello" 193 | 194 | {{ "hello, %dude%" | ab("my_test_suite", {"%dude%": "dude's name"}) }} 195 | → getResource("hello, %dude%", "my_test_suite"), array("%dude%" => "dude's name")) ?> 196 | → "hello, dude's name" 197 | 198 | If you don't want to use this feature, you can disable the Twig extension in configuration: :: 199 | 200 | # app/config/config.yml 201 | ab: 202 | load_twig_extension: false 203 | 204 | For your assets 205 | --------------- 206 | 207 | TODO 208 | 209 | --------------------------------------------------------------------------------