From 1cf3c605b7eb25372f98a422ca01d722c7933b6d Mon Sep 17 00:00:00 2001 From: Ben Batschelet Date: Thu, 17 Aug 2023 23:05:05 -0500 Subject: [PATCH 1/3] Add support for invokable objects --- phpstan.neon.dist | 1 + src/API/Method.php | 22 ++++++- src/API/ThrownException.php | 2 +- src/API/Value.php | 26 +++++++++ test/MethodUnitTest.php | 58 +++++++++++++++++++ test/_fixtures/Classes/ExampleInvokable.php | 18 ++++++ .../Classes/ExampleInvokableException.php | 20 +++++++ 7 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 test/_fixtures/Classes/ExampleInvokable.php create mode 100644 test/_fixtures/Classes/ExampleInvokableException.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a702f64..4d110af 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,6 +10,7 @@ parameters: ignoreErrors: - '/Call to function is_object\(\) with object will always evaluate to true/' - '/Parameter \#1 \$callback of method BeBat\\Verify\\API\\Base::passCallback\(\) expects callable\(mixed\): bool/' + - '/PHPUnit\\Framework\\Assert::assertInstanceOf\(\) with ''BeBat\\\\Verify\\\\API\\\\(Method|ThrownException)'' and BeBat\\Verify\\API\\(Method|ThrownException) will always evaluate to true/' - message: '/Call to an undefined method BeBat\\Verify\\API\\Base::equalTo/' path: %currentWorkingDirectory%/test/BaseUnitTest.php diff --git a/src/API/Method.php b/src/API/Method.php index 3776eac..2090fe4 100644 --- a/src/API/Method.php +++ b/src/API/Method.php @@ -33,7 +33,7 @@ class Method extends Value /** * Name of method to evaluate. * - * @var string + * @var ?string */ protected $methodName; @@ -47,7 +47,7 @@ class Method extends Value /** * @param class-string|object $actual */ - public function __construct($actual, string $name, string $methodName) + public function __construct($actual, string $name, ?string $methodName = null) { if (\is_string($actual)) { if (!class_exists($actual)) { @@ -57,7 +57,17 @@ public function __construct($actual, string $name, string $methodName) throw new InvalidSubjectException('Subject must be either an object or class name.'); } - $name = \is_string($actual) ? "{$name}::{$methodName}()" : "{$name}->{$methodName}()"; + if (!isset($methodName)) { + if (!\is_object($actual) || !(new ReflectionClass($actual))->hasMethod('__invoke')) { + throw new InvalidSubjectException('Only invokable objects can be used without a method name.'); + } + } + + $name = "{$name}()"; + + if (isset($methodName)) { + $name = \is_string($actual) ? "{$name}::{$methodName}()" : "{$name}->{$methodName}()"; + } parent::__construct($actual, $name); @@ -106,6 +116,12 @@ final public function with(...$args): self */ protected function callMethod() { + if (!isset($this->methodName)) { + \assert(\is_callable($this->actual)); + + return \call_user_func_array($this->actual, $this->methodArgs); + } + $reflector = new ReflectionClass($this->actual); $methods = \is_object($this->actual) ? $reflector->getMethods() diff --git a/src/API/ThrownException.php b/src/API/ThrownException.php index 62d628a..8da1d95 100644 --- a/src/API/ThrownException.php +++ b/src/API/ThrownException.php @@ -34,7 +34,7 @@ final class ThrownException extends Method * @param class-string|object $actual * @param mixed[] $methodArgs */ - public function __construct($actual, string $name, string $methodName, array $methodArgs) + public function __construct($actual, string $name, ?string $methodName = null, array $methodArgs = []) { parent::__construct($actual, $name, $methodName); diff --git a/src/API/Value.php b/src/API/Value.php index 725c794..630bbf4 100644 --- a/src/API/Value.php +++ b/src/API/Value.php @@ -509,6 +509,14 @@ final public function resource(): self return $this->constraint($this->constraintFactory()->type(IsType::TYPE_RESOURCE)); } + /** + * Verify return value of invokable object. + */ + public function returnValue(): Method + { + return $this->withVerifier(Method::class); + } + /** * Assert SUT does or does not have the same number of elements as given array/Countable/Traversable object. * @@ -563,6 +571,14 @@ final public function string(): self return $this->constraint($this->constraintFactory()->type(IsType::TYPE_STRING)); } + /** + * Verify exception thrown by invokable object. + */ + public function throwException(): ThrownException + { + return $this->withVerifier(ThrownException::class); + } + /** * Assert that SUT is or is not true. */ @@ -571,6 +587,16 @@ final public function true(): self return $this->constraint($this->constraintFactory()->true()); } + /** + * Pass args to invokable object and verify. + * + * @param mixed ...$args + */ + public function with(...$args): Method + { + return $this->returnValue()->with(...$args); + } + /** * Ignore object identity when comparing elements in SUT. */ diff --git a/test/MethodUnitTest.php b/test/MethodUnitTest.php index 4046336..ec3d4b1 100644 --- a/test/MethodUnitTest.php +++ b/test/MethodUnitTest.php @@ -12,6 +12,8 @@ use BeBat\Verify\Exception\InvalidSubjectException; use BeBat\Verify\Test\Examples\ExampleChild; use BeBat\Verify\Test\Examples\ExampleDynamic; +use BeBat\Verify\Test\Examples\ExampleInvokable; +use BeBat\Verify\Test\Examples\ExampleInvokableException; use Codeception\AssertThrows; use Mockery; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; @@ -19,6 +21,7 @@ use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\Framework\TestCase; use RuntimeException; +use Throwable; /** * @internal @@ -149,6 +152,20 @@ public function testCallMethodThrowsExceptions(): void $subject->method('missingStaticMethod')->will()->returnValue(); }); + $subject = new Value(new ExampleChild(), 'Value'); + $exception = new InvalidSubjectException('Only invokable objects can be used without a method name.'); + + $this->assertThrows($exception, static function () use ($subject): void { + $subject->will()->returnValue(); + }); + + $subject = new Value(ExampleInvokable::class, 'Value'); + $exception = new InvalidSubjectException('Only invokable objects can be used without a method name.'); + + $this->assertThrows($exception, static function () use ($subject): void { + $subject->will()->returnValue(); + }); + $subject = new Value('\\Some\\Fake\\Class\\Name', 'Value'); $exception = new InvalidSubjectException('Could not find class "\\Some\\Fake\\Class\\Name".'); @@ -164,6 +181,47 @@ public function testCallMethodThrowsExceptions(): void }); } + public function testInvokableObjectReturnValue(): void + { + $verifier = new Value(new ExampleInvokable(), 'Value'); + $assert = Mockery::mock(AssertInterface::class); + $constraint = Mockery::mock(Constraint::class); + + $verifier->setAssert($assert); + + $assert->expects() + ->assertThat('Hello World', $constraint, '') + ->once(); + + $assert->expects() + ->assertThat('Hello Name', $constraint, '') + ->once(); + + self::assertInstanceOf(Method::class, $verifier->will()->returnValue()->constraint($constraint)); + self::assertInstanceOf(Method::class, $verifier->with('Name')->will()->returnValue()->constraint($constraint)); + } + + public function testInvokableObjectThrownException(): void + { + $verifier = new Value(new ExampleInvokableException(), 'Value'); + $assert = Mockery::mock(AssertInterface::class); + $constraint = Mockery::mock(Constraint::class); + + $verifier->setAssert($assert); + + $assert->expects() + ->assertThat( + Mockery::on(static function (Throwable $e): bool { + return $e instanceof RuntimeException && ($e->getMessage() === 'Exception: Unknown' || $e->getMessage() === 'Exception: Some Error'); + }), + $constraint, + '' + )->twice(); + + self::assertInstanceOf(ThrownException::class, $verifier->will()->throwException()->constraint($constraint)); + self::assertInstanceOf(ThrownException::class, $verifier->with('Some Error')->will()->throwException()->constraint($constraint)); + } + public function testThrownExceptionFailsIfNoException(): void { $verifier = new Value(new ExampleChild(), 'Value'); diff --git a/test/_fixtures/Classes/ExampleInvokable.php b/test/_fixtures/Classes/ExampleInvokable.php new file mode 100644 index 0000000..c106db5 --- /dev/null +++ b/test/_fixtures/Classes/ExampleInvokable.php @@ -0,0 +1,18 @@ + Date: Thu, 17 Aug 2023 23:13:15 -0500 Subject: [PATCH 2/3] Update CHANGELOG --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CHANGELOG.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b87ee97..c598872 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,7 +7,7 @@ diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e9115..6e4acae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `property()` and `propertyName()` methods - [#98][PR98] - `passCallback()` method to use a callback function to do validation - [#100][PR100] +- Support for using `with()`, `returnValue()`, and `throwException()` on invokable objects, rather than their methods - [#101][PR101] - (Dev) Pull request & issue templates - [#98][PR98] - (Dev) Test coverage for PHP 8.2 and PHPUnit 10 - [#98][PR98] - (Dev) Contribution guidelines and security policy - [#98][PR98] -### Changed - ### Deprecated - `attribute()` and `attributeName()` methods (use `property()` and `propertyName()` instead) - [#98][PR98] @@ -127,3 +126,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [PR93]: https://github.com/bbatsche/Verify/pull/93 "Reset Return Value" [PR98]: https://github.com/bbatsche/Verify/pull/98 "Deprecate Attribute" [PR100]: https://github.com/bbatsche/Verify/pull/100 "Callback Verifier" +[PR101]: https://github.com/bbatsche/Verify/pull/100 "Invokable Objects" From 068fd6d7908b15c3619b9e623be5b0e893d88aa4 Mon Sep 17 00:00:00 2001 From: Ben Batschelet Date: Fri, 18 Aug 2023 11:30:23 -0500 Subject: [PATCH 3/3] Document invokable object assertions --- .vscode/settings.json | 1 + CHANGELOG.md | 2 +- docs/methods.rst | 23 ++++++++++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index bf6da72..ce9c80c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "esbonio.sphinx.confDir": "${workspaceFolder}/docs", "cSpell.words": [ + "invokable", "phpref", "samp" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4acae..e783aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,4 +126,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [PR93]: https://github.com/bbatsche/Verify/pull/93 "Reset Return Value" [PR98]: https://github.com/bbatsche/Verify/pull/98 "Deprecate Attribute" [PR100]: https://github.com/bbatsche/Verify/pull/100 "Callback Verifier" -[PR101]: https://github.com/bbatsche/Verify/pull/100 "Invokable Objects" +[PR101]: https://github.com/bbatsche/Verify/pull/101 "Invokable Objects" diff --git a/docs/methods.rst b/docs/methods.rst index 40aef40..75a4988 100644 --- a/docs/methods.rst +++ b/docs/methods.rst @@ -45,7 +45,9 @@ If you need to test an exception thrown by your method, you may do so with :php: verify($calculator)->divide($someValue, 0) ->will()->throwException()->instanceOf(DivideByZeroException::class); -You can drill into more detail of your exception by using the :php:`withMessage()` and :php:`withCode()` methods: +Just like with :php:`returnValue()`, :php:`throwException()` will call your method and then capture any exceptions it throws so that you can write assertion about the exception object. If your method does *not* throw an exception, :php:`throwException()` will fail the test for you. + +To inspect the exception further, you can drill into it by using the :php:`withMessage()` and :php:`withCode()` methods: .. code-block:: php @@ -53,3 +55,22 @@ You can drill into more detail of your exception by using the :php:`withMessage( ->will()->throwException()->instanceOf(InvalidArgumentException::class) ->withMessage()->startWith('Invalid argument passed') ->withCode()->identicalTo(2); + +Invokable Objects +================= + +If your subject is an object with an :php:`__invoke()` magic method, you can write assertions about its return value or exceptions just like with other methods. Simply use :php:`returnValue()` or :php:`throwException()` after passing your subject to :php:`verify()` and BeBat/Verify will invoke your object itself: + +.. code-block:: php + + verify($subject)->will()->returnValue()->identicalTo('return value of __invoke()'); + +You can supply parameters for your subject using :php:`with()` just like other methods: + +.. code-block:: php + + verify($subject) + ->with('invalid parameter')->will() + ->throwException()->instanceOf(InvalidArgumentException::class) + ->with('correct parameter')->will() + ->returnValue()->identicalTo('correct parameter value');