Skip to content

Commit 9970f5f

Browse files
authored
Merge pull request #38 from Yoast/feature/3-add-polyfill-assertobjectequals
AssertObjectEquals trait: polyfill the Assert::assertObjectEquals() method
2 parents 8dedf60 + cb1b31d commit 9970f5f

18 files changed

+1361
-2
lines changed

.phpcs.xml.dist

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,15 @@
145145
<exclude-pattern>/tests/TestCases/TestCaseTestTrait\.php$</exclude-pattern>
146146
</rule>
147147

148+
<!-- These fixtures for the assertEqualObject() tests will only be loaded on PHP 7+/8+ respectively. -->
149+
<rule ref="PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.boolFound">
150+
<exclude-pattern>/tests/Polyfills/Fixtures/ChildValueObject\.php$</exclude-pattern>
151+
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObject\.php$</exclude-pattern>
152+
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObjectUnion\.php$</exclude-pattern>
153+
</rule>
154+
<rule ref="PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations.UnionTypeFound">
155+
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObjectUnion\.php$</exclude-pattern>
156+
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType\.php$</exclude-pattern>
157+
</rule>
158+
148159
</ruleset>

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,24 @@ if ( self::shouldClosedResourceAssertionBeSkipped( $actual ) === false ) {
436436
> :point_right: While this polyfill is tested extensively, testing for these kind of bugs exhaustively is _hard_.
437437
> Please [report any bugs](https://github.com/Yoast/PHPUnit-Polyfills/issues/new/choose) found and include a clear code sample to reproduce the issue.
438438
439+
#### PHPUnit < 9.4.0: `Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals`
440+
441+
Polyfills the [`Assert::assertObjectEquals()`] method to verify two (value) objects are considered equal.
442+
This assertion expects an object to contain a comparator method in the object itself. This comparator method is subsequently called to verify the "equalness" of the objects.
443+
444+
The `assertObjectEquals() assertion was introduced in PHPUnit 9.4.0.
445+
446+
> :info: Due to [limitations in how this assertion is implemented in PHPUnit] itself, it is currently not possible to create a single comparator method which will be compatible with both PHP < 7.0 and PHP 7.0 or higher.
447+
>
448+
> In effect two declarations of the same object would be needed to be compatible with PHP < 7.0 and PHP 7.0 and higher and still allow for testing the object using the `assertObjectEquals()` assertion.
449+
>
450+
> Due to this limitation, it is recommended to only use this assertion if the minimum supported PHP version of a project is PHP 7.0 or higher; or if the project does not run its tests on PHPUnit >= 9.4.0.
451+
452+
[limitations in how this assertion is implemented in PHPUnit]: https://github.com/sebastianbergmann/phpunit/issues/4707
453+
454+
<!--
455+
COMMENT: No documentation available (yet) for this assertion on the PHPUnit site.
456+
-->
439457

440458
### Helper traits
441459

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@
4747
},
4848
"scripts": {
4949
"lint7": [
50-
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git --exclude src/Exceptions/Error.php --exclude src/Exceptions/TypeError.php"
50+
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git --exclude src/Exceptions/Error.php --exclude src/Exceptions/TypeError.php --exclude tests/Polyfills/Fixtures/ValueObjectUnion.php --exclude tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType.php"
5151
],
5252
"lint-lt70": [
53-
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git --exclude src/TestCases/TestCasePHPUnitGte8.php --exclude src/TestListeners/TestListenerDefaultImplementationPHPUnitGte7.php"
53+
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git --exclude src/TestCases/TestCasePHPUnitGte8.php --exclude src/TestListeners/TestListenerDefaultImplementationPHPUnitGte7.php --exclude tests/Polyfills/Fixtures/ChildValueObject.php --exclude tests/Polyfills/Fixtures/ValueObject.php --exclude tests/Polyfills/Fixtures/ValueObjectUnion.php --exclude tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType.php"
5454
],
5555
"lint-gte80": [
5656
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git"

phpunitpolyfills-autoload.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ public static function load( $className ) {
9898
self::loadAssertClosedResource();
9999
return true;
100100

101+
case 'Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals':
102+
self::loadAssertObjectEquals();
103+
return true;
104+
101105
case 'Yoast\PHPUnitPolyfills\TestCases\TestCase':
102106
self::loadTestCase();
103107
return true;
@@ -108,6 +112,7 @@ public static function load( $className ) {
108112

109113
/*
110114
* Handles:
115+
* - Yoast\PHPUnitPolyfills\Exceptions\InvalidComparisonMethodException
111116
* - Yoast\PHPUnitPolyfills\Helpers\AssertAttributeHelper
112117
* - Yoast\PHPUnitPolyfills\Helpers\ResourceHelper
113118
* - Yoast\PHPUnitPolyfills\TestCases\XTestCase
@@ -400,6 +405,23 @@ public static function loadAssertClosedResource() {
400405
require_once __DIR__ . '/src/Polyfills/AssertClosedResource_Empty.php';
401406
}
402407

408+
/**
409+
* Load the AssertObjectEquals polyfill or an empty trait with the same name
410+
* if a PHPUnit version is used which already contains this functionality.
411+
*
412+
* @return void
413+
*/
414+
public static function loadAssertObjectEquals() {
415+
if ( \method_exists( '\PHPUnit\Framework\Assert', 'assertObjectEquals' ) === false ) {
416+
// PHPUnit < 9.4.0.
417+
require_once __DIR__ . '/src/Polyfills/AssertObjectEquals.php';
418+
return;
419+
}
420+
421+
// PHPUnit >= 9.4.0.
422+
require_once __DIR__ . '/src/Polyfills/AssertObjectEquals_Empty.php';
423+
}
424+
403425
/**
404426
* Load the appropriate TestCase class based on the PHPUnit version being used.
405427
*
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Yoast\PHPUnitPolyfills\Exceptions;
4+
5+
use Exception;
6+
7+
/**
8+
* Exception used for all errors throw by the polyfill for the `assertObjectEquals()` assertion.
9+
*
10+
* PHPUnit natively throws a range of different exceptions.
11+
* The polyfill throws just one exception type with different messages.
12+
*/
13+
final class InvalidComparisonMethodException extends Exception {
14+
15+
/**
16+
* Convert the Exception object to a string message.
17+
*
18+
* @return string
19+
*/
20+
public function __toString() {
21+
return $this->getMessage() . \PHP_EOL;
22+
}
23+
}

src/Polyfills/AssertObjectEquals.php

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<?php
2+
3+
namespace Yoast\PHPUnitPolyfills\Polyfills;
4+
5+
use ReflectionClass;
6+
use ReflectionException;
7+
use ReflectionNamedType;
8+
use ReflectionObject;
9+
use ReflectionType;
10+
use TypeError;
11+
use Yoast\PHPUnitPolyfills\Exceptions\InvalidComparisonMethodException;
12+
13+
/**
14+
* Polyfill the Assert::assertObjectEquals() methods.
15+
*
16+
* Introduced in PHPUnit 9.4.0.
17+
*
18+
* The polyfill implementation closely matches the PHPUnit native implementation with the exception
19+
* of the return type check and the names of the thrown exceptions.
20+
*
21+
* @link https://github.com/sebastianbergmann/phpunit/issues/4467
22+
* @link https://github.com/sebastianbergmann/phpunit/issues/4707
23+
* @link https://github.com/sebastianbergmann/phpunit/commit/1dba8c3a4b2dd04a3ff1869f75daaeb6757a14ee
24+
* @link https://github.com/sebastianbergmann/phpunit/commit/6099c5eefccfda860c889f575d58b5fe6cc10c83
25+
*/
26+
trait AssertObjectEquals {
27+
28+
/**
29+
* Asserts that two objects are considered equal based on a custom object comparison
30+
* using a comparator method in the target object.
31+
*
32+
* The custom comparator method is expected to have the following method
33+
* signature: `equals(self $other): bool` (or similar with a different method name).
34+
*
35+
* Basically, the assertion checks the following:
36+
* - A method with name $method must exist on the $actual object.
37+
* - The method must accept exactly one argument and this argument must be required.
38+
* - This parameter must have a classname-based declared type.
39+
* - The $expected object must be compatible with this declared type.
40+
* - The method must have a declared bool return type. (JRF: not verified in this implementation)
41+
* - `$actual->$method($expected)` returns boolean true.
42+
*
43+
* @param object $expected Expected value.
44+
* @param object $actual The value to test.
45+
* @param string $method The name of the comparator method within the object.
46+
* @param string $message Optional failure message to display.
47+
*
48+
* @return void
49+
*
50+
* @throws TypeError When any of the passed arguments do not meet the required type.
51+
* @throws InvalidComparisonMethodException When the comparator method does not comply with the requirements.
52+
*/
53+
public static function assertObjectEquals( $expected, $actual, $method = 'equals', $message = '' ) {
54+
/*
55+
* Parameter input validation.
56+
* In PHPUnit this is done via PHP native type declarations. Emulating this for the polyfill.
57+
*/
58+
if ( \is_object( $expected ) === false ) {
59+
throw new TypeError(
60+
\sprintf(
61+
'Argument 1 passed to assertObjectEquals() must be an object, %s given',
62+
\gettype( $expected )
63+
)
64+
);
65+
}
66+
67+
if ( \is_object( $actual ) === false ) {
68+
throw new TypeError(
69+
\sprintf(
70+
'Argument 2 passed to assertObjectEquals() must be an object, %s given',
71+
\gettype( $actual )
72+
)
73+
);
74+
}
75+
76+
if ( \is_scalar( $method ) === false ) {
77+
throw new TypeError(
78+
\sprintf(
79+
'Argument 3 passed to assertObjectEquals() must be of the type string, %s given',
80+
\gettype( $method )
81+
)
82+
);
83+
}
84+
else {
85+
$method = (string) $method;
86+
}
87+
88+
/*
89+
* Comparator method validation.
90+
*/
91+
$reflObject = new ReflectionObject( $actual );
92+
93+
if ( $reflObject->hasMethod( $method ) === false ) {
94+
throw new InvalidComparisonMethodException(
95+
\sprintf(
96+
'Comparison method %s::%s() does not exist.',
97+
\get_class( $actual ),
98+
$method
99+
)
100+
);
101+
}
102+
103+
$reflMethod = $reflObject->getMethod( $method );
104+
105+
/*
106+
* As the next step, PHPUnit natively would validate the return type,
107+
* but as return type declarations is a PHP 7.0+ feature, the polyfill
108+
* skips this check in favour of checking the type of the actual
109+
* returned value.
110+
*
111+
* Also see the upstream discussion about this:
112+
* {@link https://github.com/sebastianbergmann/phpunit/issues/4707}
113+
*/
114+
115+
/*
116+
* Comparator method parameter requirements validation.
117+
*/
118+
if ( $reflMethod->getNumberOfParameters() !== 1
119+
|| $reflMethod->getNumberOfRequiredParameters() !== 1
120+
) {
121+
throw new InvalidComparisonMethodException(
122+
\sprintf(
123+
'Comparison method %s::%s() does not declare exactly one parameter.',
124+
\get_class( $actual ),
125+
$method
126+
)
127+
);
128+
}
129+
130+
$noDeclaredTypeError = \sprintf(
131+
'Parameter of comparison method %s::%s() does not have a declared type.',
132+
\get_class( $actual ),
133+
$method
134+
);
135+
136+
$notAcceptableTypeError = \sprintf(
137+
'%s is not an accepted argument type for comparison method %s::%s().',
138+
\get_class( $expected ),
139+
\get_class( $actual ),
140+
$method
141+
);
142+
143+
$reflParameter = $reflMethod->getParameters()[0];
144+
145+
if ( \method_exists( $reflParameter, 'hasType' ) ) {
146+
// PHP >= 7.0.
147+
$hasType = $reflParameter->hasType();
148+
if ( $hasType === false ) {
149+
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
150+
}
151+
152+
$type = $reflParameter->getType();
153+
if ( \class_exists( 'ReflectionNamedType' ) ) {
154+
// PHP >= 7.1.
155+
if ( ( $type instanceof ReflectionNamedType ) === false ) {
156+
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
157+
}
158+
159+
$typeName = $type->getName();
160+
}
161+
else {
162+
/*
163+
* PHP 7.0.
164+
* Checking for `ReflectionType` will not throw an error on union types,
165+
* but then again union types are not supported on PHP 7.0.
166+
*/
167+
if ( ( $type instanceof ReflectionType ) === false ) {
168+
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
169+
}
170+
171+
$typeName = (string) $type;
172+
}
173+
}
174+
else {
175+
// PHP < 7.0.
176+
try {
177+
/*
178+
* Using `ReflectionParameter::getClass()` will trigger an autoload of the class,
179+
* but that's okay as for a valid class type that would be triggered on the
180+
* function call to the $method (at the end of this assertion) anyway.
181+
*/
182+
$hasType = $reflParameter->getClass();
183+
} catch ( ReflectionException $e ) {
184+
// Class with a type declaration for a non-existent class.
185+
throw new InvalidComparisonMethodException( $notAcceptableTypeError );
186+
}
187+
188+
if ( ( $hasType instanceof ReflectionClass ) === false ) {
189+
// Array or callable type.
190+
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
191+
}
192+
193+
$typeName = $hasType->name;
194+
}
195+
196+
/*
197+
* Validate that the $expected object complies with the declared parameter type.
198+
*/
199+
if ( $typeName === 'self' ) {
200+
$typeName = \get_class( $actual );
201+
}
202+
203+
if ( ( $expected instanceof $typeName ) === false ) {
204+
throw new InvalidComparisonMethodException( $notAcceptableTypeError );
205+
}
206+
207+
/*
208+
* Execute the comparator method.
209+
*/
210+
$result = $actual->{$method}( $expected );
211+
212+
if ( \is_bool( $result ) === false ) {
213+
throw new InvalidComparisonMethodException(
214+
\sprintf(
215+
'%s::%s() does not return a boolean value.',
216+
\get_class( $actual ),
217+
$method
218+
)
219+
);
220+
}
221+
222+
$msg = \sprintf(
223+
'Failed asserting that two objects are equal. The objects are not equal according to %s::%s()',
224+
\get_class( $actual ),
225+
$method
226+
);
227+
228+
if ( $message !== '' ) {
229+
$msg = $message . \PHP_EOL . $msg;
230+
}
231+
232+
static::assertTrue( $result, $msg );
233+
}
234+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Yoast\PHPUnitPolyfills\Polyfills;
4+
5+
/**
6+
* Empty trait for use with PHPUnit >= 9.4.0 in which this polyfill is not needed.
7+
*/
8+
trait AssertObjectEquals {}

src/TestCases/TestCasePHPUnitGte8.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Yoast\PHPUnitPolyfills\Polyfills\AssertClosedResource;
88
use Yoast\PHPUnitPolyfills\Polyfills\AssertFileEqualsSpecializations;
99
use Yoast\PHPUnitPolyfills\Polyfills\AssertionRenames;
10+
use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals;
1011
use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations;
1112
use Yoast\PHPUnitPolyfills\Polyfills\ExpectExceptionMessageMatches;
1213
use Yoast\PHPUnitPolyfills\Polyfills\ExpectPHPException;
@@ -26,6 +27,7 @@ abstract class TestCase extends PHPUnit_TestCase {
2627
use AssertClosedResource;
2728
use AssertFileEqualsSpecializations;
2829
use AssertionRenames;
30+
use AssertObjectEquals;
2931
use EqualToSpecializations;
3032
use ExpectExceptionMessageMatches;
3133
use ExpectPHPException;

src/TestCases/TestCasePHPUnitLte7.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Yoast\PHPUnitPolyfills\Polyfills\AssertionRenames;
1212
use Yoast\PHPUnitPolyfills\Polyfills\AssertIsType;
1313
use Yoast\PHPUnitPolyfills\Polyfills\AssertNumericType;
14+
use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals;
1415
use Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains;
1516
use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations;
1617
use Yoast\PHPUnitPolyfills\Polyfills\ExpectException;
@@ -37,6 +38,7 @@ abstract class TestCase extends PHPUnit_TestCase {
3738
use AssertionRenames;
3839
use AssertIsType;
3940
use AssertNumericType;
41+
use AssertObjectEquals;
4042
use AssertStringContains;
4143
use EqualToSpecializations;
4244
use ExpectException;

0 commit comments

Comments
 (0)