diff --git a/README.md b/README.md index 5c0eee5..bc2242f 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,18 @@ This method was introduced in PHPUnit 10.0.0. [`Assert::assertIsList()`]: https://docs.phpunit.de/en/main/assertions.html#assertislist +#### PHPUnit < 10.1.0: `Yoast\PHPUnitPolyfills\Polyfills\AssertIsList` + +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. + +[`Assert::assertObjectHasProperty()`]: https://docs.phpunit.de/en/main/assertions.html#assertObjectHasProperty +[`Assert::assertObjectNotHasProperty()`]: https://docs.phpunit.de/en/main/assertions.html#assertObjectHasProperty + ### Helper traits diff --git a/phpunitpolyfills-autoload.php b/phpunitpolyfills-autoload.php index 37064f2..11ab66d 100644 --- a/phpunitpolyfills-autoload.php +++ b/phpunitpolyfills-autoload.php @@ -99,6 +99,10 @@ public static function load( $className ) { self::loadAssertIgnoringLineEndings(); return true; + case 'Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty': + self::loadAssertObjectProperty(); + return true; + case 'Yoast\PHPUnitPolyfills\TestCases\TestCase': self::loadTestCase(); return true; @@ -331,6 +335,23 @@ public static function loadAssertIgnoringLineEndings() { require_once __DIR__ . '/src/Polyfills/AssertIgnoringLineEndings_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( Assert::class, 'assertObjectHasProperty' ) === false ) { + // PHPUnit < 10.1.0. + require_once __DIR__ . '/src/Polyfills/AssertObjectProperty.php'; + return; + } + + // PHPUnit >= 10.1.0. + 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..59e7872 --- /dev/null +++ b/src/Polyfills/AssertObjectProperty.php @@ -0,0 +1,154 @@ +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 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( Assert::class, 'assertObjectNotHasAttribute' ) + && \version_compare( Autoload::getPHPUnitVersion(), '9.6.0', '<=' ) + ) { + // PHPUnit <= 9.6.0. + static::assertObjectNotHasAttribute( $propertyName, $object, $message ); + return; + } + + /* + * PHPUnit 9.6.1+ and PHPUnit 10.0.x. + * 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..6aad27e --- /dev/null +++ b/src/Polyfills/AssertObjectProperty_Empty.php @@ -0,0 +1,10 @@ += 10.1.0 in which this polyfill is not needed. + * + * @since 2.1.0 + */ +trait AssertObjectProperty {} diff --git a/src/TestCases/TestCasePHPUnitGte8.php b/src/TestCases/TestCasePHPUnitGte8.php index d3b290b..7d89e8a 100644 --- a/src/TestCases/TestCasePHPUnitGte8.php +++ b/src/TestCases/TestCasePHPUnitGte8.php @@ -10,6 +10,7 @@ use Yoast\PHPUnitPolyfills\Polyfills\AssertionRenames; use Yoast\PHPUnitPolyfills\Polyfills\AssertIsList; use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals; +use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty; use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations; use Yoast\PHPUnitPolyfills\Polyfills\ExpectExceptionMessageMatches; @@ -31,6 +32,7 @@ abstract class TestCase extends PHPUnit_TestCase { use AssertionRenames; use AssertIsList; use AssertObjectEquals; + use AssertObjectProperty; use EqualToSpecializations; use ExpectExceptionMessageMatches; diff --git a/src/TestCases/TestCasePHPUnitLte7.php b/src/TestCases/TestCasePHPUnitLte7.php index 2cc584d..9fb9801 100644 --- a/src/TestCases/TestCasePHPUnitLte7.php +++ b/src/TestCases/TestCasePHPUnitLte7.php @@ -12,6 +12,7 @@ use Yoast\PHPUnitPolyfills\Polyfills\AssertIsList; use Yoast\PHPUnitPolyfills\Polyfills\AssertIsType; use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals; +use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty; use Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains; use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations; use Yoast\PHPUnitPolyfills\Polyfills\ExpectExceptionMessageMatches; @@ -37,6 +38,7 @@ abstract class TestCase extends PHPUnit_TestCase { use AssertIsList; use AssertIsType; use AssertObjectEquals; + use AssertObjectProperty; use AssertStringContains; use EqualToSpecializations; use ExpectExceptionMessageMatches; diff --git a/src/TestCases/XTestCase.php b/src/TestCases/XTestCase.php index 93b4ce9..4d4ea25 100644 --- a/src/TestCases/XTestCase.php +++ b/src/TestCases/XTestCase.php @@ -12,6 +12,7 @@ use Yoast\PHPUnitPolyfills\Polyfills\AssertIsList; use Yoast\PHPUnitPolyfills\Polyfills\AssertIsType; use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectEquals; +use Yoast\PHPUnitPolyfills\Polyfills\AssertObjectProperty; use Yoast\PHPUnitPolyfills\Polyfills\AssertStringContains; use Yoast\PHPUnitPolyfills\Polyfills\EqualToSpecializations; use Yoast\PHPUnitPolyfills\Polyfills\ExpectExceptionMessageMatches; @@ -39,6 +40,7 @@ abstract class XTestCase extends PHPUnit_TestCase { use AssertIsList; use AssertIsType; use AssertObjectEquals; + use AssertObjectProperty; use AssertStringContains; use EqualToSpecializations; use ExpectExceptionMessageMatches; diff --git a/tests/Polyfills/AssertObjectPropertyTest.php b/tests/Polyfills/AssertObjectPropertyTest.php new file mode 100644 index 0000000..70c1489 --- /dev/null +++ b/tests/Polyfills/AssertObjectPropertyTest.php @@ -0,0 +1,323 @@ +=' ) ) { + $this->markTestSkipped( 'PHPUnit native implementation relies on strict_types and when not used will accept scalar inputs' ); + } + + if ( \PHP_VERSION_ID >= 80100 + && \version_compare( PHPUnit_Version::id(), '10.1.0', '>=' ) + ) { + $msg = 'assertObjectHasProperty(): Argument #1 ($propertyName) must be of type string, '; + } + else { + // PHP 5/7. + $msg = 'Argument 1 passed to assertObjectHasProperty() must be of type string, '; + } + + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( $msg ); + + $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(), '10.1.0', '>=' ) ) { + $this->markTestSkipped( 'PHPUnit native implementation relies on strict_types and when not used will accept scalar inputs' ); + } + + if ( \PHP_VERSION_ID >= 80100 + && \version_compare( PHPUnit_Version::id(), '10.1.0', '>=' ) + ) { + $msg = 'assertObjectNotHasProperty(): Argument #1 ($propertyName) must be of type string, '; + } + else { + // PHP 5/7. + $msg = 'Argument 1 passed to assertObjectNotHasProperty() must be of type string, '; + } + + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( $msg ); + + $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 ) { + if ( \PHP_VERSION_ID >= 80100 + && \version_compare( PHPUnit_Version::id(), '10.1.0', '>=' ) + ) { + $msg = 'assertObjectHasProperty(): Argument #2 ($object) must be of type object, '; + } + else { + // PHP 5/7. + $msg = 'Argument 2 passed to assertObjectHasProperty() must be of type object, '; + } + + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( $msg ); + + $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 ) { + if ( \PHP_VERSION_ID >= 80100 + && \version_compare( PHPUnit_Version::id(), '10.1.0', '>=' ) + ) { + $msg = 'assertObjectNotHasProperty(): Argument #2 ($object) must be of type object, '; + } + else { + // PHP 5/7. + $msg = 'Argument 2 passed to assertObjectNotHasProperty() must be of type object, '; + } + + $this->expectException( TypeError::class ); + $this->expectExceptionMessage( $msg ); + + 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 = AssertionFailedError::class; + if ( \class_exists( PHPUnit_Framework_AssertionFailedError::class ) ) { + // PHPUnit < 6. + $exception = PHPUnit_Framework_AssertionFailedError::class; + } + + 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 0dcb0a4..d92e7eb 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; @@ -151,4 +152,16 @@ final public function testAvailabilityAssertIgnoringLineEndings() { final public function testAvailabilityAssertIsList() { static::assertIsList( [ 0, 1, 2 ] ); } + + /** + * Verify availability of trait polyfilled PHPUnit methods [17]. + * + * @return void + */ + final public function testAvailabilityAssertObjectProperty() { + $object = new stdClass(); + $object->prop = true; + + self::assertObjectHasProperty( 'prop', $object ); + } }