CRM-17860 - Civi\Test - Add mixins for Headless, Hook, Transaction, and EndToEnd...
authorTim Otten <totten@civicrm.org>
Tue, 9 Feb 2016 02:58:22 +0000 (18:58 -0800)
committerTim Otten <totten@civicrm.org>
Mon, 15 Feb 2016 22:23:50 +0000 (14:23 -0800)
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 [new file with mode: 0644]
Civi/Test/EndToEndInterface.php [new file with mode: 0644]
Civi/Test/HeadlessInterface.php [new file with mode: 0644]
Civi/Test/HookInterface.php [new file with mode: 0644]
Civi/Test/TransactionalInterface.php [new file with mode: 0644]
phpunit.xml.dist
tests/phpunit/Civi/Test/ExampleHookTest.php [new file with mode: 0644]
tests/phpunit/Civi/Test/ExampleTransactionalTest.php [new file with mode: 0644]

diff --git a/Civi/Test/CiviTestListener.php b/Civi/Test/CiviTestListener.php
new file mode 100644 (file)
index 0000000..8dff35b
--- /dev/null
@@ -0,0 +1,290 @@
+<?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";
+      }
+    }
+  }
+
+}
diff --git a/Civi/Test/EndToEndInterface.php b/Civi/Test/EndToEndInterface.php
new file mode 100644 (file)
index 0000000..af5ea0a
--- /dev/null
@@ -0,0 +1,18 @@
+<?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 {
+
+}
diff --git a/Civi/Test/HeadlessInterface.php b/Civi/Test/HeadlessInterface.php
new file mode 100644 (file)
index 0000000..fb1cbf4
--- /dev/null
@@ -0,0 +1,38 @@
+<?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();
+
+}
diff --git a/Civi/Test/HookInterface.php b/Civi/Test/HookInterface.php
new file mode 100644 (file)
index 0000000..53d5c8c
--- /dev/null
@@ -0,0 +1,30 @@
+<?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 {
+}
diff --git a/Civi/Test/TransactionalInterface.php b/Civi/Test/TransactionalInterface.php
new file mode 100644 (file)
index 0000000..f79e8db
--- /dev/null
@@ -0,0 +1,19 @@
+<?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 {
+
+}
index b2f5700bec42969c96eece092b85416fe0352ed6..0da4639ae815ae1f400686703253e4da65ec01ae 100644 (file)
       <directory suffix=".php">./api</directory>
     </whitelist>
   </filter>
+
+  <listeners>
+    <listener class="Civi\Test\CiviTestListener">
+      <arguments></arguments>
+    </listener>
+  </listeners>
+
 </phpunit>
 
diff --git a/tests/phpunit/Civi/Test/ExampleHookTest.php b/tests/phpunit/Civi/Test/ExampleHookTest.php
new file mode 100644 (file)
index 0000000..0d17eed
--- /dev/null
@@ -0,0 +1,50 @@
+<?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);
+  }
+
+}
diff --git a/tests/phpunit/Civi/Test/ExampleTransactionalTest.php b/tests/phpunit/Civi/Test/ExampleTransactionalTest.php
new file mode 100644 (file)
index 0000000..dba1299
--- /dev/null
@@ -0,0 +1,94 @@
+<?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!");
+    }
+  }
+
+}