Commit 09e1f1e3 authored by totten's avatar totten

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.
parent db47db96
<?php
namespace Civi\Test;
/**
* Class CiviTestListener
* @package Civi\Test
*
* CiviTestListener participates in test-execution, looking for test-classes
* which have certain tags. If the tags are found, the listener will perform
* additional setup/teardown logic.
*
* @see EndToEndInterface
* @see HeadlessInterface
* @see HookInterface
*/
class CiviTestListener extends \PHPUnit_Framework_BaseTestListener {
/**
* @var \CRM_Core_TemporaryErrorScope
*/
private $errorScope;
/**
* @var array
* Ex: $cache['Some_Test_Class']['civicrm_foobar'] = 'hook_civicrm_foobar';
* Array(string $testClass => 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";
}
}
}
}
<?php
namespace Civi\Test;
/**
* Interface EndToEndInterface
* @package Civi\Test
*
* To run your test against a live, CMS-integrated database, flag it with the the
* EndToEndInterface.
*
* Alternatively, if you wish to run a test in a live (CMS-enabled) environment,
* flag it with EndToEndInterface.
*
* @see HeadlessInterface
*/
interface EndToEndInterface {
}
<?php
namespace Civi\Test;
/**
* Interface HeadlessInterface
* @package Civi\Test
*
* To run your test against a fake, headless database, flag it with the
* HeadlessInterface. CiviTestListener will automatically boot
*
* Alternatively, if you wish to run a test in a live (CMS-enabled) environment,
* flag it with EndToEndInterface.
*
* You may mix-in additional features for headless tests:
* - HookInterface: Auto-register any functions named "hook_civicrm_foo()".
* - TransactionalInterface: Wrap all work in a transaction, and rollback at the end.
*
* @see EndToEndInterface
* @see HookInterface
* @see TransactionalInterface
*/
interface HeadlessInterface {
/**
* The setupHeadless functions runs at the start of each test case.
* It should perform any necessary steps required for putting the database
* in a consistent baseline -- such as loading schema and extensions.
*
* The utility class `CiviTester` provides a number of helper functions
* for managing this setup, and it includes optimizations to avoid redundant
* setup work.
*
* @see CiviTester
*/
public function setUpHeadless();
}
<?php
namespace Civi\Test;
/**
* Interface HookInterface
* @package Civi\Test
*
* This interface allows you to subscribe to hooks as part of the test.
* Simply create an eponymous hook function (e.g. `hook_civicrm_post()`).
*
* @code
* class MyTest extends \PHPUnit_Framework_TestCase implements \Civi\Test\HookInterface {
* public function hook_civicrm_post($op, $objectName, $objectId, &$objectRef) {
* echo "Running hook_civicrm_post\n";
* }
* }
* @endCode
*
* At time of writing, there are a few limitations in how HookInterface is handled
* by CiviTestListener:
*
* - The test must execute in-process (aka HeadlessInterface; aka CIVICRM_UF==UnitTests).
* End-to-end tests (multi-process tests) are not supported.
* - Early bootstrap hooks (e.g. hook_civicrm_config) are not supported.
*
* @see CiviTestListener
*/
interface HookInterface {
}
<?php
namespace Civi\Test;
/**
* Interface HeadlessInterface
* @package Civi\Test
*
* Mark a test with TransactionalInterface to instruct CiviTestListener to wrap
* each test in a transaction (and rollback).
*
* Note: At time of writing, CiviTestListener only supports using TransactionalInterface if
* the test is in-process and runs with CIVICRM_UF==UnitTests.
*
* @see HeadlessInterface
*/
interface TransactionalInterface {
}
......@@ -32,5 +32,12 @@
<directory suffix=".php">./api</directory>
</whitelist>
</filter>
<listeners>
<listener class="Civi\Test\CiviTestListener">
<arguments></arguments>
</listener>
</listeners>
</phpunit>
<?php
namespace Civi\Test;
use Civi\Angular\Page\Main;
/**
* This is an example of a barebones test which uses a hook (based on CiviTestListener).
*
* @group headless
*/
class ExampleHookTest extends \PHPUnit_Framework_TestCase implements HeadlessInterface, HookInterface {
/**
* @var \CRM_Contact_DAO_Contact
*/
protected $contact;
public function setUpHeadless() {
return \CiviTester::headless()->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);
}
}
<?php
namespace Civi\Test;
/**
* This is an example of a barebones test which uses a transaction (based on CiviTestListener).
*
* We check that the transaction works by creating some example records in setUp(). These
* records should fetchable while the test executes, but not during tearDownAfterClass().
*
* @group headless
*/
class ExampleTransactionalTest extends \PHPUnit_Framework_TestCase implements HeadlessInterface, TransactionalInterface {
/**
* @var array
* Array(int $id).
*/
static $contactIds = array();
public function setUpHeadless() {
return \CiviTester::headless()->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!");
}
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment