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