diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 710fb4c..67c1ea3 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -145,4 +145,15 @@ /tests/TestCases/TestCaseTestTrait\.php$ + + + /tests/Polyfills/Fixtures/ChildValueObject\.php$ + /tests/Polyfills/Fixtures/ValueObject\.php$ + /tests/Polyfills/Fixtures/ValueObjectUnion\.php$ + + + /tests/Polyfills/Fixtures/ValueObjectUnion\.php$ + /tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType\.php$ + + diff --git a/README.md b/README.md index 5d870f8..f78a1f6 100644 --- a/README.md +++ b/README.md @@ -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 + + ### Helper traits diff --git a/composer.json b/composer.json index 9d0e049..5cf9d22 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/phpunitpolyfills-autoload.php b/phpunitpolyfills-autoload.php index 5376f7a..318ab6a 100644 --- a/phpunitpolyfills-autoload.php +++ b/phpunitpolyfills-autoload.php @@ -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; @@ -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 @@ -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. * diff --git a/src/Exceptions/InvalidComparisonMethodException.php b/src/Exceptions/InvalidComparisonMethodException.php new file mode 100644 index 0000000..db05e34 --- /dev/null +++ b/src/Exceptions/InvalidComparisonMethodException.php @@ -0,0 +1,23 @@ +getMessage() . \PHP_EOL; + } +} diff --git a/src/Polyfills/AssertObjectEquals.php b/src/Polyfills/AssertObjectEquals.php new file mode 100644 index 0000000..4e8be06 --- /dev/null +++ b/src/Polyfills/AssertObjectEquals.php @@ -0,0 +1,234 @@ +$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 ); + } +} diff --git a/src/Polyfills/AssertObjectEquals_Empty.php b/src/Polyfills/AssertObjectEquals_Empty.php new file mode 100644 index 0000000..4390a97 --- /dev/null +++ b/src/Polyfills/AssertObjectEquals_Empty.php @@ -0,0 +1,8 @@ += 9.4.0 in which this polyfill is not needed. + */ +trait AssertObjectEquals {} diff --git a/src/TestCases/TestCasePHPUnitGte8.php b/src/TestCases/TestCasePHPUnitGte8.php index f819be9..cfe397c 100644 --- a/src/TestCases/TestCasePHPUnitGte8.php +++ b/src/TestCases/TestCasePHPUnitGte8.php @@ -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; @@ -26,6 +27,7 @@ abstract class TestCase extends PHPUnit_TestCase { use AssertClosedResource; use AssertFileEqualsSpecializations; use AssertionRenames; + use AssertObjectEquals; use EqualToSpecializations; use ExpectExceptionMessageMatches; use ExpectPHPException; diff --git a/src/TestCases/TestCasePHPUnitLte7.php b/src/TestCases/TestCasePHPUnitLte7.php index e906972..34cf29b 100644 --- a/src/TestCases/TestCasePHPUnitLte7.php +++ b/src/TestCases/TestCasePHPUnitLte7.php @@ -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; @@ -37,6 +38,7 @@ abstract class TestCase extends PHPUnit_TestCase { use AssertionRenames; use AssertIsType; use AssertNumericType; + use AssertObjectEquals; use AssertStringContains; use EqualToSpecializations; use ExpectException; diff --git a/src/TestCases/XTestCase.php b/src/TestCases/XTestCase.php index 3d4dbd9..4db9148 100644 --- a/src/TestCases/XTestCase.php +++ b/src/TestCases/XTestCase.php @@ -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; @@ -39,6 +40,7 @@ abstract class XTestCase extends PHPUnit_TestCase { use AssertionRenames; use AssertIsType; use AssertNumericType; + use AssertObjectEquals; use AssertStringContains; use EqualToSpecializations; use ExpectException; diff --git a/tests/Polyfills/AssertObjectEqualsPHPUnitLt940Test.php b/tests/Polyfills/AssertObjectEqualsPHPUnitLt940Test.php new file mode 100644 index 0000000..90a0e33 --- /dev/null +++ b/tests/Polyfills/AssertObjectEqualsPHPUnitLt940Test.php @@ -0,0 +1,314 @@ +=' ) ) { + $this->markTestSkipped( 'This test can not be run with the PHPUnit native implementation of assertObjectEquals()' ); + } + } + + /** + * Verify availability of the assertObjectEquals() method. + * + * @return void + */ + public function testAssertObjectEquals() { + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual ); + } + + /** + * Verify behaviour when passing the $method parameter. + * + * @return void + */ + public function testAssertObjectEqualsCustomMethodName() { + $expected = new ValueObjectNoReturnType( 'different name' ); + $actual = new ValueObjectNoReturnType( 'different name' ); + $this->assertObjectEquals( $expected, $actual, 'nonDefaultName' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $expected parameter is not an object. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnExpectedNotObject() { + $pattern = '`^Argument 1 passed to [^\s]*assertObjectEquals\(\) must be an object, string given`'; + + $this->expectException( 'TypeError' ); + $this->expectExceptionMessageMatches( $pattern ); + + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( 'className', $actual ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $actual parameter is not an object. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnActualNotObject() { + $pattern = '`^Argument 2 passed to [^\s]*assertObjectEquals\(\) must be an object, string given`'; + + $this->expectException( 'TypeError' ); + $this->expectExceptionMessageMatches( $pattern ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, 'className' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter is not + * juggleable to a string. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodNotJuggleableToString() { + $pattern = '`^Argument 3 passed to [^\s]*assertObjectEquals\(\) must be of the type string, array given`'; + + $this->expectException( 'TypeError' ); + $this->expectExceptionMessageMatches( $pattern ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, [] ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $actual object + * does not contain a method called $method. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodNotDeclared() { + $msg = 'Comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::doesNotExist() does not exist.'; + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'doesNotExist' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method accepts more than one parameter. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodAllowsForMoreParams() { + $msg = 'Comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::equalsTwoParams() does not declare exactly one parameter.'; + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsTwoParams' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method is not a required parameter. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamNotRequired() { + $msg = 'Comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::equalsParamNotRequired() does not declare exactly one parameter.'; + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNotRequired' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * does not have a type declaration. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamMissingTypeDeclaration() { + $msg = 'Parameter of comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::equalsParamNoType() does not have a declared type.'; + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNoType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * has a PHP 8.0+ union type declaration. + * + * @requires PHP 8.0 + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamHasUnionTypeDeclaration() { + $msg = 'Parameter of comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectUnionNoReturnType::equalsParamUnionType() does not have a declared type.'; + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectUnionNoReturnType( 'test' ); + $actual = new ValueObjectUnionNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamUnionType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * does not have a class-based type declaration. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamNonClassTypeDeclaration() { + $msg = 'is not an accepted argument type for comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::equalsParamNonClassType().'; + if ( \PHP_VERSION_ID < 70000 ) { + $msg = 'Parameter of comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::equalsParamNonClassType() does not have a declared type.'; + } + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNonClassType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * has a class-based type declaration, but for a class which doesn't exist. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamNonExistentClassTypeDeclaration() { + $msg = 'is not an accepted argument type for comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::equalsParamNonExistentClassType().'; + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNonExistentClassType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when $expected is not + * an instance of the type declared for the $method parameter. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamTypeMismatch() { + $msg = 'is not an accepted argument type for comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectNoReturnType::equals().'; + + $this->expectException( self::COMPARATOR_EXCEPTION ); + $this->expectExceptionMessage( $msg ); + + $actual = new ValueObjectNoReturnType( 'test' ); + $this->assertObjectEquals( new stdClass(), $actual ); + } + + /** + * Verify that the assertObjectEquals() method fails a test when a call to method + * determines that the objects are not equal. + * + * @return void + */ + public function testAssertObjectEqualsFailsAsNotEqual() { + $msg = 'Failed asserting that two objects are equal.'; + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'testing... 1..2..3' ); + $this->assertObjectEquals( $expected, $actual ); + } + + /** + * Verify that the assertObjectEquals() method fails a test with a custom failure message, when a call + * to the method determines that the objects are not equal and the custom $message parameter has been passed. + * + * @return void + */ + public function testAssertObjectEqualsFailsAsNotEqualWithCustomMessage() { + $pattern = '`^This assertion failed for reason XYZ\s+Failed asserting that two objects are equal\.`'; + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessageMatches( $pattern ); + + $expected = new ValueObjectNoReturnType( 'test' ); + $actual = new ValueObjectNoReturnType( 'testing... 1..2..3' ); + $this->assertObjectEquals( $expected, $actual, 'equals', 'This assertion failed for reason XYZ' ); + } + + /** + * Helper function: retrieve the name of the "assertion failed" exception to expect (PHPUnit cross-version). + * + * @return string + */ + public function getAssertionFailedExceptionName() { + $exception = 'PHPUnit\Framework\AssertionFailedError'; + if ( \class_exists( 'PHPUnit_Framework_AssertionFailedError' ) ) { + // PHPUnit < 6. + $exception = 'PHPUnit_Framework_AssertionFailedError'; + } + + return $exception; + } +} diff --git a/tests/Polyfills/AssertObjectEqualsTest.php b/tests/Polyfills/AssertObjectEqualsTest.php new file mode 100644 index 0000000..384a62a --- /dev/null +++ b/tests/Polyfills/AssertObjectEqualsTest.php @@ -0,0 +1,391 @@ +assertObjectEquals( $expected, $actual ); + } + + /** + * Verify behaviour when passing the $method parameter. + * + * @return void + */ + public function testAssertObjectEqualsCustomMethodName() { + $expected = new ValueObject( 'different name' ); + $actual = new ValueObject( 'different name' ); + $this->assertObjectEquals( $expected, $actual, 'nonDefaultName' ); + } + + /** + * Verify behaviour when $expected is a child of $actual. + * + * @return void + */ + public function testAssertObjectEqualsExpectedChildOfActual() { + $expected = new ChildValueObject( 'inheritance' ); + $actual = new ValueObject( 'inheritance' ); + $this->assertObjectEquals( $expected, $actual ); + } + + /** + * Verify behaviour when $actual is a child of $expected. + * + * @return void + */ + public function testAssertObjectEqualsActualChildOfExpected() { + $expected = new ValueObject( 'inheritance' ); + $actual = new ChildValueObject( 'inheritance' ); + $this->assertObjectEquals( $expected, $actual ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $expected parameter is not an object. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnExpectedNotObject() { + $this->expectException( 'TypeError' ); + + if ( \PHP_VERSION_ID >= 80000 + && \version_compare( PHPUnit_Version::id(), '9.4.0', '>=' ) + ) { + $msg = 'assertObjectEquals(): Argument #1 ($expected) must be of type object, string given'; + $this->expectExceptionMessage( $msg ); + } + else { + // PHP 5/7 or PHP 8 with the polyfill. + $pattern = '`^Argument 1 passed to [^\s]*assertObjectEquals\(\) must be an object, string given`'; + $this->expectExceptionMessageMatches( $pattern ); + } + + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( 'className', $actual ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $actual parameter is not an object. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnActualNotObject() { + $this->expectException( 'TypeError' ); + + if ( \PHP_VERSION_ID >= 80000 + && \version_compare( PHPUnit_Version::id(), '9.4.0', '>=' ) + ) { + $msg = 'assertObjectEquals(): Argument #2 ($actual) must be of type object, string given'; + $this->expectExceptionMessage( $msg ); + } + else { + // PHP 5/7. + $pattern = '`^Argument 2 passed to [^\s]*assertObjectEquals\(\) must be an object, string given`'; + $this->expectExceptionMessageMatches( $pattern ); + } + + $expected = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, 'className' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter is not + * juggleable to a string. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodNotJuggleableToString() { + $this->expectException( 'TypeError' ); + + if ( \PHP_VERSION_ID >= 80000 + && \version_compare( PHPUnit_Version::id(), '9.4.0', '>=' ) + ) { + $msg = 'assertObjectEquals(): Argument #3 ($method) must be of type string, array given'; + $this->expectExceptionMessage( $msg ); + } + else { + // PHP 5/7. + $pattern = '`^Argument 3 passed to [^\s]*assertObjectEquals\(\) must be of the type string, array given`'; + $this->expectExceptionMessageMatches( $pattern ); + } + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual, [] ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $actual object + * does not contain a method called $method. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodNotDeclared() { + $msg = 'Comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject::doesNotExist() does not exist.'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotExistException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotExistException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'doesNotExist' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method accepts more than one parameter. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodAllowsForMoreParams() { + $msg = 'Comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject::equalsTwoParams() does not declare exactly one parameter.'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareExactlyOneParameterException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareExactlyOneParameterException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsTwoParams' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method is not a required parameter. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamNotRequired() { + $msg = 'Comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject::equalsParamNotRequired() does not declare exactly one parameter.'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareExactlyOneParameterException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareExactlyOneParameterException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNotRequired' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * does not have a type declaration. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamMissingTypeDeclaration() { + $msg = 'Parameter of comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject::equalsParamNoType() does not have a declared type.'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareParameterTypeException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareParameterTypeException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNoType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * has a PHP 8.0+ union type declaration. + * + * @requires PHP 8.0 + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamHasUnionTypeDeclaration() { + $msg = 'Parameter of comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObjectUnion::equalsParamUnionType() does not have a declared type.'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareParameterTypeException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotDeclareParameterTypeException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObjectUnion( 'test' ); + $actual = new ValueObjectUnion( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamUnionType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * does not have a class-based type declaration. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamNonClassTypeDeclaration() { + $msg = 'is not an accepted argument type for comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject::equalsParamNonClassType().'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotAcceptParameterTypeException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotAcceptParameterTypeException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNonClassType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when the $method parameter + * has a class-based type declaration, but for a class which doesn't exist. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamNonExistentClassTypeDeclaration() { + $msg = 'is not an accepted argument type for comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject::equalsParamNonExistentClassType().'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotAcceptParameterTypeException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotAcceptParameterTypeException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual, 'equalsParamNonExistentClassType' ); + } + + /** + * Verify that the assertObjectEquals() method throws an error when $expected is not + * an instance of the type declared for the $method parameter. + * + * @return void + */ + public function testAssertObjectEqualsFailsOnMethodParamTypeMismatch() { + $msg = 'is not an accepted argument type for comparison method Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject::equals().'; + + $exception = self::COMPARATOR_EXCEPTION; + if ( \class_exists( 'PHPUnit\Framework\ComparisonMethodDoesNotAcceptParameterTypeException' ) ) { + // PHPUnit > 9.4.0. + $exception = 'PHPUnit\Framework\ComparisonMethodDoesNotAcceptParameterTypeException'; + } + + $this->expectException( $exception ); + $this->expectExceptionMessage( $msg ); + + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( new stdClass(), $actual ); + } + + /** + * Verify that the assertObjectEquals() method fails a test when a call to method + * determines that the objects are not equal. + * + * @return void + */ + public function testAssertObjectEqualsFailsAsNotEqual() { + $msg = 'Failed asserting that two objects are equal.'; + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessage( $msg ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'testing... 1..2..3' ); + $this->assertObjectEquals( $expected, $actual ); + } + + /** + * Verify that the assertObjectEquals() method fails a test with a custom failure message, when a call + * to the method determines that the objects are not equal and the custom $message parameter has been passed. + * + * @return void + */ + public function testAssertObjectEqualsFailsAsNotEqualWithCustomMessage() { + $pattern = '`^This assertion failed for reason XYZ\s+Failed asserting that two objects are equal\.`'; + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessageMatches( $pattern ); + + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'testing... 1..2..3' ); + $this->assertObjectEquals( $expected, $actual, 'equals', 'This assertion failed for reason XYZ' ); + } + + /** + * Helper function: retrieve the name of the "assertion failed" exception to expect (PHPUnit cross-version). + * + * @return string + */ + public function getAssertionFailedExceptionName() { + $exception = 'PHPUnit\Framework\AssertionFailedError'; + if ( \class_exists( 'PHPUnit_Framework_AssertionFailedError' ) ) { + // PHPUnit < 6. + $exception = 'PHPUnit_Framework_AssertionFailedError'; + } + + return $exception; + } +} diff --git a/tests/Polyfills/Fixtures/ChildValueObject.php b/tests/Polyfills/Fixtures/ChildValueObject.php new file mode 100644 index 0000000..6eaafed --- /dev/null +++ b/tests/Polyfills/Fixtures/ChildValueObject.php @@ -0,0 +1,36 @@ +value = $value; + } + + /** + * Comparator method: correctly declared. + * + * @param ValueObject $other Object to compare. + * + * @return bool + */ + public function equals( ValueObject $other ): bool { + return ( $this->value === $other->value ); + } +} diff --git a/tests/Polyfills/Fixtures/ValueObject.php b/tests/Polyfills/Fixtures/ValueObject.php new file mode 100644 index 0000000..e2305dd --- /dev/null +++ b/tests/Polyfills/Fixtures/ValueObject.php @@ -0,0 +1,105 @@ +value = $value; + } + + /** + * Comparator method: correctly declared. + * + * @param self $other Object to compare. + * + * @return bool + */ + public function equals( self $other ): bool { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: correctly declared and with the class name as type instead of `self`. + * + * @param ValueObject $other Object to compare. + * + * @return bool + */ + public function nonDefaultName( ValueObject $other ): bool { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - more than one parameter. + * + * @param ValueObject $other Object to compare. + * @param mixed $param Just testing. + * + * @return bool + */ + public function equalsTwoParams( $other, $param ): bool { + return ( $param && $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter is not required. + * + * @param self|null $other Object to compare. + * + * @return bool + */ + public function equalsParamNotRequired( self $other = null ): bool { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter is not typed. + * + * @param ValueObject $other Object to compare. + * + * @return bool + */ + public function equalsParamNoType( $other ): bool { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter has a non-classname type. + * + * @param array $other Object to compare. + * + * @return bool + */ + public function equalsParamNonClassType( array $other ): bool { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter has a non-existent classname type. + * + * @param ClassWhichDoesntExist $other Object to compare. + * + * @return bool + */ + public function equalsParamNonExistentClassType( ClassWhichDoesntExist $other ): bool { + return ( $this->value === $other->value ); + } +} diff --git a/tests/Polyfills/Fixtures/ValueObjectNoReturnType.php b/tests/Polyfills/Fixtures/ValueObjectNoReturnType.php new file mode 100644 index 0000000..52775a0 --- /dev/null +++ b/tests/Polyfills/Fixtures/ValueObjectNoReturnType.php @@ -0,0 +1,105 @@ +value = $value; + } + + /** + * Comparator method: correctly declared. + * + * @param ValueObjectNoReturnType $other Object to compare. + * + * @return bool + */ + public function equals( ValueObjectNoReturnType $other ) { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: correctly declared and with self as type instead of the class name. + * + * @param self $other Object to compare. + * + * @return bool + */ + public function nonDefaultName( self $other ) { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - more than one parameter. + * + * @param ValueObjectNoReturnType $other Object to compare. + * @param mixed $param Just testing. + * + * @return bool + */ + public function equalsTwoParams( $other, $param ) { + return ( $param && $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter is not required. + * + * @param self|null $other Object to compare. + * + * @return bool + */ + public function equalsParamNotRequired( self $other = null ) { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter is not typed. + * + * @param ValueObjectNoReturnType $other Object to compare. + * + * @return bool + */ + public function equalsParamNoType( $other ) { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter has a non-classname type. + * + * @param array $other Object to compare. + * + * @return bool + */ + public function equalsParamNonClassType( array $other ) { + return ( $this->value === $other->value ); + } + + /** + * Comparator method: incorrectly declared - parameter has a non-existent classname type. + * + * @param ClassWhichDoesntExist $other Object to compare. + * + * @return bool + */ + public function equalsParamNonExistentClassType( ClassWhichDoesntExist $other ) { + return ( $this->value === $other->value ); + } +} diff --git a/tests/Polyfills/Fixtures/ValueObjectUnion.php b/tests/Polyfills/Fixtures/ValueObjectUnion.php new file mode 100644 index 0000000..0291fc7 --- /dev/null +++ b/tests/Polyfills/Fixtures/ValueObjectUnion.php @@ -0,0 +1,36 @@ +value = $value; + } + + /** + * Comparator method: incorrectly declared - parameter has a union type. + * + * @param self|OtherClass|array $other Object to compare. + * + * @return bool + */ + public function equalsParamUnionType( self|OtherClass|array $other ): bool { + return ( $this->value === $other->value ); + } +} diff --git a/tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType.php b/tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType.php new file mode 100644 index 0000000..efa2387 --- /dev/null +++ b/tests/Polyfills/Fixtures/ValueObjectUnionNoReturnType.php @@ -0,0 +1,36 @@ +value = $value; + } + + /** + * Comparator method: incorrectly declared - parameter has a union type. + * + * @param self|OtherClass|array $other Object to compare. + * + * @return bool + */ + public function equalsParamUnionType( self|OtherClass|array $other ) { + return ( $this->value === $other->value ); + } +} diff --git a/tests/TestCases/TestCaseTestTrait.php b/tests/TestCases/TestCaseTestTrait.php index 54601f5..a4a80f0 100644 --- a/tests/TestCases/TestCaseTestTrait.php +++ b/tests/TestCases/TestCaseTestTrait.php @@ -4,6 +4,7 @@ use Exception; use Yoast\PHPUnitPolyfills\Tests\Polyfills\AssertFileEqualsSpecializationsTest; +use Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject; /** * Tests for the TestCase setups. @@ -163,4 +164,17 @@ public function testAvailabilityAssertClosedResource() { public function testAvailabilityEqualToSpecializations() { self::assertThat( [ 2, 3, 1 ], $this->equalToCanonicalizing( [ 3, 2, 1 ] ) ); } + + /** + * Verify availability of trait polyfilled PHPUnit methods [14]. + * + * @requires PHP 7.0 + * + * @return void + */ + public function testAvailabilityAssertObjectEquals() { + $expected = new ValueObject( 'test' ); + $actual = new ValueObject( 'test' ); + $this->assertObjectEquals( $expected, $actual ); + } }