Skip to content

Commit

Permalink
AssertObjectEquals trait: polyfill the Assert::assertObjectEquals() m…
Browse files Browse the repository at this point in the history
…ethod

PHPUnit 9.4.0 introduced the new `Assert::assertObjectEquals()` method.

This commit:
* Adds two traits with the same name.
    One to polyfill the method when not available in PHPUnit.
    The other to allow for `use`-ing the trait in PHPUnit versions in which the method is already natively available.
* Adds an `InvalidComparisonMethodException` exception class.
    _PHPUnit natively throws a range of different exceptions._
    _The polyfill included in this library throws one exception type - the `InvalidComparisonMethodException` - with a range of different messages._
* Logic to the custom autoloader which will load the correct trait depending on the PHPUnit version used.
* Adds tests.

As the polyfill contains logic to match the PHPUnit native implementation as closely as possible, while still being PHP and PHPUnit cross-version compatible, extensive unit tests have been added to ensure the behaviour of the polyfill matches that of the original function, with the exception of the _return type verification_.

As return types were not available in PHP prior to PHP 7.0, the return type verification as done in the PHPUnit native implementation, has been replaced by a verification that the _returned value_ is of the required type.
This provides the same safeguard as the PHPUnit native implementation, but in a PHP cross-version compatible manner.

Note: the method uses `static::` to call the PHPUnit native functionality. This allows for existing method overloads in a child class of the PHPUnit native `TestCase` to be respected.

Includes:
* Adding information on the new polyfill to the README.
* Adding the new polyfill to the existing `TestCases` classes.
* Adding a few select exceptions to the PHPCS ruleset for the fixtures used in the tests.

Refs:
 * sebastianbergmann/phpunit#4467
 * sebastianbergmann/phpunit#4707
 * sebastianbergmann/phpunit@1dba8c3
 * sebastianbergmann/phpunit@6099c5e
  • Loading branch information
jrfnl committed Jun 17, 2021
1 parent 8dedf60 commit cb1b31d
Show file tree
Hide file tree
Showing 18 changed files with 1,361 additions and 2 deletions.
11 changes: 11 additions & 0 deletions .phpcs.xml.dist
Expand Up @@ -145,4 +145,15 @@
<exclude-pattern>/tests/TestCases/TestCaseTestTrait\.php$</exclude-pattern>
</rule>

<!-- These fixtures for the assertEqualObject() tests will only be loaded on PHP 7+/8+ respectively. -->
<rule ref="PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.boolFound">
<exclude-pattern>/tests/Polyfills/Fixtures/ChildValueObject\.php$</exclude-pattern>
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObject\.php$</exclude-pattern>
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObjectUnion\.php$</exclude-pattern>
</rule>
<rule ref="PHPCompatibility.FunctionDeclarations.NewParamTypeDeclarations.UnionTypeFound">
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObjectUnion\.php$</exclude-pattern>
<exclude-pattern>/tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType\.php$</exclude-pattern>
</rule>

