diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0214c7b8a7e..b8258af644b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -119,6 +119,11 @@ jobs: - "8.3" - "8.4" + include: + - os: ubuntu-latest + php-version: "8.3" + add-ext: ", pcntl" + steps: - name: Configure Git to avoid issues with line endings if: matrix.os == 'windows-latest' @@ -131,7 +136,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: ${{ env.PHP_EXTENSIONS }} + extensions: "${{ env.PHP_EXTENSIONS }}${{ matrix.add-ext }}" ini-values: ${{ env.PHP_INI_VALUES }} tools: none @@ -166,6 +171,11 @@ jobs: - "8.3" - "8.4" + include: + - os: ubuntu-latest + php-version: "8.3" + add-ext: ", pcntl" + steps: - name: Configure Git to avoid issues with line endings if: matrix.os == 'windows-latest' @@ -178,7 +188,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: ${{ env.PHP_EXTENSIONS }} + extensions: "${{ env.PHP_EXTENSIONS }}${{ matrix.add-ext }}" ini-values: ${{ env.PHP_INI_VALUES }} coverage: pcov tools: none diff --git a/src/Framework/Attributes/RunClassInSeparateProcess.php b/src/Framework/Attributes/RunClassInSeparateProcess.php index 1ccd3bc6fb2..d15069068d5 100644 --- a/src/Framework/Attributes/RunClassInSeparateProcess.php +++ b/src/Framework/Attributes/RunClassInSeparateProcess.php @@ -19,4 +19,15 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class RunClassInSeparateProcess { + private ?bool $forkIfPossible; + + public function __construct(?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } } diff --git a/src/Framework/Attributes/RunInSeparateProcess.php b/src/Framework/Attributes/RunInSeparateProcess.php index 838e267e2e4..d10cbba3efd 100644 --- a/src/Framework/Attributes/RunInSeparateProcess.php +++ b/src/Framework/Attributes/RunInSeparateProcess.php @@ -19,4 +19,15 @@ #[Attribute(Attribute::TARGET_METHOD)] final readonly class RunInSeparateProcess { + private ?bool $forkIfPossible; + + public function __construct(?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } } diff --git a/src/Framework/Attributes/RunTestsInSeparateProcesses.php b/src/Framework/Attributes/RunTestsInSeparateProcesses.php index 19fea88239a..ed804fa2a70 100644 --- a/src/Framework/Attributes/RunTestsInSeparateProcesses.php +++ b/src/Framework/Attributes/RunTestsInSeparateProcesses.php @@ -19,4 +19,15 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class RunTestsInSeparateProcesses { + private ?bool $forkIfPossible; + + public function __construct(?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } } diff --git a/src/Framework/TestBuilder.php b/src/Framework/TestBuilder.php index e2e63150e42..1c89966af8d 100644 --- a/src/Framework/TestBuilder.php +++ b/src/Framework/TestBuilder.php @@ -19,6 +19,9 @@ use PHPUnit\Metadata\ExcludeStaticPropertyFromBackup; use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; use PHPUnit\Metadata\PreserveGlobalState; +use PHPUnit\Metadata\RunClassInSeparateProcess; +use PHPUnit\Metadata\RunInSeparateProcess; +use PHPUnit\Metadata\RunTestsInSeparateProcesses; use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use ReflectionClass; @@ -51,6 +54,7 @@ public function build(ReflectionClass $theClass, string $methodName, array $grou $this->shouldTestMethodBeRunInSeparateProcess($className, $methodName), $this->shouldGlobalStateBePreserved($className, $methodName), $this->shouldAllTestMethodsOfTestClassBeRunInSingleSeparateProcess($className), + $this->shouldForkIfPossible($className, $methodName), $this->backupSettings($className, $methodName), $groups, ); @@ -64,6 +68,7 @@ public function build(ReflectionClass $theClass, string $methodName, array $grou $this->shouldTestMethodBeRunInSeparateProcess($className, $methodName), $this->shouldGlobalStateBePreserved($className, $methodName), $this->shouldAllTestMethodsOfTestClassBeRunInSingleSeparateProcess($className), + $this->shouldForkIfPossible($className, $methodName), $this->backupSettings($className, $methodName), ); @@ -76,7 +81,7 @@ public function build(ReflectionClass $theClass, string $methodName, array $grou * @psalm-param array{backupGlobals: ?bool, backupGlobalsExcludeList: list, backupStaticProperties: ?bool, backupStaticPropertiesExcludeList: array>} $backupSettings * @psalm-param list $groups */ - private function buildDataProviderTestSuite(string $methodName, string $className, array $data, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, array $backupSettings, array $groups): DataProviderTestSuite + private function buildDataProviderTestSuite(string $methodName, string $className, array $data, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, bool $forkIfPossible, array $backupSettings, array $groups): DataProviderTestSuite { $dataProviderTestSuite = DataProviderTestSuite::empty( $className . '::' . $methodName, @@ -98,6 +103,7 @@ private function buildDataProviderTestSuite(string $methodName, string $classNam $runTestInSeparateProcess, $preserveGlobalState, $runClassInSeparateProcess, + $forkIfPossible, $backupSettings, ); @@ -110,7 +116,7 @@ private function buildDataProviderTestSuite(string $methodName, string $classNam /** * @psalm-param array{backupGlobals: ?bool, backupGlobalsExcludeList: list, backupStaticProperties: ?bool, backupStaticPropertiesExcludeList: array>} $backupSettings */ - private function configureTestCase(TestCase $test, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, array $backupSettings): void + private function configureTestCase(TestCase $test, bool $runTestInSeparateProcess, ?bool $preserveGlobalState, bool $runClassInSeparateProcess, bool $forkIfPossible, array $backupSettings): void { if ($runTestInSeparateProcess) { $test->setRunTestInSeparateProcess(true); @@ -120,6 +126,10 @@ private function configureTestCase(TestCase $test, bool $runTestInSeparateProces $test->setRunClassInSeparateProcess(true); } + if ($forkIfPossible) { + $test->setForkIfPossible(true); + } + if ($preserveGlobalState !== null) { $test->setPreserveGlobalState($preserveGlobalState); } @@ -272,4 +282,53 @@ private function shouldAllTestMethodsOfTestClassBeRunInSingleSeparateProcess(str { return MetadataRegistry::parser()->forClass($className)->isRunClassInSeparateProcess()->isNotEmpty(); } + + /** + * @psalm-param class-string $className + * @psalm-param non-empty-string $methodName + */ + private function shouldForkIfPossible(string $className, string $methodName): bool + { + $metadataForMethod = MetadataRegistry::parser()->forMethod($className, $methodName); + + if ($metadataForMethod->isRunInSeparateProcess()->isNotEmpty()) { + $metadata = $metadataForMethod->isRunInSeparateProcess()->asArray()[0]; + + assert($metadata instanceof RunInSeparateProcess); + + $forkIfPossible = $metadata->forkIfPossible(); + + if ($forkIfPossible !== null) { + return $forkIfPossible; + } + } + + $metadataForClass = MetadataRegistry::parser()->forClass($className); + + if ($metadataForClass->isRunTestsInSeparateProcesses()->isNotEmpty()) { + $metadata = $metadataForClass->isRunTestsInSeparateProcesses()->asArray()[0]; + + assert($metadata instanceof RunTestsInSeparateProcesses); + + $forkIfPossible = $metadata->forkIfPossible(); + + if ($forkIfPossible !== null) { + return $forkIfPossible; + } + } + + if ($metadataForClass->isRunClassInSeparateProcess()->isNotEmpty()) { + $metadata = $metadataForClass->isRunClassInSeparateProcess()->asArray()[0]; + + assert($metadata instanceof RunClassInSeparateProcess); + + $forkIfPossible = $metadata->forkIfPossible(); + + if ($forkIfPossible !== null) { + return $forkIfPossible; + } + } + + return false; + } } diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index 5f6a1a9f6b2..eade3a9d83c 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -142,6 +142,7 @@ abstract class TestCase extends Assert implements Reorderable, SelfDescribing, T */ private ?array $backupGlobalExceptionHandlers = null; private ?bool $runClassInSeparateProcess = null; + private ?bool $forkIfPossible = null; private ?bool $runTestInSeparateProcess = null; private bool $preserveGlobalState = false; private bool $inIsolation = false; @@ -340,6 +341,7 @@ final public function run(): void $this, $this->runClassInSeparateProcess && !$this->runTestInSeparateProcess, $this->preserveGlobalState, + $this->forkIfPossible === true, ); } } @@ -709,6 +711,14 @@ final public function setRunClassInSeparateProcess(bool $runClassInSeparateProce $this->runClassInSeparateProcess = $runClassInSeparateProcess; } + /** + * @internal This method is not covered by the backward compatibility promise for PHPUnit + */ + final public function setForkIfPossible(bool $forkIfPossible): void + { + $this->forkIfPossible = $forkIfPossible; + } + /** * @internal This method is not covered by the backward compatibility promise for PHPUnit */ diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner.php index e41a44bfcb6..b4c69eb4849 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner.php @@ -33,6 +33,7 @@ use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use PHPUnit\Util\GlobalState; use PHPUnit\Util\PHP\AbstractPhpProcess; +use PHPUnit\Util\PHP\PcntlFork; use ReflectionClass; use SebastianBergmann\CodeCoverage\Exception as OriginalCodeCoverageException; use SebastianBergmann\CodeCoverage\InvalidArgumentException; @@ -248,8 +249,17 @@ public function run(TestCase $test): void * @throws ProcessIsolationException * @throws StaticAnalysisCacheNotConfiguredException */ - public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void + public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState, bool $forkIfPossible): void { + if ($forkIfPossible && PcntlFork::isPcntlForkAvailable()) { + // forking the parent process is a more lightweight way to run a test in isolation. + // it requires the pcntl extension though. + $fork = new PcntlFork; + $fork->runTest($test); + + return; + } + $class = new ReflectionClass($test); if ($runEntireClass) { diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index d9a787b731c..ce1296974c3 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -390,19 +390,19 @@ public static function requiresSettingOnMethod(string $setting, string $value): return new RequiresSetting(self::METHOD_LEVEL, $setting, $value); } - public static function runClassInSeparateProcess(): RunClassInSeparateProcess + public static function runClassInSeparateProcess(?bool $forkIfPossible = null): RunClassInSeparateProcess { - return new RunClassInSeparateProcess(self::CLASS_LEVEL); + return new RunClassInSeparateProcess(self::CLASS_LEVEL, $forkIfPossible); } - public static function runTestsInSeparateProcesses(): RunTestsInSeparateProcesses + public static function runTestsInSeparateProcesses(?bool $forkIfPossible = null): RunTestsInSeparateProcesses { - return new RunTestsInSeparateProcesses(self::CLASS_LEVEL); + return new RunTestsInSeparateProcesses(self::CLASS_LEVEL, $forkIfPossible); } - public static function runInSeparateProcess(): RunInSeparateProcess + public static function runInSeparateProcess(?bool $forkIfPossible = null): RunInSeparateProcess { - return new RunInSeparateProcess(self::METHOD_LEVEL); + return new RunInSeparateProcess(self::METHOD_LEVEL, $forkIfPossible); } public static function test(): Test diff --git a/src/Metadata/Parser/AttributeParser.php b/src/Metadata/Parser/AttributeParser.php index 257c38e3282..8b19fa387c5 100644 --- a/src/Metadata/Parser/AttributeParser.php +++ b/src/Metadata/Parser/AttributeParser.php @@ -297,12 +297,20 @@ public function forClass(string $className): MetadataCollection break; case RunClassInSeparateProcess::class: - $result[] = Metadata::runClassInSeparateProcess(); + assert($attributeInstance instanceof RunClassInSeparateProcess); + + $result[] = Metadata::runClassInSeparateProcess( + $attributeInstance->forkIfPossible(), + ); break; case RunTestsInSeparateProcesses::class: - $result[] = Metadata::runTestsInSeparateProcesses(); + assert($attributeInstance instanceof RunTestsInSeparateProcesses); + + $result[] = Metadata::runTestsInSeparateProcesses( + $attributeInstance->forkIfPossible(), + ); break; @@ -638,7 +646,11 @@ public function forMethod(string $className, string $methodName): MetadataCollec break; case RunInSeparateProcess::class: - $result[] = Metadata::runInSeparateProcess(); + assert($attributeInstance instanceof RunInSeparateProcess); + + $result[] = Metadata::runInSeparateProcess( + $attributeInstance->forkIfPossible(), + ); break; diff --git a/src/Metadata/RunClassInSeparateProcess.php b/src/Metadata/RunClassInSeparateProcess.php index 907a45d4524..a5b6aa7f829 100644 --- a/src/Metadata/RunClassInSeparateProcess.php +++ b/src/Metadata/RunClassInSeparateProcess.php @@ -16,6 +16,23 @@ */ final readonly class RunClassInSeparateProcess extends Metadata { + private ?bool $forkIfPossible; + + /** + * @psalm-param 0|1 $level + */ + protected function __construct(int $level, ?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + + parent::__construct($level); + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } + /** * @psalm-assert-if-true RunClassInSeparateProcess $this */ diff --git a/src/Metadata/RunInSeparateProcess.php b/src/Metadata/RunInSeparateProcess.php index 07febae09de..9b5cc237b01 100644 --- a/src/Metadata/RunInSeparateProcess.php +++ b/src/Metadata/RunInSeparateProcess.php @@ -16,6 +16,23 @@ */ final readonly class RunInSeparateProcess extends Metadata { + private ?bool $forkIfPossible; + + /** + * @psalm-param 0|1 $level + */ + protected function __construct(int $level, ?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + + parent::__construct($level); + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } + /** * @psalm-assert-if-true RunInSeparateProcess $this */ diff --git a/src/Metadata/RunTestsInSeparateProcesses.php b/src/Metadata/RunTestsInSeparateProcesses.php index b71233d3dbe..5a8275d293f 100644 --- a/src/Metadata/RunTestsInSeparateProcesses.php +++ b/src/Metadata/RunTestsInSeparateProcesses.php @@ -16,6 +16,23 @@ */ final readonly class RunTestsInSeparateProcesses extends Metadata { + private ?bool $forkIfPossible; + + /** + * @psalm-param 0|1 $level + */ + protected function __construct(int $level, ?bool $forkIfPossible = null) + { + $this->forkIfPossible = $forkIfPossible; + + parent::__construct($level); + } + + public function forkIfPossible(): ?bool + { + return $this->forkIfPossible; + } + /** * @psalm-assert-if-true RunTestsInSeparateProcesses $this */ diff --git a/src/Util/PHP/AbstractPhpProcess.php b/src/Util/PHP/AbstractPhpProcess.php index 7d76d73c4c9..787943e6af8 100644 --- a/src/Util/PHP/AbstractPhpProcess.php +++ b/src/Util/PHP/AbstractPhpProcess.php @@ -230,7 +230,7 @@ protected function settingsToParameters(array $settings): array * @throws Exception * @throws NoPreviousThrowableException */ - private function processChildResult(Test $test, string $stdout, string $stderr): void + public function processChildResult(Test $test, string $stdout, string $stderr): void { if (!empty($stderr)) { $exception = new Exception(trim($stderr)); diff --git a/src/Util/PHP/PcntlFork.php b/src/Util/PHP/PcntlFork.php new file mode 100644 index 00000000000..41f8887d85b --- /dev/null +++ b/src/Util/PHP/PcntlFork.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Util\PHP; + +use function array_key_exists; +use function array_values; +use function function_exists; +use function hrtime; +use function ini_get; +use function is_array; +use function pack; +use function pcntl_fork; +use function serialize; +use function socket_close; +use function socket_create_pair; +use function socket_last_error; +use function socket_read; +use function socket_strerror; +use function socket_write; +use function str_contains; +use function strlen; +use function strtoupper; +use function substr; +use function unpack; +use function unserialize; +use Exception; +use PHPUnit\Event\Facade; +use PHPUnit\Event\Telemetry\HRTime; +use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestRunner\TestResult\PassedTests; +use RuntimeException; + +final class PcntlFork +{ + // IPC inspired from https://github.com/barracudanetworks/forkdaemon-php + private const SOCKET_HEADER_SIZE = 4; + + public static function isPcntlForkAvailable(): bool + { + $disabledFunctions = ini_get('disable_functions'); + + return + function_exists('pcntl_fork') && + !str_contains($disabledFunctions, 'pcntl') && + function_exists('socket_create_pair') && + !str_contains($disabledFunctions, 'socket'); + } + + public function runTest(TestCase $test): void + { + [$socket_child, $socket_parent] = $this->ipcInit(); + + $pid = pcntl_fork(); + + if ($pid === -1) { + throw new Exception('could not fork'); + } + + if ($pid) { + // we are the parent + + socket_close($socket_parent); + + // read child stdout, stderr + $result = $this->socketReceive($socket_child); + + $stderr = ''; + $stdout = ''; + + if (is_array($result) && array_key_exists('error', $result)) { + $stderr = $result['error']; + } else { + $stdout = $result; + } + + $php = AbstractPhpProcess::factory(); + $php->processChildResult($test, $stdout, $stderr); + + } else { + // we are the child + + socket_close($socket_child); + + $offset = hrtime(); + $dispatcher = Facade::instance()->initForIsolation( + HRTime::fromSecondsAndNanoseconds( + $offset[0], + $offset[1], + ), + ); + + $test->setInIsolation(true); + + try { + $test->run(); + } catch (Throwable $e) { + $this->socketSend($socket_parent, ['error' => $e->getMessage()]); + + exit(); + } + + $result = serialize( + [ + 'testResult' => $test->result(), + 'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null, + 'numAssertions' => $test->numberOfAssertionsPerformed(), + 'output' => !$test->expectsOutput() ? $test->output() : '', + 'events' => $dispatcher->flush(), + 'passedTests' => PassedTests::instance(), + ], + ); + + // send result into parent + $this->socketSend($socket_parent, $result); + + exit(); + } + } + + private function ipcInit(): array + { + // windows needs AF_INET + $domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX; + + // create a socket pair for IPC + $sockets = []; + + if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false) { + throw new RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error())); + } + + return $sockets; + } + + /** + * @param resource $socket + */ + private function socketReceive($socket): mixed + { + // initially read to the length of the header size, then + // expand to read more + $bytes_total = self::SOCKET_HEADER_SIZE; + $bytes_read = 0; + $have_header = false; + $socket_message = ''; + + while ($bytes_read < $bytes_total) { + $read = @socket_read($socket, $bytes_total - $bytes_read); + + if ($read === false) { + throw new RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error())); + } + + // blank socket_read means done + if ($read == '') { + break; + } + + $bytes_read += strlen($read); + $socket_message .= $read; + + if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE) { + $have_header = true; + [$bytes_total] = array_values(unpack('N', $socket_message)); + $bytes_read = 0; + $socket_message = ''; + } + } + + return @unserialize($socket_message); + } + + /** + * @param resource $socket + * @param mixed $message + */ + private function socketSend($socket, $message): void + { + $serialized_message = @serialize($message); + + if ($serialized_message == false) { + throw new RuntimeException('socket_send failed to serialize message'); + } + + $header = pack('N', strlen($serialized_message)); + $data = $header . $serialized_message; + $bytes_left = strlen($data); + + while ($bytes_left > 0) { + $bytes_sent = @socket_write($socket, $data); + + if ($bytes_sent === false) { + throw new RuntimeException('socket_send failed to write to socket'); + } + + $bytes_left -= $bytes_sent; + $data = substr($data, $bytes_sent); + } + } +} diff --git a/tests/_files/Metadata/Attribute/tests/ProcessIsolationForkedTest.php b/tests/_files/Metadata/Attribute/tests/ProcessIsolationForkedTest.php new file mode 100644 index 00000000000..3278fa91c7e --- /dev/null +++ b/tests/_files/Metadata/Attribute/tests/ProcessIsolationForkedTest.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Metadata\Attribute; + +use PHPUnit\Framework\Attributes\RunClassInSeparateProcess; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\TestCase; + +#[RunClassInSeparateProcess(true)] +#[RunTestsInSeparateProcesses] +final class ProcessIsolationForkedTest extends TestCase +{ + #[RunInSeparateProcess] + public function testOne(): void + { + } +} diff --git a/tests/_files/TestWithClassLevelIsolationAttributesForked.php b/tests/_files/TestWithClassLevelIsolationAttributesForked.php new file mode 100644 index 00000000000..4b363c3e690 --- /dev/null +++ b/tests/_files/TestWithClassLevelIsolationAttributesForked.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\TestBuilder; + +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\BackupStaticProperties; +use PHPUnit\Framework\Attributes\RunClassInSeparateProcess; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\TestCase; + +#[BackupGlobals(true)] +#[BackupStaticProperties(true)] +#[RunClassInSeparateProcess] +#[RunTestsInSeparateProcesses(true)] +final class TestWithClassLevelIsolationAttributesForked extends TestCase +{ + public function testOne(): void + { + } +} diff --git a/tests/_files/TestWithMethodLevelIsolationAttributesForked.php b/tests/_files/TestWithMethodLevelIsolationAttributesForked.php new file mode 100644 index 00000000000..8a5221cfd87 --- /dev/null +++ b/tests/_files/TestWithMethodLevelIsolationAttributesForked.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\TestBuilder; + +use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\BackupStaticProperties; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\TestCase; + +final class TestWithMethodLevelIsolationAttributes extends TestCase +{ + #[BackupGlobals(true)] + #[BackupStaticProperties(true)] + #[RunInSeparateProcess(true)] + public function testOne(): void + { + } +}