Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP Add ability to run tests in a separate process #285

Closed
wants to merge 11 commits into from
8 changes: 0 additions & 8 deletions src/Actions/ValidatesConfiguration.php
Expand Up @@ -4,9 +4,7 @@

namespace Pest\Actions;

use Pest\Exceptions\AttributeNotSupportedYet;
use Pest\Exceptions\FileOrFolderNotFound;
use PHPUnit\TextUI\XmlConfiguration\Loader;

/**
* @internal
Expand All @@ -28,11 +26,5 @@ public static function in($arguments): void
if (!array_key_exists(self::CONFIGURATION_KEY, $arguments) || !file_exists($arguments[self::CONFIGURATION_KEY])) {
throw new FileOrFolderNotFound('phpunit.xml');
}

$configuration = (new Loader())->load($arguments[self::CONFIGURATION_KEY])->phpunit();

if ($configuration->processIsolation()) {
throw new AttributeNotSupportedYet('processIsolation', 'true');
}
}
}
9 changes: 9 additions & 0 deletions src/Concerns/TestCase.php
Expand Up @@ -138,6 +138,15 @@ protected function tearDown(): void
parent::tearDown();

TestSuite::getInstance()->test = null;

// Cleanup Temp.php files in tests!
$iterator = new \RecursiveDirectoryIterator(dirname(__FILE__, 3) . '/tests');

foreach(new \RecursiveIteratorIterator($iterator) as $file) {
if (strpos($file->getFileName(), 'Temp.php') !== false) {
unlink($file->getPath() . DIRECTORY_SEPARATOR . $file->getFileName());
}
}
}

/**
Expand Down
113 changes: 105 additions & 8 deletions src/Factories/TestCaseFactory.php
Expand Up @@ -40,6 +40,15 @@ final class TestCaseFactory
*/
public $only = false;

/**
* Identifier whether the test should be run in a separate process.
*
* @readonly
*
* @var bool
*/
public $separateProcess = false;

/**
* Holds the test description.
*
Expand Down Expand Up @@ -84,15 +93,15 @@ final class TestCaseFactory

/**
* Holds the higher order messages
* for the factory that are proxyble.
* for the factory that are proxyable.
*
* @var HigherOrderMessageCollection
*/
public $factoryProxies;

/**
* Holds the higher order
* messages that are proxyble.
* messages that are proxyable.
*
* @var HigherOrderMessageCollection
*/
Expand Down Expand Up @@ -146,14 +155,36 @@ public function build(TestSuite $testSuite): array
return call_user_func(Closure::bind($factoryTest, $this, get_class($this)), ...func_get_args());
};

$className = $this->makeClassFromFilename($this->filename);
if ($this->separateProcess) {
$className = $this->makeTemporaryClassFromFilename($this->filename);

$createTest = function ($description, $data) use ($className, $test) {
$testCase = new $className($test, $description, $data);
$this->factoryProxies->proxy($testCase);
$createTest = function ($description, $data) use ($className, $test) {

return $testCase;
};
$classFilename = str_replace('.php', 'Temp.php', $this->filename);
require_once $classFilename;

$testCase = new $className($test, $description, $data);

if ($this->separateProcess) {
$testCase->setPreserveGlobalState(false);
$testCase->setInIsolation(false);
$testCase->setRunTestInSeparateProcess(true);
}

$this->factoryProxies->proxy($testCase);

return $testCase;
};
} else {
$className = $this->makeClassFromFilename($this->filename);

$createTest = function ($description, $data) use ($className, $test) {
$testCase = new $className($test, $description, $data);
$this->factoryProxies->proxy($testCase);

return $testCase;
};
}

$datasets = Datasets::resolve($this->description, $this->dataset);

Expand Down Expand Up @@ -223,4 +254,70 @@ final class $className extends $baseClass implements $hasPrintableTestCaseClassF

return $classFQN;
}

/**
* Makes a fully qualified class name from the given filename.
*/
public function makeTemporaryClassFromFilename(string $filename): string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really need all this code?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is related to the comment

So, after a bit of digging I've found out what the correct flags are, but the problem is that the test fails with

Parse error: syntax error, unexpected 'd' (T_STRING) in Standard input code on line 33

Digging into PHPUnit, in the src/Framework/TestCase.php line 847 there is a call to a getFileName() method of a reflection class of the test class. And since all the test classes in Pest are done on the fly using eval in the TestCaseFactory.php, there are no real test classes (not in the PHPUnit sense of the word).

This will return

  'filename' => '/Users/denis.zoljom/Projects/Personal/pest/src/Factories/TestCaseFactory.php(233) : eval()\'d code',

