diff --git a/phpunit.xml b/phpunit.xml
index 456f32ff34..19cf3a5ab6 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -29,6 +29,7 @@
tests/end-to-end/mock-objects
tests/end-to-end/phpt
tests/end-to-end/regression
+ tests/end-to-end/sandbox
tests/end-to-end/self-direct-indirect
tests/end-to-end/testdox
diff --git a/src/Framework/TestRunner/templates/class.tpl b/src/Framework/TestRunner/templates/class.tpl
index d73716fe6e..6705c6ab57 100644
--- a/src/Framework/TestRunner/templates/class.tpl
+++ b/src/Framework/TestRunner/templates/class.tpl
@@ -1,7 +1,10 @@
initForIsolation(
PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds(
@@ -68,52 +71,67 @@ function __phpunit_run_isolated_test()
ErrorHandler::instance()->useDeprecationTriggers($deprecationTriggers);
- $test = new {className}('{name}');
+ ini_set('xdebug.scream', '0');
- $test->setData('{dataName}', unserialize('{data}'));
- $test->setDependencyInput(unserialize('{dependencyInput}'));
- $test->setInIsolation(true);
+ try {
+ $testClass = (new TestSuiteLoader)->load('{filename}');
+ } catch (Exception $e) {
+ print $e->getMessage() . PHP_EOL;
+ exit(1);
+ }
- ob_end_clean();
+ $output = '';
+ $results = [];
- $test->run();
+ $suite = TestSuite::fromClassReflector($testClass);
+ $suite->setIsInSeparatedProcess(false);
- $output = '';
+ $testSuiteValueObjectForEvents = Event\TestSuite\TestSuiteBuilder::from($suite);
- if (!$test->expectsOutput()) {
- $output = $test->output();
+ if (!$suite->invokeMethodsBeforeFirstTest(Facade::emitter(), $testSuiteValueObjectForEvents)) {
+ return;
}
- ini_set('xdebug.scream', '0');
+ foreach($suite->tests() as $test) {
+ $test->setRunClassInSeparateProcess(false);
+ $test->run();
+
+ $testOutput = '';
- // Not every STDOUT target stream is rewindable
- @rewind(STDOUT);
+ if (!$test->expectsOutput()) {
+ $testOutput = $test->output();
+ }
- if ($stdout = @stream_get_contents(STDOUT)) {
- $output = $stdout . $output;
- $streamMetaData = stream_get_meta_data(STDOUT);
+ // Not every STDOUT target stream is rewindable
+ @rewind(STDOUT);
- if (!empty($streamMetaData['stream_type']) && 'STDIO' === $streamMetaData['stream_type']) {
- @ftruncate(STDOUT, 0);
- @rewind(STDOUT);
+ if ($stdout = @stream_get_contents(STDOUT)) {
+ $testOutput = $stdout . $testOutput;
+ $streamMetaData = stream_get_meta_data(STDOUT);
+
+ if (!empty($streamMetaData['stream_type']) && 'STDIO' === $streamMetaData['stream_type']) {
+ @ftruncate(STDOUT, 0);
+ @rewind(STDOUT);
+ }
}
+
+ $results[] = (object)[
+ 'testResult' => $test->result(),
+ 'codeCoverage' => {collectCodeCoverageInformation} ? CodeCoverage::instance()->codeCoverage() : null,
+ 'numAssertions' => $test->numberOfAssertionsPerformed(),
+ 'output' => $testOutput,
+ 'events' => $dispatcher->flush(),
+ 'passedTests' => PassedTests::instance()
+ ];
+
+ $output .= $testOutput;
}
+ $suite->invokeMethodsAfterLastTest(Facade::emitter());
+
Facade::emitter()->testRunnerFinishedChildProcess($output, '');
- file_put_contents(
- '{processResultFile}',
- serialize(
- (object)[
- 'testResult' => $test->result(),
- 'codeCoverage' => {collectCodeCoverageInformation} ? CodeCoverage::instance()->codeCoverage() : null,
- 'numAssertions' => $test->numberOfAssertionsPerformed(),
- 'output' => $output,
- 'events' => $dispatcher->flush(),
- 'passedTests' => PassedTests::instance()
- ]
- )
- );
+ file_put_contents('{processResultFile}', serialize($results));
}
function __phpunit_error_handler($errno, $errstr, $errfile, $errline)
@@ -136,4 +154,4 @@ if ('{bootstrap}' !== '') {
require_once '{bootstrap}';
}
-__phpunit_run_isolated_test();
+__phpunit_run_isolated_class();
diff --git a/src/Framework/TestSuite.php b/src/Framework/TestSuite.php
index beaa0b64d1..725a5ff317 100644
--- a/src/Framework/TestSuite.php
+++ b/src/Framework/TestSuite.php
@@ -35,6 +35,7 @@
use PHPUnit\Metadata\Api\HookMethods;
use PHPUnit\Metadata\Api\Requirements;
use PHPUnit\Metadata\MetadataCollection;
+use PHPUnit\Metadata\Parser\Registry as MetadataRegistry;
use PHPUnit\Runner\Exception as RunnerException;
use PHPUnit\Runner\Filter\Factory;
use PHPUnit\Runner\PhptTestCase;
@@ -81,9 +82,11 @@ class TestSuite implements IteratorAggregate, Reorderable, Test
/**
* @var ?list
*/
- private ?array $providedTests = null;
- private ?Factory $iteratorFilter = null;
- private bool $wasRun = false;
+ private ?array $providedTests = null;
+ private ?Factory $iteratorFilter = null;
+ private bool $wasRun = false;
+ private bool $isInSeparatedProcess = false;
+ private bool $isTestsInSeparatedProcess = false;
/**
* @param non-empty-string $name
@@ -118,6 +121,10 @@ public static function fromClassReflector(ReflectionClass $class, array $groups
);
}
+ $registry = MetadataRegistry::parser()->forClass($class->name);
+ $testSuite->isTestsInSeparatedProcess = $registry->isRunTestsInSeparateProcesses()->isNotEmpty();
+ $testSuite->isInSeparatedProcess = $registry->isRunClassInSeparateProcess()->isNotEmpty() || $testSuite->isTestsInSeparatedProcess;
+
return $testSuite;
}
@@ -316,6 +323,16 @@ public function collect(): array
return $tests;
}
+ public function isInSeparatedProcess(): bool
+ {
+ return $this->isInSeparatedProcess;
+ }
+
+ public function setIsInSeparatedProcess(bool $isInSeparatedProcess): void
+ {
+ $this->isInSeparatedProcess = $isInSeparatedProcess;
+ }
+
/**
* @throws CodeCoverageException
* @throws Event\RuntimeException
@@ -367,6 +384,20 @@ public function run(): void
}
$test->run();
+
+ // When all tests are run in a separated process, the primary process loads
+ // all the test methods. After executing the first test, TestRunner spawns
+ // a separated process which loads all the tests again.
+ // Skip primary process tests expect the first which initiates
+ // the separated process TestSuite.
+ if ($this->isInSeparatedProcess && !$this->isTestsInSeparatedProcess) {
+ // TestSuite statuses are returned from the separated process.
+ // Skipped and incomplete tests should continue processing, otherwise
+ // only a single test result is outputted to the console.
+ if ($test->status()->isUnknown()) {
+ break;
+ }
+ }
}
$this->invokeMethodsAfterLastTest($emitter);
@@ -571,7 +602,7 @@ private function methodDoesNotExistOrIsDeclaredInTestCase(string $methodName): b
$reflector = new ReflectionClass($this->name);
return !$reflector->hasMethod($methodName) ||
- $reflector->getMethod($methodName)->getDeclaringClass()->getName() === TestCase::class;
+ $reflector->getMethod($methodName)->getDeclaringClass()->getName() === TestCase::class;
}
/**
@@ -605,9 +636,9 @@ private function throwableToString(Throwable $t): string
* @throws Exception
* @throws NoPreviousThrowableException
*/
- private function invokeMethodsBeforeFirstTest(Event\Emitter $emitter, Event\TestSuite\TestSuite $testSuiteValueObjectForEvents): bool
+ public function invokeMethodsBeforeFirstTest(Event\Emitter $emitter, Event\TestSuite\TestSuite $testSuiteValueObjectForEvents): bool
{
- if (!$this->isForTestClass()) {
+ if (!$this->isForTestClass() || $this->isInSeparatedProcess) {
return true;
}
@@ -678,9 +709,9 @@ private function invokeMethodsBeforeFirstTest(Event\Emitter $emitter, Event\Test
return $result;
}
- private function invokeMethodsAfterLastTest(Event\Emitter $emitter): void
+ public function invokeMethodsAfterLastTest(Event\Emitter $emitter): void
{
- if (!$this->isForTestClass()) {
+ if (!$this->isForTestClass() || $this->isInSeparatedProcess) {
return;
}
diff --git a/tests/end-to-end/sandbox/_files/ClassIsolationBeforeAndAfterClassMethodCallCountTest.php b/tests/end-to-end/sandbox/_files/ClassIsolationBeforeAndAfterClassMethodCallCountTest.php
new file mode 100644
index 0000000000..40c4b80de9
--- /dev/null
+++ b/tests/end-to-end/sandbox/_files/ClassIsolationBeforeAndAfterClassMethodCallCountTest.php
@@ -0,0 +1,46 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace PHPUnit\TestFixture;
+
+use function file_get_contents;
+use function file_put_contents;
+use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
+use PHPUnit\Framework\TestCase;
+
+#[RunClassInSeparateProcess]
+final class ClassIsolationBeforeAndAfterClassMethodCallCountTest extends TestCase
+{
+ public const string BEFORE_CALL_COUNT_FILE_PATH = __DIR__ . '/temp/class_before_method_call_count.txt';
+ public const string AFTER_CALL_COUNT_FILE_PATH = __DIR__ . '/temp/class_after_method_call_count.txt';
+
+ public static function setUpBeforeClass(): void
+ {
+ $count = (int) (file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH));
+ file_put_contents(self::BEFORE_CALL_COUNT_FILE_PATH, ++$count);
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ $count = (int) (file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH));
+ file_put_contents(self::AFTER_CALL_COUNT_FILE_PATH, ++$count);
+ }
+
+ public function testBeforeAndAfterClassMethodCallCount1(): void
+ {
+ $this->assertEquals('1', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('0', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+
+ public function testBeforeAndAfterClassMethodCallCount2(): void
+ {
+ $this->assertEquals('1', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('0', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+}
diff --git a/tests/end-to-end/sandbox/_files/MethodIsolationBeforeAndAfterClassMethodCallCountTest.php b/tests/end-to-end/sandbox/_files/MethodIsolationBeforeAndAfterClassMethodCallCountTest.php
new file mode 100644
index 0000000000..90f150770e
--- /dev/null
+++ b/tests/end-to-end/sandbox/_files/MethodIsolationBeforeAndAfterClassMethodCallCountTest.php
@@ -0,0 +1,64 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace PHPUnit\TestFixture;
+
+use function file_get_contents;
+use function file_put_contents;
+use PHPUnit\Framework\Attributes\Depends;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
+use PHPUnit\Framework\TestCase;
+
+final class MethodIsolationBeforeAndAfterClassMethodCallCountTest extends TestCase
+{
+ public const string BEFORE_CALL_COUNT_FILE_PATH = __DIR__ . '/temp/method_before_method_call_count.txt';
+ public const string AFTER_CALL_COUNT_FILE_PATH = __DIR__ . '/temp/method_after_method_call_count.txt';
+
+ public static function setUpBeforeClass(): void
+ {
+ $count = (int) (file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH));
+ file_put_contents(self::BEFORE_CALL_COUNT_FILE_PATH, ++$count);
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ $count = (int) (file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH));
+ file_put_contents(self::AFTER_CALL_COUNT_FILE_PATH, ++$count);
+ }
+
+ #[RunInSeparateProcess]
+ public function testBeforeAndAfterClassMethodCallCount1(): void
+ {
+ // TODO: Due source code design, before methods for primary process are always called first. Should be 1
+ $this->assertEquals('2', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('0', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+
+ #[Depends('testBeforeAndAfterClassMethodCallCount1')]
+ public function testBeforeAndAfterClassMethodCallCount2(): void
+ {
+ $this->assertEquals('2', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('1', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+
+ #[RunInSeparateProcess]
+ #[Depends('testBeforeAndAfterClassMethodCallCount2')]
+ public function testBeforeAndAfterClassMethodCallCount3(): void
+ {
+ $this->assertEquals('3', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('1', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+
+ #[Depends('testBeforeAndAfterClassMethodCallCount3')]
+ public function testBeforeAndAfterClassMethodCallCount4(): void
+ {
+ $this->assertEquals('3', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('2', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+}
diff --git a/tests/end-to-end/sandbox/_files/TestsIsolationBeforeAndAfterClassMethodCallCountTest.php b/tests/end-to-end/sandbox/_files/TestsIsolationBeforeAndAfterClassMethodCallCountTest.php
new file mode 100644
index 0000000000..be6f6699d9
--- /dev/null
+++ b/tests/end-to-end/sandbox/_files/TestsIsolationBeforeAndAfterClassMethodCallCountTest.php
@@ -0,0 +1,52 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace PHPUnit\TestFixture;
+
+use function file_get_contents;
+use function file_put_contents;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
+use PHPUnit\Framework\TestCase;
+
+#[RunTestsInSeparateProcesses]
+final class TestsIsolationBeforeAndAfterClassMethodCallCountTest extends TestCase
+{
+ public const string BEFORE_CALL_COUNT_FILE_PATH = __DIR__ . '/temp/tests_before_method_call_count.txt';
+ public const string AFTER_CALL_COUNT_FILE_PATH = __DIR__ . '/temp/tests_after_method_call_count.txt';
+
+ public static function setUpBeforeClass(): void
+ {
+ $count = (int) (file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH));
+ file_put_contents(self::BEFORE_CALL_COUNT_FILE_PATH, ++$count);
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ $count = (int) (file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH));
+ file_put_contents(self::AFTER_CALL_COUNT_FILE_PATH, ++$count);
+ }
+
+ public function testBeforeAndAfterClassMethodCallCount1(): void
+ {
+ $this->assertEquals('1', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('0', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+
+ public function testBeforeAndAfterClassMethodCallCount2(): void
+ {
+ $this->assertEquals('2', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('1', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+
+ public function testBeforeAndAfterClassMethodCallCount3(): void
+ {
+ $this->assertEquals('3', file_get_contents(self::BEFORE_CALL_COUNT_FILE_PATH), 'before_method_call_count');
+ $this->assertEquals('2', file_get_contents(self::AFTER_CALL_COUNT_FILE_PATH), 'after_method_call_count');
+ }
+}
diff --git a/tests/end-to-end/sandbox/class-isolation-before-and-after-class-method-call-count.phpt b/tests/end-to-end/sandbox/class-isolation-before-and-after-class-method-call-count.phpt
new file mode 100644
index 0000000000..c2b8b9d390
--- /dev/null
+++ b/tests/end-to-end/sandbox/class-isolation-before-and-after-class-method-call-count.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Before and after class methods must not be called from primary process when test class or method is run in separated process.
+--FILE--
+run($_SERVER['argv']);
+
+if (\intval(\file_get_contents(__DIR__ . '/_files/temp/class_after_method_call_count.txt')) !== 1){
+ throw new \Exception('Invalid after class method call count!');
+}
+--EXPECTF--
+PHPUnit %s by Sebastian Bergmann and contributors.
+
+Runtime: %s
+
+.. 2 / 2 (100%)
+
+Time: %s, Memory: %s
+
+OK (2 tests, 4 assertions)
diff --git a/tests/end-to-end/sandbox/method-isolation-before-and-after-class-method-call-count.phpt b/tests/end-to-end/sandbox/method-isolation-before-and-after-class-method-call-count.phpt
new file mode 100644
index 0000000000..5ffa788fda
--- /dev/null
+++ b/tests/end-to-end/sandbox/method-isolation-before-and-after-class-method-call-count.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Before and after class methods must not be called from primary process when test class or method is run in separated process.
+--FILE--
+run($_SERVER['argv']);
+
+if (\intval(\file_get_contents(__DIR__ . '/_files/temp/method_before_method_call_count.txt')) !== 3){
+ throw new \Exception('Invalid before class method call count!');
+}
+
+if (\intval(\file_get_contents(__DIR__ . '/_files/temp/method_after_method_call_count.txt')) !== 3){
+ throw new \Exception('Invalid after class method call count!');
+}
+--EXPECTF--
+PHPUnit %s by Sebastian Bergmann and contributors.
+
+Runtime: %s
+
+.... 4 / 4 (100%)
+
+Time: %s, Memory: %s
+
+OK (4 tests, 8 assertions)
diff --git a/tests/end-to-end/sandbox/tests-isolation-before-and-after-class-method-call-count.phpt b/tests/end-to-end/sandbox/tests-isolation-before-and-after-class-method-call-count.phpt
new file mode 100644
index 0000000000..c4eb7ffccb
--- /dev/null
+++ b/tests/end-to-end/sandbox/tests-isolation-before-and-after-class-method-call-count.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Before and after class methods must not be called from primary process when test class or method is run in separated process.
+--FILE--
+run($_SERVER['argv']);
+
+if (\intval(\file_get_contents(__DIR__ . '/_files/temp/tests_after_method_call_count.txt')) !== 3){
+ throw new \Exception('Invalid after class method call count!');
+}
+--EXPECTF--
+PHPUnit %s by Sebastian Bergmann and contributors.
+
+Runtime: %s
+
+... 3 / 3 (100%)
+
+Time: %s, Memory: %s
+
+OK (3 tests, 6 assertions)
\ No newline at end of file