From 2810188fb405c4e1b53fb8c8f91bf5fde56bc282 Mon Sep 17 00:00:00 2001 From: jrfnl Date: Sat, 19 Aug 2023 15:42:04 +0200 Subject: [PATCH] PHPUnit 9.6.11 | AssertObjectProperty trait: polyfill the Assert::assertObject[Not]HasProperty() methods PHPUnit 10.1.0 introduced the new `Assert::assertObjectHasProperty()` and `Assert::assertObjectNotHasProperty()` methods. These methods have now been backported to PHPUnit 9.6.11, so should be made available in the PHPUnit Polyfills 1.x series. This commit: * Adds two traits with the same name. One to polyfill the methods when not available in PHPUnit. The other - an empty trait - to allow for `use`-ing the trait in PHPUnit versions in which the methods are already natively available. * Logic to the custom autoloader which will load the correct trait depending on the PHPUnit version used. * An availability test and functional tests for the functionality polyfilled. Includes: * Adding the new polyfill to the existing `TestCases` classes. Refs: * https://github.com/sebastianbergmann/phpunit/issues/5220 * https://github.com/sebastianbergmann/phpunit/pull/5231 (and follow up commits/PRs) * https://github.com/sebastianbergmann/phpunit/issues/5478 Co-authored-by: Jan-Sverre Riksfjord Co-authored-by: Sebastian Bergmann --- .github/workflows/test.yml | 5 + README.md | 15 + phpunitpolyfills-autoload.php | 21 ++ src/Polyfills/AssertObjectProperty.php | 155 +++++++++ src/Polyfills/AssertObjectProperty_Empty.php | 10 + src/TestCases/TestCasePHPUnitGte8.php | 2 + src/TestCases/TestCasePHPUnitLte7.php | 2 + src/TestCases/XTestCase.php | 2 + tests/Polyfills/AssertObjectPropertyTest.php | 329 ++++++++++++++++++ .../Fixtures/ObjectWithProperties.php | 83 +++++ tests/TestCases/TestCaseTestTrait.php | 13 + 11 files changed, 637 insertions(+) create mode 100644 src/Polyfills/AssertObjectProperty.php create mode 100644 src/Polyfills/AssertObjectProperty_Empty.php create mode 100644 tests/Polyfills/AssertObjectPropertyTest.php create mode 100644 tests/Polyfills/Fixtures/ObjectWithProperties.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6e372e4..21c6164 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,11 @@ jobs: phpunit: '8.1.6' coverage: true experimental: false + - php: '7.4' + # Specifically set at 9.6.10 to test functioning on 9.x before the 9.6.11 assertObject*() backports came in. + phpunit: '9.6.10' + coverage: true + experimental: false - php: '8.0' phpunit: '8.5.16' # PHPUnit 8.x does not support code coverage on PHP 8.x. diff --git a/README.md b/README.md index da9c86b..1d8cca1 100644 --- a/README.md +++ b/README.md @@ -481,6 +481,21 @@ The `assertObjectEquals()` assertion was introduced in PHPUnit 9.4.0. [`Assert::assertObjectEquals()`]: https://docs.phpunit.de/en/9.6/assertions.html#assertobjectequals +#### PHPUnit < 9.6.11: `Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty` + +Polyfills the following method: +| | | +|---------------------------------------|------------------------------------------| +| `Assert::assertObjectHasProperty()` | `Assert::assertObjectNotHasProperty()` | + +These methods were introduced in PHPUnit 10.1.0 as alternatives to the `Assert::assertObjectHasAttribute()` and `Assert::assertObjectNotHasAttribute()` methods, which were hard deprecated (warning) in PHPUnit 9.6.1 and removed in PHPUnit 10.0.0. + +These methods were later backported to the PHPUnit 9 branch and included in the PHPUnit 9.6.11 release. + + + ### Helper traits diff --git a/phpunitpolyfills-autoload.php b/phpunitpolyfills-autoload.php index be62abf..76a9b82 100644 --- a/phpunitpolyfills-autoload.php +++ b/phpunitpolyfills-autoload.php @@ -105,6 +105,10 @@ public static function load( $className ) { self::loadAssertObjectEquals(); return true; + case 'Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty': + self::loadAssertObjectProperty(); + return true; + case 'Yoast\PHPUnitPolyfills\TestCases\TestCase': self::loadTestCase(); return true; @@ -421,6 +425,23 @@ public static function loadAssertObjectEquals() { require_once __DIR__ . '/src/Polyfills/AssertObjectEquals_Empty.php'; } + /** + * Load the AssertObjectProperty 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 loadAssertObjectProperty() { + if ( \method_exists( '\PHPUnit\Framework\Assert', 'assertObjectHasProperty' ) === false ) { + // PHPUnit < 9.6.11. + require_once __DIR__ . '/src/Polyfills/AssertObjectProperty.php'; + return; + } + + // PHPUnit >= 9.6.11. + require_once __DIR__ . '/src/Polyfills/AssertObjectProperty_Empty.php'; + } + /** * Load the appropriate TestCase class based on the PHPUnit version being used. * diff --git a/src/Polyfills/AssertObjectProperty.php b/src/Polyfills/AssertObjectProperty.php new file mode 100644 index 0000000..e80a98c --- /dev/null +++ b/src/Polyfills/AssertObjectProperty.php @@ -0,0 +1,155 @@ +hasProperty( $propertyName ); + static::assertTrue( $hasProperty, $msg ); + } + + /** + * Asserts that an object does not have a specified property. + * + * @param string $propertyName The name of the property. + * @param object $object The object on which to check whether the property exists. + * @param string $message Optional failure message to display. + * + * @return void + * + * @throws TypeError When any of the passed arguments do not meet the required type. + */ + final public static function assertObjectNotHasProperty( $propertyName, $object, $message = '' ) { + /* + * Parameter input validation. + * In PHPUnit this is done via PHP native type declarations. Emulating this for the polyfill, + * including for those PHPUnit versions where we hand off to a native PHPUnit alternative, as + * otherwise the method referenced in the error message would get very confusing and inconsistent. + */ + if ( \is_string( $propertyName ) === false ) { + throw new TypeError( + \sprintf( + 'Argument 1 passed to assertObjectNotHasProperty() must be of type string, %s given', + \gettype( $propertyName ) + ) + ); + } + if ( \is_object( $object ) === false ) { + throw new TypeError( + \sprintf( + 'Argument 2 passed to assertObjectNotHasProperty() must be of type object, %s given', + \gettype( $object ) + ) + ); + } + + if ( \method_exists( '\PHPUnit\Framework\Assert', 'assertObjectNotHasAttribute' ) + && \version_compare( Autoload::getPHPUnitVersion(), '9.6.0', '<=' ) + ) { + // PHPUnit <= 9.6.0. + static::assertObjectNotHasAttribute( $propertyName, $object, $message ); + return; + } + + /* + * PHPUnit 9.6.1+. + * Note: letting this polyfill code kick in for PHPUnit 9.6.1+ as well + * to prevent the PHPUnit deprecation notice showing. + */ + $msg = self::assertObjectHasPropertyFailureDescription( $object ); + $msg .= \sprintf( ' does not have property "%s".', $propertyName ); + if ( $message !== '' ) { + $msg = $message . \PHP_EOL . $msg; + } + + $hasProperty = ( new ReflectionObject( $object ) )->hasProperty( $propertyName ); + static::assertFalse( $hasProperty, $msg ); + } + + /** + * Returns the description of the failure. + * + * @param object $object The object under test. + * + * @return string + */ + private static function assertObjectHasPropertyFailureDescription( $object ) { + return \sprintf( + 'Failed asserting that object of class "%s"', + \get_class( $object ) + ); + } +} diff --git a/src/Polyfills/AssertObjectProperty_Empty.php b/src/Polyfills/AssertObjectProperty_Empty.php new file mode 100644 index 0000000..ae78205 --- /dev/null +++ b/src/Polyfills/AssertObjectProperty_Empty.php @@ -0,0 +1,10 @@ += 9.6.11 in which this polyfill is not needed. + * + * @since 1.1.0 + */ +trait AssertObjectProperty {} diff --git a/src/TestCases/TestCasePHPUnitGte8.php b/src/TestCases/TestCasePHPUnitGte8.php index cfe397c..4a54cf5 100644 --- a/src/TestCases/TestCasePHPUnitGte8.php +++ b/src/TestCases/TestCasePHPUnitGte8.php @@ -8,6 +8,7 @@ use Yoast\PHPUnitPolyfills\Polyfills\AssertFileEqualsSpecializations; use Yoast\PHPUnitPolyfills\Polyfills\AssertionRenames; use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals; +use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty; use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations; use Yoast\PHPUnitPolyfills\Polyfills\ExpectExceptionMessageMatches; use Yoast\PHPUnitPolyfills\Polyfills\ExpectPHPException; @@ -28,6 +29,7 @@ abstract class TestCase extends PHPUnit_TestCase { use AssertFileEqualsSpecializations; use AssertionRenames; use AssertObjectEquals; + use AssertObjectProperty; use EqualToSpecializations; use ExpectExceptionMessageMatches; use ExpectPHPException; diff --git a/src/TestCases/TestCasePHPUnitLte7.php b/src/TestCases/TestCasePHPUnitLte7.php index 34cf29b..1cdfd8c 100644 --- a/src/TestCases/TestCasePHPUnitLte7.php +++ b/src/TestCases/TestCasePHPUnitLte7.php @@ -12,6 +12,7 @@ use Yoast\PHPUnitPolyfills\Polyfills\AssertIsType; use Yoast\PHPUnitPolyfills\Polyfills\AssertNumericType; use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals; +use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty; use Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains; use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations; use Yoast\PHPUnitPolyfills\Polyfills\ExpectException; @@ -39,6 +40,7 @@ abstract class TestCase extends PHPUnit_TestCase { use AssertIsType; use AssertNumericType; use AssertObjectEquals; + use AssertObjectProperty; use AssertStringContains; use EqualToSpecializations; use ExpectException; diff --git a/src/TestCases/XTestCase.php b/src/TestCases/XTestCase.php index 4db9148..c8dd875 100644 --- a/src/TestCases/XTestCase.php +++ b/src/TestCases/XTestCase.php @@ -12,6 +12,7 @@ use Yoast\PHPUnitPolyfills\Polyfills\AssertIsType; use Yoast\PHPUnitPolyfills\Polyfills\AssertNumericType; use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals; +use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty; use Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains; use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations; use Yoast\PHPUnitPolyfills\Polyfills\ExpectException; @@ -41,6 +42,7 @@ abstract class XTestCase extends PHPUnit_TestCase { use AssertIsType; use AssertNumericType; use AssertObjectEquals; + use AssertObjectProperty; use AssertStringContains; use EqualToSpecializations; use ExpectException; diff --git a/tests/Polyfills/AssertObjectPropertyTest.php b/tests/Polyfills/AssertObjectPropertyTest.php new file mode 100644 index 0000000..0c018d4 --- /dev/null +++ b/tests/Polyfills/AssertObjectPropertyTest.php @@ -0,0 +1,329 @@ +=' ) ) { + $this->markTestSkipped( 'PHPUnit native implementation relies on strict_types and when not used will accept scalar inputs' ); + } + + $this->expectException( 'TypeError' ); + + if ( \PHP_VERSION_ID >= 80000 + && \version_compare( PHPUnit_Version::id(), '9.6.11', '>=' ) + ) { + $msg = 'assertObjectHasProperty(): Argument #1 ($propertyName) must be of type string, '; + $this->expectExceptionMessage( $msg ); + } + else { + // PHP 5/7. + $pattern = '`^Argument 1 passed to [^\s]*assertObjectHasProperty\(\) must be of (the )?type string, `'; + $this->expectExceptionMessageMatches( $pattern ); + } + + $this->assertObjectHasProperty( $input, new stdClass() ); + } + + /** + * Verify that the assertObjectNotHasProperty() method throws an error when the $propertyName parameter is not a scalar. + * + * @dataProvider dataAssertObjectPropertyFailsOnInvalidInputTypePropertyName + * + * @param mixed $input Non-scalar value. + * + * @return void + */ + public function testAssertObjectNotHasPropertyFailsOnInvalidInputTypePropertyName( $input ) { + if ( \is_scalar( $input ) && \version_compare( PHPUnit_Version::id(), '9.6.11', '>=' ) ) { + $this->markTestSkipped( 'PHPUnit native implementation relies on strict_types and when not used will accept scalar inputs' ); + } + + $this->expectException( 'TypeError' ); + + if ( \PHP_VERSION_ID >= 80000 + && \version_compare( PHPUnit_Version::id(), '9.6.11', '>=' ) + ) { + $msg = 'assertObjectNotHasProperty(): Argument #1 ($propertyName) must be of type string, '; + $this->expectExceptionMessage( $msg ); + } + else { + // PHP 5/7. + $pattern = '`^Argument 1 passed to [^\s]*assertObjectNotHasProperty\(\) must be of (the )?type string, `'; + $this->expectExceptionMessageMatches( $pattern ); + } + + $this->assertObjectNotHasProperty( $input, new stdClass() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function dataAssertObjectPropertyFailsOnInvalidInputTypePropertyName() { + // Only testing closed resource to not leak an open resource. + $resource = \fopen( __DIR__ . '/Fixtures/test.txt', 'r' ); + \fclose( $resource ); + + return [ + 'null' => [ null ], + 'boolean' => [ true ], + 'integer' => [ 10 ], + 'float' => [ 5.34 ], + 'array' => [ [ 1, 2, 3 ] ], + 'object' => [ new stdClass() ], + 'closed resource' => [ $resource ], + ]; + } + + /** + * Verify that the assertObjectHasProperty() method throws an error when the $object parameter is not an object. + * + * @dataProvider dataAssertObjectPropertyFailsOnInvalidInputTypeObject + * + * @param mixed $input Non-object value. + * + * @return void + */ + public function testAssertObjectHasPropertyFailsOnInvalidInputTypeObject( $input ) { + $this->expectException( 'TypeError' ); + + if ( \PHP_VERSION_ID >= 80000 + && \version_compare( PHPUnit_Version::id(), '9.6.11', '>=' ) + ) { + $msg = 'assertObjectHasProperty(): Argument #2 ($object) must be of type object, '; + $this->expectExceptionMessage( $msg ); + } + else { + // PHP 5/7. + $pattern = '`^Argument 2 passed to [^\s]*assertObjectHasProperty\(\) must be (of type|an) object, `'; + $this->expectExceptionMessageMatches( $pattern ); + } + + $this->assertObjectHasProperty( 'propertyName', $input ); + } + + /** + * Verify that the assertObjectNotHasProperty() method throws an error when the $object parameter is not an object. + * + * @dataProvider dataAssertObjectPropertyFailsOnInvalidInputTypeObject + * + * @param mixed $input Non-object value. + * + * @return void + */ + public function testAssertObjectNotHasPropertyFailsOnInvalidInputTypeObject( $input ) { + $this->expectException( 'TypeError' ); + + if ( \PHP_VERSION_ID >= 80000 + && \version_compare( PHPUnit_Version::id(), '9.6.11', '>=' ) + ) { + $msg = 'assertObjectNotHasProperty(): Argument #2 ($object) must be of type object, '; + $this->expectExceptionMessage( $msg ); + } + else { + // PHP 5/7. + $pattern = '`^Argument 2 passed to [^\s]*assertObjectNotHasProperty\(\) must be (of type|an) object, `'; + $this->expectExceptionMessageMatches( $pattern ); + } + + static::assertObjectNotHasProperty( 'propertyName', $input ); + } + + /** + * Data provider. + * + * @return array + */ + public static function dataAssertObjectPropertyFailsOnInvalidInputTypeObject() { + // Only testing closed resource to not leak an open resource. + $resource = \fopen( __DIR__ . '/Fixtures/test.txt', 'r' ); + \fclose( $resource ); + + return [ + 'null' => [ null ], + 'boolean' => [ true ], + 'integer' => [ 10 ], + 'float' => [ 5.34 ], + 'string' => [ 'text' ], + 'array' => [ [ 1, 2, 3 ] ], + 'closed resource' => [ $resource ], + ]; + } + + /** + * Verify availability and functionality of the assertObjectHasProperty() method. + * + * @dataProvider dataAssertObjectPropertyDeclaredProps + * + * @param string $name The property name to look for. + * + * @return void + */ + public function testAssertObjectHasPropertyPass( $name ) { + $this->assertObjectHasProperty( $name, new ObjectWithProperties() ); + } + + /** + * Verify availability and functionality of the assertObjectNotHasProperty() method. + * + * @dataProvider dataAssertObjectPropertyUnavailableProps + * + * @param string $name The property name to look for. + * + * @return void + */ + public function testAssertObjectNotHasPropertyPass( $name ) { + self::assertObjectNotHasProperty( $name, new ObjectWithProperties() ); + } + + /** + * Verify that the assertObjectHasProperty() method throws an error when the property does not exist on the object. + * + * @dataProvider dataAssertObjectPropertyUnavailableProps + * + * @param string $name The property name to look for. + * + * @return void + */ + public function testAssertObjectHasPropertyFails( $name ) { + $pattern = \sprintf( + '`^Failed asserting that object of class "[^\s]*ObjectWithProperties" has (?:property|attribute) "%s"\.`', + \preg_quote( $name, '`' ) + ); + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessageMatches( $pattern ); + + static::assertObjectHasProperty( $name, new ObjectWithProperties() ); + } + + /** + * Verify that the assertObjectNotHasProperty() method throws an error when the property does exist on the object. + * + * @dataProvider dataAssertObjectPropertyDeclaredProps + * + * @param string $name The property name to look for. + * + * @return void + */ + public function testAssertObjectNotHasPropertyFails( $name ) { + $pattern = \sprintf( + '`^Failed asserting that object of class "[^\s]*ObjectWithProperties" does not have (?:property|attribute) "%s"\.`', + \preg_quote( $name, '`' ) + ); + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessageMatches( $pattern ); + + $this->assertObjectNotHasProperty( $name, new ObjectWithProperties() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function dataAssertObjectPropertyDeclaredProps() { + return [ + 'declared public property without default' => [ 'publicNoDefaultValue' ], + 'declared protected property without default' => [ 'protectedNoDefaultValue' ], + 'declared private property without default' => [ 'privateNoDefaultValue' ], + 'declared public property with default' => [ 'publicWithDefaultValue' ], + 'declared protected property with default' => [ 'protectedWithDefaultValue' ], + 'declared private property with default' => [ 'privateWithDefaultValue' ], + 'unset declared public property' => [ 'unsetPublic' ], + 'unset declared protected property' => [ 'unsetProtected' ], + 'unset declared private property' => [ 'unsetPrivate' ], + ]; + } + + /** + * Data provider. + * + * @return array + */ + public static function dataAssertObjectPropertyUnavailableProps() { + return [ + 'property which is not declared' => [ 'doesNotExist' ], + ]; + } + + /** + * Verify that the assertObjectHasProperty() method fails a test with a custom failure message, + * when the custom $message parameter has been passed. + * + * @return void + */ + public function testAssertObjectHasPropertyFailsWithCustomMessage() { + $pattern = '`^This assertion failed for reason XYZ\s+Failed asserting that object of class `'; + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessageMatches( $pattern ); + + $this->assertObjectHasProperty( 'doesNotExist', new ObjectWithProperties(), 'This assertion failed for reason XYZ' ); + } + + /** + * Verify that the assertObjectNotHasProperty() method fails a test with a custom failure message, + * when the custom $message parameter has been passed. + * + * @return void + */ + public function testAssertObjectNotHasPropertyFailsWithCustomMessage() { + $pattern = '`^This assertion failed for reason XYZ\s+Failed asserting that object of class `'; + + $this->expectException( $this->getAssertionFailedExceptionName() ); + $this->expectExceptionMessageMatches( $pattern ); + + $this->assertObjectNotHasProperty( 'protectedWithDefaultValue', new ObjectWithProperties(), '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/ObjectWithProperties.php b/tests/Polyfills/Fixtures/ObjectWithProperties.php new file mode 100644 index 0000000..99bb0fd --- /dev/null +++ b/tests/Polyfills/Fixtures/ObjectWithProperties.php @@ -0,0 +1,83 @@ +existsButUnsetPublic, + $this->existsButUnsetProtected, + $this->existsButUnsetPrivate + ); + } +} diff --git a/tests/TestCases/TestCaseTestTrait.php b/tests/TestCases/TestCaseTestTrait.php index 40c713c..cd20c47 100644 --- a/tests/TestCases/TestCaseTestTrait.php +++ b/tests/TestCases/TestCaseTestTrait.php @@ -3,6 +3,7 @@ namespace Yoast\PHPUnitPolyfills\Tests\TestCases; use Exception; +use stdClass; use Yoast\PHPUnitPolyfills\Tests\Polyfills\AssertFileEqualsSpecializationsTest; use Yoast\PHPUnitPolyfills\Tests\Polyfills\Fixtures\ValueObject; @@ -177,4 +178,16 @@ public function testAvailabilityAssertObjectEquals() { $actual = new ValueObject( 'test' ); $this->assertObjectEquals( $expected, $actual ); } + + /** + * Verify availability of trait polyfilled PHPUnit methods [15]. + * + * @return void + */ + public function testAvailabilityAssertObjectProperty() { + $object = new stdClass(); + $object->prop = true; + + self::assertObjectHasProperty( 'prop', $object ); + } }