</ruleset>
18 changes: 18 additions & 0 deletions README.md
Expand Up @@ -436,6 +436,24 @@ if ( self::shouldClosedResourceAssertionBeSkipped( $actual ) === false ) {
> :point_right: While this polyfill is tested extensively, testing for these kind of bugs exhaustively is _hard_.
> Please [report any bugs](https://github.com/Yoast/PHPUnit-Polyfills/issues/new/choose) found and include a clear code sample to reproduce the issue.
#### PHPUnit < 9.4.0: `Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals`

Polyfills the [`Assert::assertObjectEquals()`] method to verify two (value) objects are considered equal.
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.

The `assertObjectEquals() assertion was introduced in PHPUnit 9.4.0.

> :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.
>
> 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.
>
> 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.
[limitations in how this assertion is implemented in PHPUnit]: https://github.com/sebastianbergmann/phpunit/issues/4707

<!--
COMMENT: No documentation available (yet) for this assertion on the PHPUnit site.
-->

### Helper traits

Expand Down
4 changes: 2 additions & 2 deletions composer.json
Expand Up @@ -47,10 +47,10 @@
},
"scripts": {
"lint7": [
"@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"
"@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"
],
"lint-lt70": [
"@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"
"@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"
],
"lint-gte80": [
"@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git"
Expand Down
22 changes: 22 additions & 0 deletions phpunitpolyfills-autoload.php
Expand Up @@ -98,6 +98,10 @@ public static function load( $className ) {
self::loadAssertClosedResource();
return true;

case 'Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals':
self::loadAssertObjectEquals();
return true;

case 'Yoast\PHPUnitPolyfills\TestCases\TestCase':
self::loadTestCase();
return true;
Expand All @@ -108,6 +112,7 @@ public static function load( $className ) {

/*
* Handles:
* - Yoast\PHPUnitPolyfills\Exceptions\InvalidComparisonMethodException
* - Yoast\PHPUnitPolyfills\Helpers\AssertAttributeHelper
* - Yoast\PHPUnitPolyfills\Helpers\ResourceHelper
* - Yoast\PHPUnitPolyfills\TestCases\XTestCase
Expand Down Expand Up @@ -400,6 +405,23 @@ public static function loadAssertClosedResource() {
require_once __DIR__ . '/src/Polyfills/AssertClosedResource_Empty.php';
}

/**
* Load the AssertObjectEquals polyfill or an empty trait with the same name
* if a PHPUnit version is used which already contains this functionality.
*
* @return void
*/
public static function loadAssertObjectEquals() {
if ( \method_exists( '\PHPUnit\Framework\Assert', 'assertObjectEquals' ) === false ) {
// PHPUnit < 9.4.0.
require_once __DIR__ . '/src/Polyfills/AssertObjectEquals.php';
return;
}

// PHPUnit >= 9.4.0.
require_once __DIR__ . '/src/Polyfills/AssertObjectEquals_Empty.php';
}

/**
* Load the appropriate TestCase class based on the PHPUnit version being used.
*
Expand Down
23 changes: 23 additions & 0 deletions src/Exceptions/InvalidComparisonMethodException.php
@@ -0,0 +1,23 @@
<?php

namespace Yoast\PHPUnitPolyfills\Exceptions;

use Exception;

/**
* Exception used for all errors throw by the polyfill for the `assertObjectEquals()` assertion.
*
* PHPUnit natively throws a range of different exceptions.
* The polyfill throws just one exception type with different messages.
*/
final class InvalidComparisonMethodException extends Exception {

/**
* Convert the Exception object to a string message.
*
* @return string
*/
public function __toString() {
return $this->getMessage() . \PHP_EOL;
}
}
234 changes: 234 additions & 0 deletions src/Polyfills/AssertObjectEquals.php
@@ -0,0 +1,234 @@
<?php

namespace Yoast\PHPUnitPolyfills\Polyfills;

use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionObject;
use ReflectionType;
use TypeError;
use Yoast\PHPUnitPolyfills\Exceptions\InvalidComparisonMethodException;

/**
* Polyfill the Assert::assertObjectEquals() methods.
*
* Introduced in PHPUnit 9.4.0.
*
* The polyfill implementation closely matches the PHPUnit native implementation with the exception
* of the return type check and the names of the thrown exceptions.
*
* @link https://github.com/sebastianbergmann/phpunit/issues/4467
* @link https://github.com/sebastianbergmann/phpunit/issues/4707
* @link https://github.com/sebastianbergmann/phpunit/commit/1dba8c3a4b2dd04a3ff1869f75daaeb6757a14ee
* @link https://github.com/sebastianbergmann/phpunit/commit/6099c5eefccfda860c889f575d58b5fe6cc10c83
*/
trait AssertObjectEquals {

/**
* Asserts that two objects are considered equal based on a custom object comparison
* using a comparator method in the target object.
*
* The custom comparator method is expected to have the following method
* signature: `equals(self $other): bool` (or similar with a different method name).
*
* Basically, the assertion checks the following:
* - A method with name $method must exist on the $actual object.
* - The method must accept exactly one argument and this argument must be required.
* - This parameter must have a classname-based declared type.
* - The $expected object must be compatible with this declared type.
* - The method must have a declared bool return type. (JRF: not verified in this implementation)
* - `$actual->$method($expected)` returns boolean true.
*
* @param object $expected Expected value.
* @param object $actual The value to test.
* @param string $method The name of the comparator method within the object.
* @param string $message Optional failure message to display.
*
* @return void
*
* @throws TypeError When any of the passed arguments do not meet the required type.
* @throws InvalidComparisonMethodException When the comparator method does not comply with the requirements.
*/
public static function assertObjectEquals( $expected, $actual, $method = 'equals', $message = '' ) {
/*
* Parameter input validation.
* In PHPUnit this is done via PHP native type declarations. Emulating this for the polyfill.
*/
if ( \is_object( $expected ) === false ) {
throw new TypeError(
\sprintf(
'Argument 1 passed to assertObjectEquals() must be an object, %s given',
\gettype( $expected )
)
);
}

if ( \is_object( $actual ) === false ) {
throw new TypeError(
\sprintf(
'Argument 2 passed to assertObjectEquals() must be an object, %s given',
\gettype( $actual )
)
);
}

if ( \is_scalar( $method ) === false ) {
throw new TypeError(
\sprintf(
'Argument 3 passed to assertObjectEquals() must be of the type string, %s given',
\gettype( $method )
)
);
}
else {
$method = (string) $method;
}

/*
* Comparator method validation.
*/
$reflObject = new ReflectionObject( $actual );

if ( $reflObject->hasMethod( $method ) === false ) {
throw new InvalidComparisonMethodException(
\sprintf(
'Comparison method %s::%s() does not exist.',
\get_class( $actual ),
$method
)
);
}

$reflMethod = $reflObject->getMethod( $method );

/*
* As the next step, PHPUnit natively would validate the return type,
* but as return type declarations is a PHP 7.0+ feature, the polyfill
* skips this check in favour of checking the type of the actual
* returned value.
*
* Also see the upstream discussion about this:
* {@link https://github.com/sebastianbergmann/phpunit/issues/4707}
*/

/*
* Comparator method parameter requirements validation.
*/
if ( $reflMethod->getNumberOfParameters() !== 1
|| $reflMethod->getNumberOfRequiredParameters() !== 1
) {
throw new InvalidComparisonMethodException(
\sprintf(
'Comparison method %s::%s() does not declare exactly one parameter.',
\get_class( $actual ),
$method
)
);
}

$noDeclaredTypeError = \sprintf(
'Parameter of comparison method %s::%s() does not have a declared type.',
\get_class( $actual ),
$method
);

$notAcceptableTypeError = \sprintf(
'%s is not an accepted argument type for comparison method %s::%s().',
\get_class( $expected ),
\get_class( $actual ),
$method
);

$reflParameter = $reflMethod->getParameters()[0];

if ( \method_exists( $reflParameter, 'hasType' ) ) {
// PHP >= 7.0.
$hasType = $reflParameter->hasType();
if ( $hasType === false ) {
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
}

$type = $reflParameter->getType();
if ( \class_exists( 'ReflectionNamedType' ) ) {
// PHP >= 7.1.
if ( ( $type instanceof ReflectionNamedType ) === false ) {
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
}

$typeName = $type->getName();
}
else {
/*
* PHP 7.0.
* Checking for `ReflectionType` will not throw an error on union types,
* but then again union types are not supported on PHP 7.0.
*/
if ( ( $type instanceof ReflectionType ) === false ) {
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
}

$typeName = (string) $type;
}
}
else {
// PHP < 7.0.
try {
/*
* Using `ReflectionParameter::getClass()` will trigger an autoload of the class,
* but that's okay as for a valid class type that would be triggered on the
* function call to the $method (at the end of this assertion) anyway.
*/
$hasType = $reflParameter->getClass();
} catch ( ReflectionException $e ) {
// Class with a type declaration for a non-existent class.
throw new InvalidComparisonMethodException( $notAcceptableTypeError );
}

if ( ( $hasType instanceof ReflectionClass ) === false ) {
// Array or callable type.
throw new InvalidComparisonMethodException( $noDeclaredTypeError );
}

$typeName = $hasType->name;
}

/*
* Validate that the $expected object complies with the declared parameter type.
*/
if ( $typeName === 'self' ) {
$typeName = \get_class( $actual );
}

if ( ( $expected instanceof $typeName ) === false ) {
throw new InvalidComparisonMethodException( $notAcceptableTypeError );
}

/*
* Execute the comparator method.
*/
$result = $actual->{$method}( $expected );

if ( \is_bool( $result ) === false ) {
throw new InvalidComparisonMethodException(
\sprintf(
'%s::%s() does not return a boolean value.',
\get_class( $actual ),
$method
)
);
}

$msg = \sprintf(
'Failed asserting that two objects are equal. The objects are not equal according to %s::%s()',
\get_class( $actual ),
$method
);

if ( $message !== '' ) {
$msg = $message . \PHP_EOL . $msg;
}

static::assertTrue( $result, $msg );
}
}
8 changes: 8 additions & 0 deletions src/Polyfills/AssertObjectEquals_Empty.php
@@ -0,0 +1,8 @@
<?php

namespace Yoast\PHPUnitPolyfills\Polyfills;

/**
* Empty trait for use with PHPUnit >= 9.4.0 in which this polyfill is not needed.
*/
trait AssertObjectEquals {}
2 changes: 2 additions & 0 deletions src/TestCases/TestCasePHPUnitGte8.php
Expand Up @@ -7,6 +7,7 @@
use Yoast\PHPUnitPolyfills\Polyfills\AssertClosedResource;
use Yoast\PHPUnitPolyfills\Polyfills\AssertFileEqualsSpecializations;
use Yoast\PHPUnitPolyfills\Polyfills\AssertionRenames;
use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals;
use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations;
use Yoast\PHPUnitPolyfills\Polyfills\ExpectExceptionMessageMatches;
use Yoast\PHPUnitPolyfills\Polyfills\ExpectPHPException;
Expand All @@ -26,6 +27,7 @@ abstract class TestCase extends PHPUnit_TestCase {
use AssertClosedResource;
use AssertFileEqualsSpecializations;
use AssertionRenames;
use AssertObjectEquals;
use EqualToSpecializations;
use ExpectExceptionMessageMatches;
use ExpectPHPException;
Expand Down
2 changes: 2 additions & 0 deletions src/TestCases/TestCasePHPUnitLte7.php
Expand Up @@ -11,6 +11,7 @@
use Yoast\PHPUnitPolyfills\Polyfills\AssertionRenames;
use Yoast\PHPUnitPolyfills\Polyfills\AssertIsType;
use Yoast\PHPUnitPolyfills\Polyfills\AssertNumericType;
use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals;
use Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains;
use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations;
use Yoast\PHPUnitPolyfills\Polyfills\ExpectException;
Expand All @@ -37,6 +38,7 @@ abstract class TestCase extends PHPUnit_TestCase {
use AssertionRenames;
use AssertIsType;
use AssertNumericType;
use AssertObjectEquals;
use AssertStringContains;
use EqualToSpecializations;
use ExpectException;
Expand Down

0 comments on commit cb1b31d

Please sign in to comment.