Skip to content

Commit 6342542

Browse files
Initial work on swappable implementation for running tests in isolation
1 parent e41231b commit 6342542

File tree

10 files changed

+357
-280
lines changed

10 files changed

+357
-280
lines changed

.psalm/baseline.xml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -378,18 +378,20 @@
378378
<code><![CDATA[$groups]]></code>
379379
</PropertyTypeCoercion>
380380
</file>
381-
<file src="src/Framework/TestRunner.php">
382-
<ArgumentTypeCoercion>
383-
<code><![CDATA[$cce->getMessage()]]></code>
384-
<code><![CDATA[$test->output()]]></code>
385-
</ArgumentTypeCoercion>
381+
<file src="src/Framework/TestRunner/SeparateProcessTestRunner.php">
386382
<InvalidArgument>
387383
<code><![CDATA[$var]]></code>
388384
</InvalidArgument>
389385
<MissingThrowsDocblock>
390386
<code><![CDATA[bootstrap]]></code>
391387
</MissingThrowsDocblock>
392388
</file>
389+
<file src="src/Framework/TestRunner/TestRunner.php">
390+
<ArgumentTypeCoercion>
391+
<code><![CDATA[$cce->getMessage()]]></code>
392+
<code><![CDATA[$test->output()]]></code>
393+
</ArgumentTypeCoercion>
394+
</file>
393395
<file src="src/Framework/TestSuite.php">
394396
<ArgumentTypeCoercion>
395397
<code><![CDATA[$this->name]]></code>
@@ -656,7 +658,7 @@
656658
<code><![CDATA[TestIdFilterIterator]]></code>
657659
</MissingTemplateParam>
658660
</file>
659-
<file src="src/Runner/PhptTestCase.php">
661+
<file src="src/Runner/PHPT/PhptTestCase.php">
660662
<ArgumentTypeCoercion>
661663
<code><![CDATA[$arguments]]></code>
662664
<code><![CDATA[$message]]></code>

src/Framework/TestCase.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -347,13 +347,15 @@ final public function run(): void
347347

348348
if (!$this->shouldRunInSeparateProcess()) {
349349
(new TestRunner)->run($this);
350-
} else {
351-
(new TestRunner)->runInSeparateProcess(
352-
$this,
353-
$this->runClassInSeparateProcess && !$this->runTestInSeparateProcess,
354-
$this->preserveGlobalState,
355-
);
350+
351+
return;
356352
}
353+
354+
IsolatedTestRunnerRegistry::run(
355+
$this,
356+
$this->runClassInSeparateProcess && !$this->runTestInSeparateProcess,
357+
$this->preserveGlobalState,
358+
);
357359
}
358360