And the : eval()\'d code' trips PHPUnit up causing the above error. I'll try to see what else I can dig out and if this can be solved in any way.

The issue is that when using eval, the reflection won't return the intended filename (by design). This is a workaround (attempt at least).

{
if ('\\' === DIRECTORY_SEPARATOR) {
// In case Windows, strtolower drive name, like in UsesCall.
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', function ($match): string {
return strtolower($match['drive']);
}, $filename);
}

$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
$rootPath = TestSuite::getInstance()->rootPath;
$relativePath = str_replace($rootPath . DIRECTORY_SEPARATOR, '', $filename);
$relativePath = dirname(ucfirst($relativePath)) . DIRECTORY_SEPARATOR . basename($relativePath, '.php');
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);

// Strip out any %-encoded octets.
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
// Remove escaped quote sequences (maintain namespace)
$relativePath = str_replace(array_map(function (string $quote): string {
return sprintf('\\%s', $quote);
}, ['\'', '"']), '', $relativePath);
// Limit to A-Z, a-z, 0-9, '_', '-'.
$relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath);

$classFQN = 'P\\' . $relativePath;
$classFQN = str_replace($classFQN, "{$classFQN}Temp", $classFQN);
if (class_exists($classFQN)) {
return $classFQN;
}

$hasPrintableTestCaseClassFQN = sprintf('\%s', HasPrintableTestCaseName::class);
$traitsCode = sprintf('use %s;', implode(', ', array_map(function ($trait): string {
return sprintf('\%s', $trait);
}, $this->traits)));

$partsFQN = explode('\\', $classFQN);
$className = array_pop($partsFQN);
$namespace = implode('\\', $partsFQN);
$baseClass = sprintf('\%s', $this->class);

if ('' === trim($className)) {
$className = 'InvalidTestName' . Str::random();
$classFQN .= $className;
}

// If the file should be run in a separate process, we cannot create it 'on the fly' as this will
// crash PHPUnit. Instead, we'll write a temporary file in the same folder, but with the Temp.php
// suffix. That way we can clean it up when the test passes. In the build method, we will provide
// that file instead on the fly creating one.
$filename = str_replace('.php', 'Temp.php', $filename);

file_put_contents($filename,"<?php
namespace $namespace;

final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode

private static \$__filename = '$filename';
}");

return $classFQN;
}
}
10 changes: 10 additions & 0 deletions src/PendingObjects/TestCall.php
Expand Up @@ -150,6 +150,16 @@ public function skip($conditionOrMessage = true, string $message = ''): TestCall
return $this;
}

/**
* Run the current test in a separate process.
*/
public function runInSeparateProcess(): TestCall
{
$this->testCaseFactory->separateProcess = true;

return $this;
}

/**
* Saves the calls to be used on the target.
*
Expand Down
42 changes: 42 additions & 0 deletions tests/Features/SeparateProcess.php
@@ -0,0 +1,42 @@
<?php

function cacheable(): int
{
static $value;

if ($value !== null) {
return $value;
}

if (defined('ISOLATED') && ISOLATED) {
$value = 1;
} else {
$value = 2;
}

return $value;
}

test('Set cacheable function', function () {
define('ISOLATED', true);

$value = cacheable();

$this->assertSame(1, $value);
});

test('Cached value will still be set', function () {
$value = cacheable();

$this->assertSame(1, $value);
});

test('Cacheable function should return a different value because of process isolation', function () {
if (!defined('ISOLATED')) {
define('ISOLATED', false);
}

$value = cacheable();

$this->assertSame(2, $value);
})->runInSeparateProcess();
8 changes: 3 additions & 5 deletions tests/Unit/Actions/ValidatesConfiguration.php
@@ -1,7 +1,6 @@
<?php

use Pest\Actions\ValidatesConfiguration;
use Pest\Exceptions\AttributeNotSupportedYet;
use Pest\Exceptions\FileOrFolderNotFound;

it('throws exception when configuration not found', function () {
Expand All @@ -12,10 +11,7 @@
]);
});

it('throws exception when `process isolation` is true', function () {
$this->expectException(AttributeNotSupportedYet::class);
$this->expectExceptionMessage('The PHPUnit attribute `processIsolation` with value `true` is not supported yet.');

it('do not throws exception when `process isolation` is true', function () {
$filename = implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 2),
'Fixtures',
Expand All @@ -25,6 +21,8 @@
ValidatesConfiguration::in([
'configuration' => $filename,
]);

expect(true)->toBeTrue();
});

it('do not throws exception when `process isolation` is false', function () {
Expand Down