From 09e1f1e3d33a64cf70d6a7f988d4c8788f090f0c Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 8 Feb 2016 18:58:22 -0800 Subject: [PATCH] CRM-17860 - Civi\Test - Add mixins for Headless, Hook, Transaction, and EndToEnd functionality. To describe the environment in which you want to run a test, add an interface to the test class, e.g. * `HeadlessInterface` (along with optional `HookInterface` and `TransactionalInterface`) * `EndToEndInterface` This is an alternative to other approaches like: * Extending a gigantic base-class (CiviUnitTestCase, CiviSeleniumTestCase) * Copy/paste a bunch of boiler-plate into your own class. --- Civi/Test/CiviTestListener.php | 290 ++++++++++++++++++ Civi/Test/EndToEndInterface.php | 18 ++ Civi/Test/HeadlessInterface.php | 38 +++ Civi/Test/HookInterface.php | 30 ++ Civi/Test/TransactionalInterface.php | 19 ++ phpunit.xml.dist | 7 + tests/phpunit/Civi/Test/ExampleHookTest.php | 50 +++ .../Civi/Test/ExampleTransactionalTest.php | 94 ++++++ 8 files changed, 546 insertions(+) create mode 100644 Civi/Test/CiviTestListener.php create mode 100644 Civi/Test/EndToEndInterface.php create mode 100644 Civi/Test/HeadlessInterface.php create mode 100644 Civi/Test/HookInterface.php create mode 100644 Civi/Test/TransactionalInterface.php create mode 100644 tests/phpunit/Civi/Test/ExampleHookTest.php create mode 100644 tests/phpunit/Civi/Test/ExampleTransactionalTest.php diff --git a/Civi/Test/CiviTestListener.php b/Civi/Test/CiviTestListener.php new file mode 100644 index 0000000000..8dff35b3f2 --- /dev/null +++ b/Civi/Test/CiviTestListener.php @@ -0,0 +1,290 @@ + Array(string $hookName => string $methodName)). + */ + private $cache = array(); + + /** + * @var \CRM_Core_Transaction|NULL + */ + private $tx; + + public function startTestSuite(\PHPUnit_Framework_TestSuite $suite) { + $byInterface = $this->indexTestsByInterface($suite->tests()); + $this->validateGroups($byInterface); + $this->autoboot($byInterface); + } + + public function endTestSuite(\PHPUnit_Framework_TestSuite $suite) { + $this->cache = array(); + } + + public function startTest(\PHPUnit_Framework_Test $test) { + if ($this->isCiviTest($test)) { + error_reporting(E_ALL); + $this->errorScope = \CRM_Core_TemporaryErrorScope::useException(); + } + + if ($test instanceof HeadlessInterface) { + $this->bootHeadless($test); + } + + if ($test instanceof HookInterface) { + // Note: bootHeadless() indirectly resets any hooks, which means that hook_civicrm_config + // is unsubscribable. However, after bootHeadless(), we're free to subscribe to hooks again. + $this->registerHooks($test); + } + + if ($test instanceof TransactionalInterface) { + $this->tx = new \CRM_Core_Transaction(TRUE); + $this->tx->rollback(); + } + else { + $this->tx = NULL; + } + } + + public function endTest(\PHPUnit_Framework_Test $test, $time) { + if ($test instanceof TransactionalInterface) { + $this->tx->rollback()->commit(); + $this->tx = NULL; + } + if ($test instanceof HookInterface) { + \CRM_Utils_Hook::singleton()->reset(); + } + if ($this->isCiviTest($test)) { + error_reporting(E_ALL & ~E_NOTICE); + $this->errorScope = NULL; + } + } + + /** + * @param HeadlessInterface|\PHPUnit_Framework_Test $test + */ + protected function bootHeadless($test) { + if (CIVICRM_UF !== 'UnitTests') { + throw new \RuntimeException('CiviHeadlessTestInterface requires CIVICRM_UF=UnitTests'); + } + + // Hrm, this seems wrong. Shouldn't we be resetting the entire session? + $session = \CRM_Core_Session::singleton(); + $session->set('userID', NULL); + + $test->setUpHeadless(); + + $config = \CRM_Core_Config::singleton(TRUE, TRUE); // ugh, performance + \CRM_Utils_System::flushCache(); + \Civi::reset(); + \CRM_Core_Session::singleton()->set('userID', NULL); + + if (property_exists($config->userPermissionClass, 'permissions')) { + $config->userPermissionClass->permissions = NULL; + } + } + + /** + * @param \Civi\Test\HookInterface $test + * @return array + * Array(string $hookName => string $methodName)). + */ + protected function findTestHooks(HookInterface $test) { + $class = get_class($test); + if (!isset($this->cache[$class])) { + $funcs = array(); + foreach (get_class_methods($class) as $func) { + if (preg_match('/^hook_/', $func)) { + $funcs[substr($func, 5)] = $func; + } + } + $this->cache[$class] = $funcs; + } + return $this->cache[$class]; + } + + /** + * @param \PHPUnit_Framework_Test $test + * @return bool + */ + protected function isCiviTest(\PHPUnit_Framework_Test $test) { + return $test instanceof HookInterface || $test instanceof HeadlessInterface; + } + + /** + * Find any hook functions in $test and register them. + * + * @param \Civi\Test\HookInterface $test + */ + protected function registerHooks(HookInterface $test) { + if (CIVICRM_UF !== 'UnitTests') { + // This is not ideal -- it's just a side-effect of how hooks and E2E tests work. + // We can temporarily subscribe to hooks in-process, but for other processes, it gets messy. + throw new \RuntimeException('CiviHookTestInterface requires CIVICRM_UF=UnitTests'); + } + \CRM_Utils_Hook::singleton()->reset(); + /** @var \CRM_Utils_Hook_UnitTests $hooks */ + $hooks = \CRM_Utils_Hook::singleton(); + foreach ($this->findTestHooks($test) as $hook => $func) { + $hooks->setHook($hook, array($test, $func)); + } + } + + /** + * The first time we come across HeadlessInterface or EndToEndInterface, we'll + * try to autoboot. + * + * Once the system is booted, there's nothing we can do -- we're stuck with that + * environment. (Thank you, prolific define()s!) If there's a conflict between a + * test-class and the active boot-level, then we'll have to bail. + * + * @param array $byInterface + * List of test classes, keyed by major interface (HeadlessInterface vs EndToEndInterface). + */ + protected function autoboot($byInterface) { + if (defined('CIVICRM_UF')) { + // OK, nothing we can do. System has booted already. + } + elseif (!empty($byInterface['HeadlessInterface'])) { + putenv('CIVICRM_UF=UnitTests'); + eval($this->cv('php:boot --level=settings', 'phpcode')); + } + elseif (!empty($byInterface['EndToEndInterface'])) { + putenv('CIVICRM_UF='); + eval($this->cv('php:boot --level=settings', 'phpcode')); + } + + $blurb = "Tip: Run the headless tests and end-to-end tests separately, e.g.\n" + . " $ phpunit4 --group headless\n" + . " $ phpunit4 --group e2e \n"; + + if (!empty($byInterface['HeadlessInterface']) && CIVICRM_UF !== 'UnitTests') { + $testNames = implode(', ', array_keys($byInterface['HeadlessInterface'])); + throw new \RuntimeException("Suite includes headless tests ($testNames) which require CIVICRM_UF=UnitTests.\n\n$blurb"); + } + if (!empty($byInterface['EndToEndInterface']) && CIVICRM_UF === 'UnitTests') { + $testNames = implode(', ', array_keys($byInterface['EndToEndInterface'])); + throw new \RuntimeException("Suite includes end-to-end tests ($testNames) which do not support CIVICRM_UF=UnitTests.\n\n$blurb"); + } + } + + /** + * Call the "cv" command. + * + * This duplicates the standalone `cv()` wrapper that is recommended in bootstrap.php. + * This duplication is necessary because `cv()` is optional, and downstream implementers + * may alter, rename, or omit the wrapper, and (by virtue of its role in bootstrap) there + * it is impossible to define it centrally. + * + * @param string $cmd + * The rest of the command to send. + * @param string $decode + * Ex: 'json' or 'phpcode'. + * @return string + * Response output (if the command executed normally). + * @throws \RuntimeException + * If the command terminates abnormally. + */ + protected function cv($cmd, $decode = 'json') { + $cmd = 'cv ' . $cmd; + $descriptorSpec = array(0 => array("pipe", "r"), 1 => array("pipe", "w"), 2 => STDERR); + $oldOutput = getenv('CV_OUTPUT'); + putenv("CV_OUTPUT=json"); + $process = proc_open($cmd, $descriptorSpec, $pipes, __DIR__); + putenv("CV_OUTPUT=$oldOutput"); + fclose($pipes[0]); + $result = stream_get_contents($pipes[1]); + fclose($pipes[1]); + if (proc_close($process) !== 0) { + throw new \RuntimeException("Command failed ($cmd):\n$result"); + } + switch ($decode) { + case 'raw': + return $result; + + case 'phpcode': + // If the last output is /*PHPCODE*/, then we managed to complete execution. + if (substr(trim($result), 0, 12) !== "/*BEGINPHP*/" || substr(trim($result), -10) !== "/*ENDPHP*/") { + throw new \RuntimeException("Command failed ($cmd):\n$result"); + } + return $result; + + case 'json': + return json_decode($result, 1); + + default: + throw new \RuntimeException("Bad decoder format ($decode)"); + } + } + + /** + * @param $tests + * @return array + */ + protected function indexTestsByInterface($tests) { + $byInterface = array('HeadlessInterface' => array(), 'EndToEndInterface' => array()); + foreach ($tests as $test) { + /** @var \PHPUnit_Framework_Test $test */ + if ($test instanceof HeadlessInterface) { + $byInterface['HeadlessInterface'][get_class($test)] = 1; + } + if ($test instanceof EndToEndInterface) { + $byInterface['EndToEndInterface'][get_class($test)] = 1; + } + } + return $byInterface; + } + + /** + * Ensure that any tests have sensible groups, e.g. + * + * `HeadlessInterface` ==> `group headless` + * `EndToEndInterface` ==> `group e2e` + * + * @param array $byInterface + */ + protected function validateGroups($byInterface) { + foreach ($byInterface['HeadlessInterface'] as $className => $nonce) { + $clazz = new \ReflectionClass($className); + $docComment = str_replace("\r\n", "\n", $clazz->getDocComment()); + if (strpos($docComment, "@group headless\n") === FALSE) { + echo "WARNING: Class $className implements HeadlessInterface. It should declare \"@group headless\".\n"; + } + if (strpos($docComment, "@group e2e\n") !== FALSE) { + echo "WARNING: Class $className implements HeadlessInterface. It should not declare \"@group e2e\".\n"; + } + } + foreach ($byInterface['EndToEndInterface'] as $className => $nonce) { + $clazz = new \ReflectionClass($className); + $docComment = str_replace("\r\n", "\n", $clazz->getDocComment()); + if (strpos($docComment, "@group e2e\n") === FALSE) { + echo "WARNING: Class $className implements EndToEndInterface. It should declare \"@group e2e\".\n"; + } + if (strpos($docComment, "@group headless\n") !== FALSE) { + echo "WARNING: Class $className implements EndToEndInterface. It should not declare \"@group headless\".\n"; + } + } + } + +} diff --git a/Civi/Test/EndToEndInterface.php b/Civi/Test/EndToEndInterface.php new file mode 100644 index 0000000000..af5ea0a322 --- /dev/null +++ b/Civi/Test/EndToEndInterface.php @@ -0,0 +1,18 @@ +./api + + + + + + + diff --git a/tests/phpunit/Civi/Test/ExampleHookTest.php b/tests/phpunit/Civi/Test/ExampleHookTest.php new file mode 100644 index 0000000000..0d17eed68d --- /dev/null +++ b/tests/phpunit/Civi/Test/ExampleHookTest.php @@ -0,0 +1,50 @@ +apply(); + } + + protected function setUp() { + $this->contact = \CRM_Core_DAO::createTestObject('CRM_Contact_DAO_Contact', array( + 'contact_type' => 'Individual', + )); + $session = \CRM_Core_Session::singleton(); + $session->set('userID', $this->contact->id); + } + + protected function tearDown() { + $this->contact->delete(); + } + + /** + * @see \CRM_Utils_Hook::alterContent + */ + public function hook_civicrm_alterContent(&$content, $context, $tplName, &$object) { + $content .= "zzzyyyxxx"; + } + + public function testPageOutput() { + ob_start(); + $p = new Main(); + $p->run(); + $content = ob_get_contents(); + ob_end_clean(); + $this->assertRegExp(';zzzyyyxxx;', $content); + } + +} diff --git a/tests/phpunit/Civi/Test/ExampleTransactionalTest.php b/tests/phpunit/Civi/Test/ExampleTransactionalTest.php new file mode 100644 index 0000000000..dba1299381 --- /dev/null +++ b/tests/phpunit/Civi/Test/ExampleTransactionalTest.php @@ -0,0 +1,94 @@ +apply(); + } + + protected function setUp() { + /** @var \CRM_Contact_DAO_Contact $contact */ + $contact = \CRM_Core_DAO::createTestObject('CRM_Contact_DAO_Contact', array( + 'contact_type' => 'Individual', + )); + self::$contactIds[$this->getName()] = $contact->id; + } + + /** + * In the first test, we make make testDummy1. He exists. + */ + public function testDummy1() { + $this->assertTrue(is_numeric(self::$contactIds['testDummy1']) && self::$contactIds['testDummy1'] > 0); + + // Still inside transaction. Data exists. + $dao = new \CRM_Contact_DAO_Contact(); + $dao->id = self::$contactIds['testDummy1']; + $this->assertTrue((bool) $dao->find()); + } + + /** + * We previously made testDummy1, but he's been lost (rolled-back). + * However, testDummy2 now exists. + */ + public function testDummy2() { + $this->assertTrue(is_numeric(self::$contactIds['testDummy1']) && self::$contactIds['testDummy1'] > 0); + $this->assertTrue(is_numeric(self::$contactIds['testDummy2']) && self::$contactIds['testDummy2'] > 0); + + // Previous contact no longer exists + $dao = new \CRM_Contact_DAO_Contact(); + $dao->id = self::$contactIds['testDummy1']; + $this->assertFalse((bool) $dao->find()); + + // Still inside transaction. Data exists. + $dao = new \CRM_Contact_DAO_Contact(); + $dao->id = self::$contactIds['testDummy2']; + $this->assertTrue((bool) $dao->find()); + } + + public function tearDown() { + } + + /** + * Both testDummy1 and testDummy2 have been created at some point (as part of the test runs), + * but all the data was rolled-back + * + * @throws \Exception + */ + public static function tearDownAfterClass() { + if (!is_numeric(self::$contactIds['testDummy1'])) { + throw new \Exception("Uh oh! The static \$contactIds does not include testDummy1! Did the test fail to execute?"); + } + + if (!is_numeric(self::$contactIds['testDummy2'])) { + throw new \Exception("Uh oh! The static \$contactIds does not include testDummy2! Did the test fail to execute?"); + } + + $dao = new \CRM_Contact_DAO_Contact(); + $dao->id = self::$contactIds['testDummy1']; + if ($dao->find()) { + throw new \Exception("Uh oh! testDummy1 still exists!"); + } + + $dao = new \CRM_Contact_DAO_Contact(); + $dao->id = self::$contactIds['testDummy2']; + if ($dao->find()) { + throw new \Exception("Uh oh! testDummy2 still exists!"); + } + } + +} -- 2.25.1