359361
/**
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php declare(strict_types=1);
2+
/*
3+
* This file is part of PHPUnit.
4+
*
5+
* (c) Sebastian Bergmann <[email protected]>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
namespace PHPUnit\Framework;
11+
12+
/**
13+
* @internal This interface is not covered by the backward compatibility promise for PHPUnit
14+
*/
15+
interface IsolatedTestRunner
16+
{
17+
public function run(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void;
18+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php declare(strict_types=1);
2+
/*
3+
* This file is part of PHPUnit.
4+
*
5+
* (c) Sebastian Bergmann <[email protected]>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
namespace PHPUnit\Framework;
11+
12+
/**
13+
* @internal This class is not covered by the backward compatibility promise for PHPUnit
14+
*/
15+
final class IsolatedTestRunnerRegistry
16+
{
17+
private static ?IsolatedTestRunner $runner = null;
18+
19+
public static function run(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
20+
{
21+
if (self::$runner === null) {
22+
self::$runner = new SeparateProcessTestRunner;
23+
}
24+
25+
self::$runner->run($test, $runEntireClass, $preserveGlobalState);
26+
}
27+
28+
public static function set(IsolatedTestRunner $runner): void
29+
{
30+
self::$runner = $runner;
31+
}
32+
}
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
<?php declare(strict_types=1);
2+
/*
3+
* This file is part of PHPUnit.
4+
*
5+
* (c) Sebastian Bergmann <[email protected]>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
namespace PHPUnit\Framework;
11+
12+
use function assert;
13+
use function defined;
14+
use function file_exists;
15+
use function file_get_contents;
16+
use function get_include_path;
17+
use function hrtime;
18+
use function restore_error_handler;
19+
use function serialize;
20+
use function set_error_handler;
21+
use function sys_get_temp_dir;
22+
use function tempnam;
23+
use function trim;
24+
use function unlink;
25+
use function unserialize;
26+
use function var_export;
27+
use ErrorException;
28+
use PHPUnit\Event\Code\TestMethodBuilder;
29+
use PHPUnit\Event\Code\ThrowableBuilder;
30+
use PHPUnit\Event\Facade;
31+
use PHPUnit\Event\NoPreviousThrowableException;
32+
use PHPUnit\Runner\CodeCoverage;
33+
use PHPUnit\TestRunner\TestResult\PassedTests;
34+
use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry;
35+
use PHPUnit\Util\GlobalState;
36+
use PHPUnit\Util\PHP\Job;
37+
use PHPUnit\Util\PHP\JobRunnerRegistry;
38+
use PHPUnit\Util\PHP\PhpProcessException;
39+
use ReflectionClass;
40+
use SebastianBergmann\CodeCoverage\StaticAnalysisCacheNotConfiguredException;
41+
use SebastianBergmann\Template\InvalidArgumentException;
42+
use SebastianBergmann\Template\Template;
43+
44+
/**
45+
* @internal This interface is not covered by the backward compatibility promise for PHPUnit
46+
*/
47+
final class SeparateProcessTestRunner implements IsolatedTestRunner
48+
{
49+
/**
50+
* @throws \PHPUnit\Runner\Exception
51+
* @throws \PHPUnit\Util\Exception
52+
* @throws Exception
53+
* @throws InvalidArgumentException
54+
* @throws NoPreviousThrowableException
55+
* @throws ProcessIsolationException
56+
* @throws StaticAnalysisCacheNotConfiguredException
57+
*/
58+
public function run(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
59+
{
60+
$class = new ReflectionClass($test);
61+
62+
if ($runEntireClass) {
63+
$template = new Template(
64+
__DIR__ . '/templates/class.tpl',
65+
);
66+
} else {
67+
$template = new Template(
68+
__DIR__ . '/templates/method.tpl',
69+
);
70+
}
71+
72+
$bootstrap = '';
73+
$constants = '';
74+
$globals = '';
75+
$includedFiles = '';
76+
$iniSettings = '';
77+
78+
if (ConfigurationRegistry::get()->hasBootstrap()) {
79+
$bootstrap = ConfigurationRegistry::get()->bootstrap();
80+
}
81+
82+
if ($preserveGlobalState) {
83+
$constants = GlobalState::getConstantsAsString();
84+
$globals = GlobalState::getGlobalsAsString();
85+
$includedFiles = GlobalState::getIncludedFilesAsString();
86+
$iniSettings = GlobalState::getIniSettingsAsString();
87+
}
88+
89+
$coverage = CodeCoverage::instance()->isActive() ? 'true' : 'false';
90+
$linesToBeIgnored = var_export(CodeCoverage::instance()->linesToBeIgnored(), true);
91+
92+
if (defined('PHPUNIT_COMPOSER_INSTALL')) {
93+
$composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true);
94+
} else {
95+
$composerAutoload = '\'\'';
96+
}
97+
98+
if (defined('__PHPUNIT_PHAR__')) {
99+
$phar = var_export(__PHPUNIT_PHAR__, true);
100+
} else {
101+
$phar = '\'\'';
102+
}
103+
104+
$data = var_export(serialize($test->providedData()), true);
105+
$dataName = var_export($test->dataName(), true);
106+
$dependencyInput = var_export(serialize($test->dependencyInput()), true);
107+
$includePath = var_export(get_include_path(), true);
108+
// must do these fixes because TestCaseMethod.tpl has unserialize('{data}') in it, and we can't break BC
109+
// the lines above used to use addcslashes() rather than var_export(), which breaks null byte escape sequences
110+
$data = "'." . $data . ".'";
111+
$dataName = "'.(" . $dataName . ").'";
112+
$dependencyInput = "'." . $dependencyInput . ".'";
113+
$includePath = "'." . $includePath . ".'";
114+
$offset = hrtime();
115+
$serializedConfiguration = $this->saveConfigurationForChildProcess();
116+
$processResultFile = tempnam(sys_get_temp_dir(), 'phpunit_');
117+
118+
$var = [
119+
'bootstrap' => $bootstrap,
120+
'composerAutoload' => $composerAutoload,
121+
'phar' => $phar,
122+
'filename' => $class->getFileName(),
123+
'className' => $class->getName(),
124+
'collectCodeCoverageInformation' => $coverage,
125+
'linesToBeIgnored' => $linesToBeIgnored,
126+
'data' => $data,
127+
'dataName' => $dataName,
128+
'dependencyInput' => $dependencyInput,
129+
'constants' => $constants,
130+
'globals' => $globals,
131+
'include_path' => $includePath,
132+
'included_files' => $includedFiles,
133+
'iniSettings' => $iniSettings,
134+
'name' => $test->name(),
135+
'offsetSeconds' => $offset[0],
136+
'offsetNanoseconds' => $offset[1],
137+
'serializedConfiguration' => $serializedConfiguration,
138+
'processResultFile' => $processResultFile,
139+
];
140+
141+
if (!$runEntireClass) {
142+
$var['methodName'] = $test->name();
143+
}
144+
145+
$template->setVar($var);
146+
147+
$code = $template->render();
148+
149+
assert($code !== '');
150+
151+
$this->runTestJob($code, $test, $processResultFile);
152+
153+
@unlink($serializedConfiguration);
154+
}
155+
156+
/**
157+
* @psalm-param non-empty-string $code
158+
*
159+
* @throws Exception
160+
* @throws NoPreviousThrowableException
161+
* @throws PhpProcessException
162+
*/
163+
private function runTestJob(string $code, Test $test, string $processResultFile): void
164+
{
165+
$result = JobRunnerRegistry::run(new Job($code));
166+
167+
$processResult = '';
168+
169+
if (file_exists($processResultFile)) {
170+
$processResult = file_get_contents($processResultFile);
171+
172+
@unlink($processResultFile);
173+
}
174+
175+
$this->processChildResult(
176+
$test,
177+
$processResult,
178+
$result->stderr(),
179+
);
180+
}
181+
182+
/**
183+
* @throws Exception
184+
* @throws NoPreviousThrowableException
185+
*/
186+
private function processChildResult(Test $test, string $stdout, string $stderr): void
187+
{
188+
if (!empty($stderr)) {
189+
$exception = new Exception(trim($stderr));
190+
191+
assert($test instanceof TestCase);
192+
193+
Facade::emitter()->testErrored(
194+
TestMethodBuilder::fromTestCase($test),
195+
ThrowableBuilder::from($exception),
196+
);
197+
198+
return;
199+
}
200+
201+
set_error_handler(
202+
/**
203+
* @throws ErrorException
204+
*/
205+
static function (int $errno, string $errstr, string $errfile, int $errline): never
206+
{
207+
throw new ErrorException($errstr, $errno, $errno, $errfile, $errline);
208+
},
209+
);
210+
211+
try {
212+
$childResult = unserialize($stdout);
213+
214+
restore_error_handler();
215+
216+
if ($childResult === false) {
217+
$exception = new AssertionFailedError('Test was run in child process and ended unexpectedly');
218+
219+
assert($test instanceof TestCase);
220+
221+
Facade::emitter()->testErrored(
222+
TestMethodBuilder::fromTestCase($test),
223+
ThrowableBuilder::from($exception),
224+
);
225+
226+
Facade::emitter()->testFinished(
227+
TestMethodBuilder::fromTestCase($test),
228+
0,
229+
);
230+
}
231+
} catch (ErrorException $e) {
232+
restore_error_handler();
233+
234+
$childResult = false;
235+
236+
$exception = new Exception(trim($stdout), 0, $e);
237+
238+
assert($test instanceof TestCase);
239+
240+
Facade::emitter()->testErrored(
241+
TestMethodBuilder::fromTestCase($test),
242+
ThrowableBuilder::from($exception),
243+
);
244+
}
245+
246+
if ($childResult !== false) {
247+
if (!empty($childResult['output'])) {
248+
$output = $childResult['output'];
249+
}
250+
251+
Facade::instance()->forward($childResult['events']);
252+
PassedTests::instance()->import($childResult['passedTests']);
253+
254+
assert($test instanceof TestCase);
255+
256+
$test->setResult($childResult['testResult']);
257+
$test->addToAssertionCount($childResult['numAssertions']);
258+
259+
if (CodeCoverage::instance()->isActive() && $childResult['codeCoverage'] instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) {
260+
CodeCoverage::instance()->codeCoverage()->merge(
261+
$childResult['codeCoverage'],
262+
);
263+
}
264+
}
265+
266+
if (!empty($output)) {
267+
print $output;
268+
}
269+
}
270+
271+
/**
272+
* @throws ProcessIsolationException
273+
*/
274+
private function saveConfigurationForChildProcess(): string
275+
{
276+
$path = tempnam(sys_get_temp_dir(), 'phpunit_');
277+
278+
if ($path === false) {
279+
throw new ProcessIsolationException;
280+
}
281+
282+
if (!ConfigurationRegistry::saveTo($path)) {
283+
throw new ProcessIsolationException;
284+
}
285+
286+
return $path;
287+
}
288+
}

0 commit comments

Comments
 (0)