diff --git a/ChangeLog-7.1.md b/ChangeLog-7.1.md
index 2682cae76bb..07853de2361 100644
--- a/ChangeLog-7.1.md
+++ b/ChangeLog-7.1.md
@@ -4,6 +4,10 @@ All notable changes of the PHPUnit 7.1 release series are documented in this fil
## [7.1.0] - 2018-04-06
+### Added
+
+* Implemented [#3002](https://github.com/sebastianbergmann/phpunit/issues/3002): Support for test runner extensions
+
### Changed
* `PHPUnit\Framework\Assert` is no longer searched for test methods
diff --git a/phpunit.xsd b/phpunit.xsd
index 3d54573c50d..81690e13cd1 100644
--- a/phpunit.xsd
+++ b/phpunit.xsd
@@ -50,6 +50,11 @@
+
+
+
+
+
@@ -248,6 +253,7 @@
+
diff --git a/src/Runner/Hook/AfterIncompleteTestHook.php b/src/Runner/Hook/AfterIncompleteTestHook.php
new file mode 100644
index 00000000000..1b352d30c66
--- /dev/null
+++ b/src/Runner/Hook/AfterIncompleteTestHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterIncompleteTestHook extends TestHook
+{
+ public function executeAfterIncompleteTest(string $test, string $message, float $time): void;
+}
diff --git a/src/Runner/Hook/AfterLastTestHook.php b/src/Runner/Hook/AfterLastTestHook.php
new file mode 100644
index 00000000000..b5d2e2efd6c
--- /dev/null
+++ b/src/Runner/Hook/AfterLastTestHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterLastTestHook extends Hook
+{
+ public function executeAfterLastTest(): void;
+}
diff --git a/src/Runner/Hook/AfterRiskyTestHook.php b/src/Runner/Hook/AfterRiskyTestHook.php
new file mode 100644
index 00000000000..0a7cb980a85
--- /dev/null
+++ b/src/Runner/Hook/AfterRiskyTestHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterRiskyTestHook extends TestHook
+{
+ public function executeAfterRiskyTest(string $test, string $message, float $time): void;
+}
diff --git a/src/Runner/Hook/AfterSkippedTestHook.php b/src/Runner/Hook/AfterSkippedTestHook.php
new file mode 100644
index 00000000000..460c81d9ee9
--- /dev/null
+++ b/src/Runner/Hook/AfterSkippedTestHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterSkippedTestHook extends TestHook
+{
+ public function executeAfterSkippedTest(string $test, string $message, float $time): void;
+}
diff --git a/src/Runner/Hook/AfterSuccessfulTestHook.php b/src/Runner/Hook/AfterSuccessfulTestHook.php
new file mode 100644
index 00000000000..175f463dbc5
--- /dev/null
+++ b/src/Runner/Hook/AfterSuccessfulTestHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterSuccessfulTestHook extends TestHook
+{
+ public function executeAfterSuccessfulTest(string $test, float $time): void;
+}
diff --git a/src/Runner/Hook/AfterTestErrorHook.php b/src/Runner/Hook/AfterTestErrorHook.php
new file mode 100644
index 00000000000..ff2ba0ed84b
--- /dev/null
+++ b/src/Runner/Hook/AfterTestErrorHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterTestErrorHook extends TestHook
+{
+ public function executeAfterTestError(string $test, string $message, float $time): void;
+}
diff --git a/src/Runner/Hook/AfterTestFailureHook.php b/src/Runner/Hook/AfterTestFailureHook.php
new file mode 100644
index 00000000000..351667e5275
--- /dev/null
+++ b/src/Runner/Hook/AfterTestFailureHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterTestFailureHook extends TestHook
+{
+ public function executeAfterTestFailure(string $test, string $message, float $time): void;
+}
diff --git a/src/Runner/Hook/AfterTestWarningHook.php b/src/Runner/Hook/AfterTestWarningHook.php
new file mode 100644
index 00000000000..19ec90875a5
--- /dev/null
+++ b/src/Runner/Hook/AfterTestWarningHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface AfterTestWarningHook extends TestHook
+{
+ public function executeAfterTestWarning(string $test, string $message, float $time): void;
+}
diff --git a/src/Runner/Hook/BeforeFirstTestHook.php b/src/Runner/Hook/BeforeFirstTestHook.php
new file mode 100644
index 00000000000..99c4dbd2bde
--- /dev/null
+++ b/src/Runner/Hook/BeforeFirstTestHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface BeforeFirstTestHook extends Hook
+{
+ public function executeBeforeFirstTest(): void;
+}
diff --git a/src/Runner/Hook/BeforeTestHook.php b/src/Runner/Hook/BeforeTestHook.php
new file mode 100644
index 00000000000..a0383445bb7
--- /dev/null
+++ b/src/Runner/Hook/BeforeTestHook.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface BeforeTestHook extends TestHook
+{
+ public function executeBeforeTest(string $test): void;
+}
diff --git a/src/Runner/Hook/Hook.php b/src/Runner/Hook/Hook.php
new file mode 100644
index 00000000000..2eeaf5b59fe
--- /dev/null
+++ b/src/Runner/Hook/Hook.php
@@ -0,0 +1,15 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface Hook
+{
+}
diff --git a/src/Runner/Hook/TestHook.php b/src/Runner/Hook/TestHook.php
new file mode 100644
index 00000000000..02d7fc6c08a
--- /dev/null
+++ b/src/Runner/Hook/TestHook.php
@@ -0,0 +1,15 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+interface TestHook extends Hook
+{
+}
diff --git a/src/Runner/Hook/TestListenerAdapter.php b/src/Runner/Hook/TestListenerAdapter.php
new file mode 100644
index 00000000000..5be8298fd27
--- /dev/null
+++ b/src/Runner/Hook/TestListenerAdapter.php
@@ -0,0 +1,134 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace PHPUnit\Runner;
+
+use PHPUnit\Framework\AssertionFailedError;
+use PHPUnit\Framework\Test;
+use PHPUnit\Framework\TestListener;
+use PHPUnit\Framework\TestSuite;
+use PHPUnit\Framework\Warning;
+use PHPUnit\Util\Test as TestUtil;
+
+final class TestListenerAdapter implements TestListener
+{
+ /**
+ * @var TestHook[]
+ */
+ private $hooks = [];
+
+ /**
+ * @var bool
+ */
+ private $lastTestWasNotSuccessful;
+
+ public function add(TestHook $hook): void
+ {
+ $this->hooks[] = $hook;
+ }
+
+ public function startTest(Test $test): void
+ {
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof BeforeTestHook) {
+ $hook->executeBeforeTest(TestUtil::describeAsString($test));
+ }
+ }
+
+ $this->lastTestWasNotSuccessful = false;
+ }
+
+ public function addError(Test $test, \Throwable $t, float $time): void
+ {
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof AfterTestErrorHook) {
+ $hook->executeAfterTestError(TestUtil::describeAsString($test), $t->getMessage(), $time);
+ }
+ }
+
+ $this->lastTestWasNotSuccessful = true;
+ }
+
+ public function addWarning(Test $test, Warning $e, float $time): void
+ {
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof AfterTestWarningHook) {
+ $hook->executeAfterTestWarning(TestUtil::describeAsString($test), $e->getMessage(), $time);
+ }
+ }
+
+ $this->lastTestWasNotSuccessful = true;
+ }
+
+ public function addFailure(Test $test, AssertionFailedError $e, float $time): void
+ {
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof AfterTestFailureHook) {
+ $hook->executeAfterTestFailure(TestUtil::describeAsString($test), $e->getMessage(), $time);
+ }
+ }
+
+ $this->lastTestWasNotSuccessful = true;
+ }
+
+ public function addIncompleteTest(Test $test, \Throwable $t, float $time): void
+ {
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof AfterIncompleteTestHook) {
+ $hook->executeAfterIncompleteTest(TestUtil::describeAsString($test), $t->getMessage(), $time);
+ }
+ }
+
+ $this->lastTestWasNotSuccessful = true;
+ }
+
+ public function addRiskyTest(Test $test, \Throwable $t, float $time): void
+ {
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof AfterRiskyTestHook) {
+ $hook->executeAfterRiskyTest(TestUtil::describeAsString($test), $t->getMessage(), $time);
+ }
+ }
+
+ $this->lastTestWasNotSuccessful = true;
+ }
+
+ public function addSkippedTest(Test $test, \Throwable $t, float $time): void
+ {
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof AfterSkippedTestHook) {
+ $hook->executeAfterSkippedTest(TestUtil::describeAsString($test), $t->getMessage(), $time);
+ }
+ }
+
+ $this->lastTestWasNotSuccessful = true;
+ }
+
+ public function endTest(Test $test, float $time): void
+ {
+ if ($this->lastTestWasNotSuccessful === true) {
+ return;
+ }
+
+ foreach ($this->hooks as $hook) {
+ if ($hook instanceof AfterSuccessfulTestHook) {
+ $hook->executeAfterSuccessfulTest(TestUtil::describeAsString($test), $time);
+ }
+ }
+ }
+
+ public function startTestSuite(TestSuite $suite): void
+ {
+ }
+
+ public function endTestSuite(TestSuite $suite): void
+ {
+ }
+}
diff --git a/src/TextUI/TestRunner.php b/src/TextUI/TestRunner.php
index cb49778a7da..6d2fe31dd7e 100644
--- a/src/TextUI/TestRunner.php
+++ b/src/TextUI/TestRunner.php
@@ -18,12 +18,17 @@
use PHPUnit\Framework\TestListener;
use PHPUnit\Framework\TestResult;
use PHPUnit\Framework\TestSuite;
+use PHPUnit\Runner\AfterLastTestHook;
use PHPUnit\Runner\BaseTestRunner;
+use PHPUnit\Runner\BeforeFirstTestHook;
use PHPUnit\Runner\Filter\ExcludeGroupFilterIterator;
use PHPUnit\Runner\Filter\Factory;
use PHPUnit\Runner\Filter\IncludeGroupFilterIterator;
use PHPUnit\Runner\Filter\NameFilterIterator;
+use PHPUnit\Runner\Hook;
use PHPUnit\Runner\StandardTestSuiteLoader;
+use PHPUnit\Runner\TestHook;
+use PHPUnit\Runner\TestListenerAdapter;
use PHPUnit\Runner\TestSuiteLoader;
use PHPUnit\Runner\Version;
use PHPUnit\Util\Configuration;
@@ -86,6 +91,11 @@ class TestRunner extends BaseTestRunner
*/
private $messagePrinted = false;
+ /**
+ * @var Hook[]
+ */
+ private $extensions = [];
+
/**
* @param TestSuiteLoader $loader
* @param CodeCoverageFilter $filter
@@ -183,6 +193,23 @@ public function doRun(Test $suite, array $arguments = [], $exit = true): TestRes
$result = $this->createTestResult();
+ $listener = new TestListenerAdapter;
+ $listenerNeeded = false;
+
+ foreach ($this->extensions as $extension) {
+ if ($extension instanceof TestHook) {
+ $listener->add($extension);
+
+ $listenerNeeded = true;
+ }
+ }
+
+ if ($listenerNeeded) {
+ $result->addListener($listener);
+ }
+
+ unset($listener, $listenerNeeded);
+
if (!$arguments['convertErrorsToExceptions']) {
$result->convertErrorsToExceptions(false);
}
@@ -505,8 +532,20 @@ public function doRun(Test $suite, array $arguments = [], $exit = true): TestRes
$suite->setRunTestInSeparateProcess($arguments['processIsolation']);
}
+ foreach ($this->extensions as $extension) {
+ if ($extension instanceof BeforeFirstTestHook) {
+ $extension->executeBeforeFirstTest();
+ }
+ }
+
$suite->run($result);
+ foreach ($this->extensions as $extension) {
+ if ($extension instanceof AfterLastTestHook) {
+ $extension->executeAfterLastTest();
+ }
+ }
+
$result->flushListeners();
if ($this->printer instanceof ResultPrinter) {
@@ -892,6 +931,34 @@ protected function handleConfiguration(array &$arguments): void
$arguments['excludeGroups'] = \array_diff($groupConfiguration['exclude'], $groupCliArgs);
}
+ foreach ($arguments['configuration']->getExtensionConfiguration() as $extension) {
+ if (!\class_exists($extension['class'], false) && $extension['file'] !== '') {
+ require_once $extension['file'];
+ }
+
+ if (!\class_exists($extension['class'])) {
+ throw new Exception(
+ \sprintf(
+ 'Class "%s" does not exist',
+ $extension['class']
+ )
+ );
+ }
+
+ $extensionClass = new ReflectionClass($extension['class']);
+
+ if (!$extensionClass->implementsInterface(Hook::class)) {
+ throw new Exception(
+ \sprintf(
+ 'Class "%s" does not implement a PHPUnit\Runner\Hook interface',
+ $extension['class']
+ )
+ );
+ }
+
+ $this->extensions[] = $extensionClass->newInstance();
+ }
+
foreach ($arguments['configuration']->getListenerConfiguration() as $listener) {
if (!\class_exists($listener['class'], false) &&
$listener['file'] !== '') {
diff --git a/src/Util/Configuration.php b/src/Util/Configuration.php
index c1dcdd6433d..b4b741997de 100644
--- a/src/Util/Configuration.php
+++ b/src/Util/Configuration.php
@@ -231,6 +231,31 @@ public function getFilename(): string
return $this->filename;
}
+ public function getExtensionConfiguration(): array
+ {
+ $result = [];
+
+ foreach ($this->xpath->query('extensions/extension') as $extension) {
+ /** @var DOMElement $extension */
+ $class = (string) $extension->getAttribute('class');
+ $file = '';
+
+ if ($extension->getAttribute('file')) {
+ $file = $this->toAbsolutePath(
+ (string) $extension->getAttribute('file'),
+ true
+ );
+ }
+
+ $result[] = [
+ 'class' => $class,
+ 'file' => $file
+ ];
+ }
+
+ return $result;
+ }
+
/**
* Returns the configuration for SUT filtering.
*
diff --git a/tests/TextUI/_files/Extension.php b/tests/TextUI/_files/Extension.php
new file mode 100644
index 00000000000..e7b0066ae83
--- /dev/null
+++ b/tests/TextUI/_files/Extension.php
@@ -0,0 +1,66 @@
+assertTrue(true);
+ }
+
+ public function testFailure(): void
+ {
+ $this->assertTrue(false);
+ }
+
+ public function testError(): void
+ {
+ throw new \Exception('message');
+ }
+
+ public function testIncomplete(): void
+ {
+ $this->markTestIncomplete('message');
+ }
+
+ public function testRisky(): void
+ {
+ throw new RiskyTestError('message');
+ }
+
+ public function testSkipped(): void
+ {
+ $this->markTestSkipped('message');
+ }
+
+ public function testWarning(): void
+ {
+ throw new Warning('message');
+ }
+}
diff --git a/tests/TextUI/_files/NullPrinter.php b/tests/TextUI/_files/NullPrinter.php
new file mode 100644
index 00000000000..3d298ff83fc
--- /dev/null
+++ b/tests/TextUI/_files/NullPrinter.php
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/tests/TextUI/hooks.phpt b/tests/TextUI/hooks.phpt
new file mode 100644
index 00000000000..778416426f8
--- /dev/null
+++ b/tests/TextUI/hooks.phpt
@@ -0,0 +1,30 @@
+--TEST--
+phpunit --configuration _files/hooks.xml HookTest _files/HookTest.php
+--FILE--
+