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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Property Hooks in Test Doubles #5948

Draft
wants to merge 29 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7a2ff99
Add tests that capture the status quo
sebastianbergmann Sep 10, 2024
46d6b6d
Rename variable and placeholder
sebastianbergmann Sep 10, 2024
0625f02
Initial work on code generation for property hooks
sebastianbergmann Sep 10, 2024
8fa4fc7
Add tests
sebastianbergmann Sep 14, 2024
fd437a9
Initial work on value object to represent property hooks
sebastianbergmann Sep 14, 2024
aef4a0f
Initial work on body for doubled property hooks
sebastianbergmann Sep 14, 2024
9e47239
Add property hooks to list of configurable methods
sebastianbergmann Sep 15, 2024
ad8697b
Add tests for doubling interfaces that declare properties with hooks
sebastianbergmann Sep 15, 2024
2f1e08f
Also accept PropertyHook value object
sebastianbergmann Sep 15, 2024
07343f9
Exclude files that currently trigger PHP-CS-Fixer errors
sebastianbergmann Sep 15, 2024
41c591a
Use PHP 8.4 for code coverage job
sebastianbergmann Sep 15, 2024
490bf65
Revert "Use PHP 8.4 for code coverage job"
sebastianbergmann Sep 15, 2024
dbe4f31
Reapply "Use PHP 8.4 for code coverage job"
sebastianbergmann Sep 15, 2024
b969bcc
Support property hooks for extendable classes
sebastianbergmann Sep 16, 2024
6f6090d
Check for ReflectionProperty::isFinal()
sebastianbergmann Sep 17, 2024
be50e77
Do not attempt to generate test double methods for final property hooks
sebastianbergmann Sep 17, 2024
0936fe6
Also document our assumptions that the new ReflectionProperty methods…
sebastianbergmann Sep 17, 2024
ba52758
Extract method
sebastianbergmann Sep 17, 2024
6423b2f
Check for ReflectionProperty::isFinal()
sebastianbergmann Sep 17, 2024
405060c
Both the property itself as well as its hook methods may be final
sebastianbergmann Sep 17, 2024
a566b54
Add tests
sebastianbergmann Sep 17, 2024
f0c40ed
Extract method
sebastianbergmann Sep 17, 2024
b7cf70a
Update assertions
sebastianbergmann Sep 17, 2024
98cdd76
Narrow types
sebastianbergmann Sep 17, 2024
820052d
Extract method
sebastianbergmann Sep 17, 2024
1426283
Aggregate Type value object in Property value object
sebastianbergmann Sep 17, 2024
1ffc747
Rename tests
sebastianbergmann Sep 17, 2024
e3ce86c
Use ReflectionMapper::fromPropertyType()
sebastianbergmann Sep 17, 2024
094d2f6
Ignore code from code coverage that is only run on PHP < 8.4
sebastianbergmann Sep 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ jobs:
- name: Install PHP with extensions
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
php-version: 8.4
coverage: xdebug
extensions: none, ctype, curl, dom, json, libxml, mbstring, phar, soap, tokenizer, xml, xmlwriter
ini-values: assert.exception=1, zend.assertions=1, error_reporting=-1, log_errors_max_len=0, display_errors=On
Expand Down
5 changes: 5 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
->in(__DIR__ . '/tests/_files')
->in(__DIR__ . '/tests/end-to-end')
->in(__DIR__ . '/tests/unit')
// *WithPropertyWith*Hook.php use PHP 8.4 syntax that currently leads to PHP-CS-Fixer errors
->notName('ExtendableClassWithPropertyWithGetHook.php')
->notName('ExtendableClassWithPropertyWithSetHook.php')
->notName('InterfaceWithPropertyWithGetHook.php')
->notName('InterfaceWithPropertyWithSetHook.php')
// DeprecatedPhpFeatureTest.php must not use declare(strict_types=1);
->notName('DeprecatedPhpFeatureTest.php')
// Issue5795Test.php contains required whitespace that would be cleaned up
Expand Down
203 changes: 193 additions & 10 deletions src/Framework/MockObject/Generator/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use function is_array;
use function is_object;
use function md5;
use function method_exists;
use function mt_rand;
use function preg_match;
use function preg_match_all;
Expand Down Expand Up @@ -63,9 +64,13 @@
use PHPUnit\Framework\MockObject\StubApi;
use PHPUnit\Framework\MockObject\StubInternal;
use PHPUnit\Framework\MockObject\TestDoubleState;
use PropertyHookType;
use ReflectionClass;
use ReflectionMethod;
use ReflectionObject;
use ReflectionProperty;
use SebastianBergmann\Type\ReflectionMapper;
use SebastianBergmann\Type\Type;
use SoapClient;
use SoapFault;
use Throwable;
Expand Down Expand Up @@ -796,18 +801,13 @@ private function generateCodeForTestDoubleClass(string $type, bool $mockObject,
}
}

