Skip to content

Commit

Permalink
Merge pull request #101 from bbatsche/invokable-objects
Browse files Browse the repository at this point in the history
Invokable Objects
  • Loading branch information
bbatsche authored Aug 18, 2023
2 parents 832d0ff + 068fd6d commit 1f2c717
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 8 deletions.
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<!--
Make sure you are targeting the correct branch with this pull request.
Changes to the current release version should go into the `main` branch.
Changes to the current release version should go into the `master` branch.
If this is a fix for a prior version make sure to target the correct version branch.
-->

Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"esbonio.sphinx.confDir": "${workspaceFolder}/docs",
"cSpell.words": [
"invokable",
"phpref",
"samp"
]
Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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/101 "Invokable Objects"
23 changes: 22 additions & 1 deletion docs/methods.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,32 @@ 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
verify($calculator)->add(1, 'two')
->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');
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions src/API/Method.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class Method extends Value
/**
* Name of method to evaluate.
*
* @var string
* @var ?string
*/
protected $methodName;

Expand All @@ -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)) {
Expand All @@ -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);

Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/API/ThrownException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
26 changes: 26 additions & 0 deletions src/API/Value.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
*/
Expand Down
58 changes: 58 additions & 0 deletions test/MethodUnitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
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;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Constraint\Constraint;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Throwable;

/**
* @internal
Expand Down Expand Up @@ -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".');

Expand All @@ -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');
Expand Down
18 changes: 18 additions & 0 deletions test/_fixtures/Classes/ExampleInvokable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace BeBat\Verify\Test\Examples;

/**
* Example invokable class.
*
* @internal
*/
final class ExampleInvokable
{
public function __invoke(string $name = 'World'): string
{
return 'Hello ' . $name;
}
}
20 changes: 20 additions & 0 deletions test/_fixtures/Classes/ExampleInvokableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace BeBat\Verify\Test\Examples;

use RuntimeException;

/**
* Example invokable class that throws exceptions.
*
* @internal
*/
final class ExampleInvokableException
{
public function __invoke(string $name = 'Unknown'): string
{
throw new RuntimeException('Exception: ' . $name);
}
}

0 comments on commit 1f2c717

Please sign in to comment.