diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b7fc6b..4a19e1f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,6 +22,9 @@ jobs: - '7.1' - '7.0' phpunit-version: + - '10.4.0' + - '10.3.0' + - '10.2.0' - '10.1.0' - '10.0.0' - '9.6.0' @@ -143,6 +146,12 @@ jobs: phpunit-version: '6.0.0' # PHP 8.0 Exclusions + - php-version: '8.0' + phpunit-version: '10.4.0' + - php-version: '8.0' + phpunit-version: '10.3.0' + - php-version: '8.0' + phpunit-version: '10.2.0' - php-version: '8.0' phpunit-version: '10.1.0' - php-version: '8.0' @@ -189,6 +198,12 @@ jobs: phpunit-version: '6.0.0' # PHP 7.4 Exclusions + - php-version: '7.4' + phpunit-version: '10.4.0' + - php-version: '7.4' + phpunit-version: '10.3.0' + - php-version: '7.4' + phpunit-version: '10.2.0' - php-version: '7.4' phpunit-version: '10.1.0' - php-version: '7.4' @@ -221,12 +236,24 @@ jobs: phpunit-version: '6.0.0' # PHP 7.3 Exclusions + - php-version: '7.3' + phpunit-version: '10.4.0' + - php-version: '7.3' + phpunit-version: '10.3.0' + - php-version: '7.3' + phpunit-version: '10.2.0' - php-version: '7.3' phpunit-version: '10.1.0' - php-version: '7.3' phpunit-version: '10.0.0' # PHP 7.2 Exclusions + - php-version: '7.2' + phpunit-version: '10.4.0' + - php-version: '7.2' + phpunit-version: '10.3.0' + - php-version: '7.2' + phpunit-version: '10.2.0' - php-version: '7.2' phpunit-version: '10.1.0' - php-version: '7.2' @@ -247,6 +274,12 @@ jobs: phpunit-version: '9.0.0' # PHP 7.1 Exclusions + - php-version: '7.1' + phpunit-version: '10.4.0' + - php-version: '7.1' + phpunit-version: '10.3.0' + - php-version: '7.1' + phpunit-version: '10.2.0' - php-version: '7.1' phpunit-version: '10.1.0' - php-version: '7.1' @@ -279,6 +312,12 @@ jobs: phpunit-version: '8.0.0' # PHP 7.0 Exclusions + - php-version: '7.0' + phpunit-version: '10.4.0' + - php-version: '7.0' + phpunit-version: '10.3.0' + - php-version: '7.0' + phpunit-version: '10.2.0' - php-version: '7.0' phpunit-version: '10.1.0' - php-version: '7.0' diff --git a/classes/DefaultArgumentRemoverReturnTypes100.php b/classes/DefaultArgumentRemoverReturnTypes100.php index 41fdcba..c32dcbb 100644 --- a/classes/DefaultArgumentRemoverReturnTypes100.php +++ b/classes/DefaultArgumentRemoverReturnTypes100.php @@ -5,6 +5,7 @@ use phpmock\generator\MockFunctionGenerator; use PHPUnit\Framework\MockObject\Invocation; use PHPUnit\Framework\MockObject\Rule\InvocationOrder; +use ReflectionClass; /** * Removes default arguments from the invocation. @@ -37,7 +38,7 @@ public function matches(Invocation $invocation) : bool $invocation, $iClass ? Invocation::class : Invocation\StaticInvocation::class ); - } else { + } elseif (!$this->shouldRemoveDefaultArgumentsWithReflection($invocation)) { MockFunctionGenerator::removeDefaultArguments($invocation->parameters); } @@ -72,10 +73,62 @@ public function toString() : string */ private function removeDefaultArguments(Invocation $invocation, string $class) { + if ($this->shouldRemoveDefaultArgumentsWithReflection($invocation)) { + return; + } + $remover = function () { MockFunctionGenerator::removeDefaultArguments($this->parameters); }; $remover->bindTo($invocation, $class)(); } + + /** + * Alternative to remove default arguments from StaticInvocation or its children (hack) + * + * @SuppressWarnings(PHPMD.StaticAccess) + */ + public static function removeDefaultArgumentsWithReflection(Invocation $invocation): Invocation + { + if (!(new self())->shouldRemoveDefaultArgumentsWithReflection($invocation)) { + return $invocation; + } + + $reflection = new ReflectionClass($invocation); + + $reflectionReturnType = $reflection->getProperty('returnType'); + $reflectionReturnType->setAccessible(true); + + $reflectionIsOptional = $reflection->getProperty('isReturnTypeNullable'); + $reflectionIsOptional->setAccessible(true); + + $reflectionIsProxied = $reflection->getProperty('proxiedCall'); + $reflectionIsProxied->setAccessible(true); + + $returnType = $reflectionReturnType->getValue($invocation); + $proxiedCall = $reflectionIsProxied->getValue($invocation); + + if ($reflectionIsOptional->getValue($invocation)) { + $returnType = '?' . $returnType; + } + + $parameters = $invocation->parameters(); + MockFunctionGenerator::removeDefaultArguments($parameters); + + return new Invocation( + $invocation->className(), + $invocation->methodName(), + $parameters, + $returnType, + $invocation->object(), + false, + $proxiedCall + ); + } + + protected function shouldRemoveDefaultArgumentsWithReflection(Invocation $invocation) + { + return method_exists($invocation, 'parameters'); + } } diff --git a/classes/PHPMock.php b/classes/PHPMock.php index 166d9d2..2a12b03 100644 --- a/classes/PHPMock.php +++ b/classes/PHPMock.php @@ -2,12 +2,15 @@ namespace phpmock\phpunit; +use DirectoryIterator; use phpmock\integration\MockDelegateFunctionBuilder; use phpmock\MockBuilder; use phpmock\Deactivatable; use PHPUnit\Event\Facade; use PHPUnit\Framework\MockObject\MockObject; +use ReflectionClass; use ReflectionProperty; +use SebastianBergmann\Template\Template; /** * Adds building a function mock functionality into \PHPUnit\Framework\TestCase. @@ -38,6 +41,13 @@ */ trait PHPMock { + public static $templatesPath = '/tmp'; + + private $phpunitVersionClass = '\\PHPUnit\\Runner\\Version'; + private $openInvocation = 'new \\PHPUnit\\Framework\\MockObject\\Invocation('; + private $openWrapper = '\\phpmock\\phpunit\\DefaultArgumentRemover::removeDefaultArgumentsWithReflection('; + private $closeFunc = ')'; + /** * Returns the enabled function mock. * @@ -50,6 +60,8 @@ trait PHPMock */ public function getFunctionMock($namespace, $name) { + $this->prepareCustomTemplates(); + $delegateBuilder = new MockDelegateFunctionBuilder(); $delegateBuilder->build($name); @@ -70,8 +82,7 @@ public function getFunctionMock($namespace, $name) $this->registerForTearDown($functionMock); - $proxy = new MockObjectProxy($mock); - return $proxy; + return new MockObjectProxy($mock); } private function addMatcher($mock, $name) @@ -145,4 +156,130 @@ public static function defineFunctionMock($namespace, $name) ->build() ->define(); } + + /** + * Adds a wrapper method to the Invocable object instance that makes it + * possible to remove optional parameters when it is declared read-only. + * + * @return void + * + * @SuppressWarnings(PHPMD.StaticAccess) + * @SuppressWarnings(PHPMD.IfStatementAssignment) + */ + private function prepareCustomTemplates() + { + if (!($this->shouldPrepareCustomTemplates() && + is_dir(static::$templatesPath) && + ($phpunitTemplatesDir = $this->getPhpunitTemplatesDir()) + )) { + return; + } + + $templatesDir = realpath(static::$templatesPath); + $directoryIterator = new DirectoryIterator($phpunitTemplatesDir); + + $templates = []; + + $prefix = 'phpmock-phpunit-' . $this->getPhpUnitVersion() . '-'; + + foreach ($directoryIterator as $fileinfo) { + if ($fileinfo->getExtension() !== 'tpl') { + continue; + } + + $filename = $fileinfo->getFilename(); + $customTemplateFile = $templatesDir . DIRECTORY_SEPARATOR . $prefix . $filename; + $templateFile = $phpunitTemplatesDir . DIRECTORY_SEPARATOR . $filename; + + $this->createCustomTemplateFile($templateFile, $customTemplateFile); + + if (file_exists($customTemplateFile)) { + $templates[$templateFile] = new Template($customTemplateFile); + } + } + + $mockMethodClasses = [ + 'PHPUnit\\Framework\\MockObject\\Generator\\MockMethod', + 'PHPUnit\\Framework\\MockObject\\MockMethod', + ]; + + foreach ($mockMethodClasses as $mockMethodClass) { + if (class_exists($mockMethodClass)) { + $reflection = new ReflectionClass($mockMethodClass); + + $reflectionTemplates = $reflection->getProperty('templates'); + $reflectionTemplates->setAccessible(true); + + $reflectionTemplates->setValue($templates); + + break; + } + } + } + + private function shouldPrepareCustomTemplates() + { + return class_exists($this->phpunitVersionClass) + && version_compare($this->getPhpUnitVersion(), '10.0.0') >= 0; + } + + private function getPhpUnitVersion() + { + return call_user_func([$this->phpunitVersionClass, 'id']); + } + + /** + * Detects the PHPUnit templates dir + * + * @return string|null + */ + private function getPhpunitTemplatesDir() + { + $phpunitLocations = [ + __DIR__ . '/../../', + __DIR__ . '/../vendor/', + ]; + + $phpunitRelativePath = '/phpunit/phpunit/src/Framework/MockObject/Generator'; + + foreach ($phpunitLocations as $prefix) { + $possibleDirs = [ + $prefix . $phpunitRelativePath . '/templates', + $prefix . $phpunitRelativePath, + ]; + + foreach ($possibleDirs as $dir) { + if (is_dir($dir)) { + return realpath($dir); + } + } + } + } + + /** + * Clones original template with the wrapper + * + * @param string $templateFile Template filename + * @param string $customTemplateFile Custom template filename + * + * @return void + * + * @SuppressWarnings(PHPMD.IfStatementAssignment) + */ + private function createCustomTemplateFile(string $templateFile, string $customTemplateFile) + { + $template = file_get_contents($templateFile); + + if (($start = strpos($template, $this->openInvocation)) !== false && + ($end = strpos($template, $this->closeFunc, $start)) !== false + ) { + $template = substr_replace($template, $this->closeFunc, $end, 0); + $template = substr_replace($template, $this->openWrapper, $start, 0); + + if ($file = fopen($customTemplateFile, 'w+')) { + fputs($file, $template); + fclose($file); + } + } + } }