$propertiesWithHooks = $this->properties($class);
$configurableMethods = $this->configurableMethods($mockMethods, $propertiesWithHooks);

$mockedMethods = '';
$configurable = [];

foreach ($mockMethods->asArray() as $mockMethod) {
$mockedMethods .= $mockMethod->generateCode();

$configurable[] = new ConfigurableMethod(
$mockMethod->methodName(),
$mockMethod->defaultParameterValues(),
$mockMethod->numberOfParameters(),
$mockMethod->returnType(),
);
}

/** @var trait-string[] $traits */
Expand Down Expand Up @@ -890,14 +890,15 @@ private function generateCodeForTestDoubleClass(string $type, bool $mockObject,
),
'use_statements' => $useStatements,
'mock_class_name' => $_mockClassName['className'],
'mocked_methods' => $mockedMethods,
'methods' => $mockedMethods,
'property_hooks' => $this->codeForPropertyHooks($propertiesWithHooks, $_mockClassName['className']),
],
);

return new MockClass(
$classTemplate->render(),
$_mockClassName['className'],
$configurable,
$configurableMethods,
);
}

Expand Down Expand Up @@ -1159,4 +1160,186 @@ private function interfaceMethods(string $interfaceName, bool $cloneArguments):

return $methods;
}

/**
* @param list<Property> $propertiesWithHooks
*
* @return list<ConfigurableMethod>
*/
private function configurableMethods(MockMethodSet $methods, array $propertiesWithHooks): array
{
$configurable = [];

foreach ($methods->asArray() as $method) {
$configurable[] = new ConfigurableMethod(
$method->methodName(),
$method->defaultParameterValues(),
$method->numberOfParameters(),
$method->returnType(),
);
}

foreach ($propertiesWithHooks as $property) {
if ($property->hasGetHook()) {
$configurable[] = new ConfigurableMethod(
sprintf(
'$%s::get',
$property->name(),
),
[],
0,
$property->type(),
);
}

if ($property->hasSetHook()) {
$configurable[] = new ConfigurableMethod(
sprintf(
'$%s::set',
$property->name(),
),
[],
1,
Type::fromName('void', false),
);
}
}

return $configurable;
}

/**
* @param ?ReflectionClass<object> $class
*
* @return list<Property>
*/
private function properties(?ReflectionClass $class): array
{
if (!method_exists(ReflectionProperty::class, 'isFinal')) {
// @codeCoverageIgnoreStart
return [];
// @codeCoverageIgnoreEnd
}

if ($class === null) {
return [];
}

$mapper = new ReflectionMapper;
$properties = [];

foreach ($class->getProperties() as $property) {
assert(method_exists($property, 'getHook'));
assert(method_exists($property, 'hasHooks'));
assert(method_exists($property, 'hasHook'));
assert(method_exists($property, 'isFinal'));
assert(class_exists(PropertyHookType::class));

if (!$property->isPublic()) {
continue;
}

if ($property->isFinal()) {
continue;
}

if (!$property->hasHooks()) {
continue;
}

$hasGetHook = false;
$hasSetHook = false;

if ($property->hasHook(PropertyHookType::Get) &&
!$property->getHook(PropertyHookType::Get)->isFinal()) {
$hasGetHook = true;
}

if ($property->hasHook(PropertyHookType::Set) &&
!$property->getHook(PropertyHookType::Set)->isFinal()) {
$hasSetHook = true;
}

if (!$hasGetHook && !$hasSetHook) {
continue;
}

$properties[] = new Property(
$property->getName(),
$mapper->fromPropertyType($property),
$hasGetHook,
$hasSetHook,
);
}

return $properties;
}

/**
* @param list<Property> $propertiesWithHooks
* @param class-string $className
*
* @return non-empty-string
*/
private function codeForPropertyHooks(array $propertiesWithHooks, string $className): string
{
$propertyHooks = '';

foreach ($propertiesWithHooks as $property) {
$propertyHooks .= sprintf(
<<<'EOT'

public %s $%s {
EOT,
$property->type()->asString(),
$property->name(),
);

if ($property->hasGetHook()) {
$propertyHooks .= sprintf(
<<<'EOT'

get {
return $this->__phpunit_getInvocationHandler()->invoke(
new \PHPUnit\Framework\MockObject\Invocation(
'%s', '$%s::get', [], '%s', $this, false
)
);
}

EOT,
$className,
$property->name(),
$property->type()->asString(),
);
}

if ($property->hasSetHook()) {
$propertyHooks .= sprintf(
<<<'EOT'

set (%s $value) {
$this->__phpunit_getInvocationHandler()->invoke(
new \PHPUnit\Framework\MockObject\Invocation(
'%s', '$%s::set', [$value], 'void', $this, false
)
);
}

EOT,
$property->type()->asString(),
$className,
$property->name(),
);
}

$propertyHooks .= <<<'EOT'
}

EOT;

}

return $propertyHooks;
}
}
59 changes: 59 additions & 0 deletions src/Framework/MockObject/Generator/Property.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Framework\MockObject\Generator;

use SebastianBergmann\Type\Type;

/**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class Property
{
/**
* @var non-empty-string
*/
private string $name;
private Type $type;
private bool $getHook;
private bool $setHook;

/**
* @param non-empty-string $name
*/
public function __construct(string $name, Type $type, bool $getHook, bool $setHook)
{
$this->name = $name;
$this->type = $type;
$this->getHook = $getHook;
$this->setHook = $setHook;
}

public function name(): string
{
return $this->name;
}

public function type(): Type
{
return $this->type;
}

public function hasGetHook(): bool
{
return $this->getHook;
}

public function hasSetHook(): bool
{
return $this->setHook;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ declare(strict_types=1);

{prologue}{class_declaration}
{
{use_statements}{mocked_methods}}{epilogue}
{use_statements}{property_hooks}{methods}}{epilogue}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use PHPUnit\Framework\MockObject\MethodNameNotConfiguredException;
use PHPUnit\Framework\MockObject\MethodParametersAlreadyConfiguredException;
use PHPUnit\Framework\MockObject\Rule;
use PHPUnit\Framework\MockObject\Runtime\PropertyHook;
use PHPUnit\Framework\MockObject\Stub\ConsecutiveCalls;
use PHPUnit\Framework\MockObject\Stub\Exception;
use PHPUnit\Framework\MockObject\Stub\ReturnArgument;
Expand Down Expand Up @@ -246,12 +247,16 @@ public function withAnyParameters(): self
*
* @return $this
*/
public function method(Constraint|string $constraint): self
public function method(Constraint|PropertyHook|string $constraint): self
{
if ($this->matcher->hasMethodNameRule()) {
throw new MethodNameAlreadyConfiguredException;
}

if ($constraint instanceof PropertyHook) {
$constraint = $constraint->asString();
}

if (is_string($constraint)) {
$this->configurableMethodNames ??= array_flip(
array_map(
Expand Down
31 changes: 31 additions & 0 deletions src/Framework/MockObject/Runtime/PropertyHook/PropertyGetHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Framework\MockObject\Runtime;

use function sprintf;

/**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*/
final readonly class PropertyGetHook extends PropertyHook
{
/**
* @return non-empty-string
*
* @internal This method is not covered by the backward compatibility promise for PHPUnit
*/
public function asString(): string
{
return sprintf(
'$%s::get',
$this->propertyName(),
);
}
}
Loading