bovigo / callmap
Allows to stub and mock method calls by applying a callmap.
Installs: 26 624
Dependents: 10
Suggesters: 0
Security: 0
Stars: 8
Watchers: 3
Forks: 4
Open Issues: 2
Requires
- php: ^8.2
- bovigo/assert: ^8.0
Requires (Dev)
- phpunit/phpunit: ^10.5
- xp-framework/core: ^11.6
- xp-framework/unittest: ^11.1
Suggests
- bovigo/assert: To use argument verification
- phpunit/phpunit: Alternative option for argument verification
- xp-framework/unittest: Alternative option for argument verification
- dev-main / 8.0.x-dev
- v8.0.6
- v8.0.5
- v8.0.4
- v8.0.3
- v8.0.2
- v8.0.1
- v8.0.0
- v7.0.0
- v6.2.1
- v6.2.0
- v6.1.0
- v6.0.0
- v5.2.1
- v5.2.0
- v5.1.0
- v5.0.2
- v5.0.1
- v5.0.0
- v4.0.1
- v4.0.0
- v3.2.0
- v3.1.1
- v3.1.0
- v3.0.3
- v3.0.2
- v3.0.1
- v3.0.0
- v2.1.0
- v2.0.1
- v2.0.0
- v1.1.0
- v1.0.0
- v0.6.1
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.0
- dev-dependabot/composer/phpunit/phpunit-10.5.24
This package is auto-updated.
Last update: 2024-10-31 00:24:01 UTC
README
Allows to stub and mock method and function calls by applying a callmap. Compatible with any unit test framework.
Package status
Installation
bovigo/callmap is distributed as Composer package. To install it as a development dependency of your package use the following command:
composer require --dev bovigo/callmap ^8.0
To install it as a runtime dependency for your package use the following command:
composer require bovigo/callmap ^8.0
Requirements
bovigo/callmap requires at least PHP 8.2.
For argument verification one of the following packages is required:
- bovigo/assert (since release 2.0.0)
- PHPUnit
- xp-framework/unittest (since release 1.1.0)
The order specified here is also the one in which the verification logic will select the assertions to be used for argument verification. This means even if you run your tests with PHPUnit but bovigo/assert is present as well argument verification will be done with the latter.
Usage
Explore the tests to see how bovigo/callmap can be used. For the very eager, here's a code example which features almost all of the possibilities:
// set up the instance to be used $yourClass = NewInstance::of(YourClass::class, ['some', 'arguments']) ->returns([ 'aMethod' => 313, 'otherMethod' => function() { return 'yeah'; }, 'play' => onConsecutiveCalls(303, 808, 909, throws(new \Exception('error')), 'ups' => throws(new \Exception('error')), 'hey' => 'strtoupper' ]); // do some stuff, e.g. execute the logic to test ... // verify method invocations and received arguments verify($yourClass, 'aMethod')->wasCalledOnce(); verify($yourClass, 'hey')->received('foo');
However, if you prefer text instead of code, read on.
Note: for the sake of brevity below it is assumed the used classes and functions are imported into the current namespace via
use bovigo\callmap\NewInstance; use bovigo\callmap\NewCallable; use function bovigo\callmap\throws; use function bovigo\callmap\onConsecutiveCalls; use function bovigo\callmap\verify;
Specify return values for method invocations
As the first step, you need to get an instance of the class, interface or trait you want to specify return values for. To do this, bovigo/callmap provides two possibilities. The first one is to create a new instance where this instance is a proxy to the actual class:
$yourClass = NewInstance::of(YourClass::class, ['some', 'arguments']);
This creates an instance where each method call is passed to the original class in case no return value was specified via the callmap. Also, it calls the constructor of the class to instantiate of. If the class doesn't have a constructor, or you create an instance of an interface or trait, the list of constructor arguments can be left away.
The other option is to create a complete stub:
$yourClass = NewInstance::stub(YourClass::class);
Instances created that way don't forward method calls.
Ok, so we created an instance of the thing that we want to specify return values for, how to do that?
$yourClass->returns([ 'aMethod' => 303, 'otherMethod' => function() { return 'yeah'; } ]);
We simply pass a callmap to the returns()
method. Now, if something calls
$yourClass->aMethod()
, the return value will always be 303
. In the case of
$yourClass->otherMethod()
, the callable will be evaluated and its return value
will be returned.
Please be aware that the array provided with the returns()
method should
contain all methods that should be stubbed. If you call this method a second
time the complete callmap will be replaced:
$yourClass->returns(['aMethod' => 303]); $yourClass->returns(['otherMethod' => function() { return 'yeah'; }]);
As a result of this, $yourClass->aMethod()
is not set any more to return 303
.
Default return values
Depending on what is instantiated and how, there will be default return values for the case that no call mapping has been passed for a method which actually is called.
-
Interfaces: Default return value is always
null
, except the return type declaration specifies the interface itself and is not optional, or the@return
type hint in the doc comment specifies the short class name or the fully qualified class name of the interface itself or any other interface it extends. In that case the default return value will be the instance itself. -
Traits: When instantiated with
NewInstance::of()
the default return value will be the value a call to the according method returns.
When instantiated withNewInstance::stub()
and for abstract methods the default return value isnull
, except the@return
type hint in the doc comment specifies$this
orself
. -
Classes: When instantiated with
NewInstance::of()
the default return value will be the value which is returned by the according method of the original class.
When instantiated withNewInstance::stub()
and for abstract methods the default return value isnull
, except the return type declaration specifies the class itself and is not optional, or the@return
type hint in the doc comment specifies$this
,self
orstatic
(the latter since release 6.2), the short class name or the fully qualified class name of the class or of a parent class or any interface the class implements. Exception to this: if the return type is\Traversable
and the class implements this interface return value will benull
.
Note: support for @return
annotations in doc comments is deprecated and will
be removed with release 9.0.0. Starting from 9.0.0 only explicit return type
declarations will be supported.
Specify a series of return values
Sometimes a method gets called more than once and you need to specify different return values for each call.
$yourClass->returns(['aMethod' => onConsecutiveCalls(303, 808, 909)]);
This will return a different value on each invocation of $yourClass->aMethod()
in the order of the specified return values. If the method is called more often
than return values are specified, each subsequent call will return the default
return value as if no call mapping has been specified.
I want to return a callable, but it is executed on method invocation
Because callables are executed when the method is invoked it is required to wrap
them into another callable. To ease this, the wrap()
function is provided:
$yourClass->returns(['aMethod' => wrap(function() { })]); $this->assertTrue(is_callable($yourClass->aMethod()); // true
The reason it is that way is that it is far more likely you want to calculate the return value with a callable instead of simply returning the callable as a result of the method call.
Let's throw an exception
Sometimes you don't need to specify a return value, but want the method to throw an exception on invocation. Of course you could do that by providing a callable in the callmap which throws the exception, but there's a more handy way available:
$yourClass->returns(['aMethod' => throws(new \Exception('error'))]);
Now each call to this method will throw this exception. Since release 3.1.0 it
is also possible to throw an \Error
(basically, any \Throwable
for that
matter):
$yourClass->returns(['aMethod' => throws(new \Error('error'))]);
Of course this can be combined with a series of return values:
$yourClass->returns(['aMethod' => onConsecutiveCalls(303, throws(new \Exception('error')))]);
Here, the first invocation of $yourClass->aMethod()
will return 303
, whereas
the second call will lead to the exception being thrown.
In case a method gets invoked more often than results are defined with
onConsecutiveCalls()
then it falls back to the default return value (see above).
Is there a way to access the passed arguments?
It might be useful to use the arguments passed to a method before returning a value. If you specify a callable this callable will receive all arguments passed to the method:
$yourClass->returns(['aMethod' => function($arg1, $arg2) { return $arg2;}]); echo $yourClass->aMethod(303, 'foo'); // prints foo
However, if a method has optional parameters the default value will not be passed as argument if it wasn't given in the actual method call. Only explicitly passed arguments will be forwarded to the callable.
Do I have to specify a closure or can I use an arbitrary callable?
You can:
$yourClass->returns(['aMethod' => 'strtoupper']); echo $yourClass->aMethod('foo'); // prints FOO
How do I specify that an object returns itself?
Actually, you don't. bovigo/callmap is smart enough to detect when it should return the object instance instead of null when no call mapping for a method was provided. To achieve that, bovigo/callmap tries to detect the return type of a method from either from the return type hint or the method's doc comment. If the return type specified is the class or interface itself it will return the instance instead of null, except when the return type hint allows null.
If no return type is defined and the return type specified in the doc comment is
one of $this
, self
, static
(since release 6.2), the short class name or
the fully qualified class name of the class or of a parent class or any interface
the class implements, it will return the instance instead of null.
Exception to this: if the return type is \Traversable
this doesn't apply, even
if the class implements this interface.
Please note that @inheritDoc
is not supported.
In case this leads to a false interpretation and the instance is returned when in fact it should not, you can always overrule that by explicitly stating a return value in the callmap.
Note: support for @return
annotations in doc comments is deprecated and will
be removed with release 9.0.0. Starting from 9.0.0 only explicit return type
declarations will be supported.
Which methods can be used in the callmap?
Only non-static, non-final public and protected methods can be used.
In case you want to map a private, or a final, or a static method you are out of luck. Probably you should rethink your class design.
Oh, and of course you can't use all of this with a class which is declared as final.
What happens if a method specified in the callmap doesn't exist?
In case the callmap contains a method which doesn't exist or is not applicable
for mapping (see above) returns()
will throw an \InvalidArgumentException
.
This also prevents typos and wondering why something doesn't work as expected.
Verify method invocations
Sometimes it is required to ensure that a method was invoked a certain amount of
times. In order to do that, bovigo/callmap provides the verify()
function:
verify($yourClass, 'aMethod')->wasCalledOnce();
In case it was not called exactly once, this will throw a CallAmountViolation
.
Otherwise, it will simply return true.
Of course you can verify the call amount even if you didn't specify the method in the callmap.
Here is a list of methods that the instance returned by verify()
offers for
verifying the amount of method invocations:
wasCalledAtMost($times)
: Asserts that the method was invoked at maximum the given amount of times.wasCalledAtLeastOnce()
: Asserts that the method was invoked at least once.wasCalledAtLeast($times)
: Asserts that the method was invoked at minimum the given amount of times.wasCalledOnce()
: Asserts that the method was invoked exactly once.wasCalled($times)
: Asserts that the method was invoked exactly the given amount of times.wasNeverCalled()
: Asserts that the method was never invoked.
In case the method to check doesn't exist or is not applicable for mapping (see
above) all of those methods throw an \InvalidArgumentException
. This also
prevents typos and wondering why something doesn't work as expected.
By the way, if PHPUnit is available, CallAmountViolation
will extend
PHPUnit\Framework\ExpectationFailedException
. In case it isn't available it
will simply extend \Exception
.
Verify passed arguments
Please note that for this feature a framework which provides assertions must be present. Please see the requirements section above for the list of currently supported assertion frameworks.
In some cases it is useful to verify that an instance received the correct
arguments. You can do this with verify()
as well:
verify($yourClass, 'aMethod')->received(303, 'foo');
This will verify that each of the expected arguments matches the actually received arguments of the first invocation of that method. In case you want to verify another invocation, we got you covered:
verify($yourClass, 'aMethod')->receivedOn(3, 303, 'foo');
This applies verification to the arguments the method received on the third invocation.
There is also a shortcut to verify that the method didn't receive any arguments:
verify($yourClass, 'aMethod')->receivedNothing(); // received nothing on first invocation verify($yourClass, 'aMethod')->receivedNothing(3); // received nothing on third invocation
In case the method wasn't invoked (that much), a MissingInvocation
exception
will be thrown.
In case the method received less arguments than expected, a ArgumentMismatch
exception will be thrown. It will also be thrown when receivedNothing()
detects
that at least one argument was received.
Please not that each method has its own invocation count (whereas in PHPUnit the invocation count is for the whole mock object). Also, invocation count starts at 1 for the first invocation, not at 0.
If the verification succeeds, it will simply return true. In case the verification fails an exception will be thrown. Which exactly depends on the available assertion framework.
Verification details for bovigo/assert
Available since release 2.0.0.
Both reveived()
and receivedOn()
also accept any instance of
bovigo\assert\predicate\Predicate
:
verify($yourClass, 'aMethod')->received(isInstanceOf('another\ExampleClass'));
In case a bare value is passed it is assumed that bovigo\assert\predicate\equals()
is meant. Additionally, instances of PHPUnit\Framework\Constraint\Constraint
are accepted as well as bovigo/assert knows how to handle those.
In case the verification fails an bovigo\assert\AssertionFailure
will be
thrown. In case PHPUnit is available as well this exception is also an instance
of PHPUnit\Framework\ExpectationFailedException
.
Verification details for PHPUnit
Both reveived()
and receivedOn()
also accept any instance of PHPUnit\Framework\Constraint\Constraint
:
verify($yourClass, 'aMethod')->received($this->isInstanceOf('another\ExampleClass'));
In case a bare value is passed it is assumed that PHPUnit\Framework\Constraint\IsEqual
.
In case the verification fails an PPHPUnit\Framework\ExpectationFailedException
will be thrown by the used PHPUnit\Framework\Constraint\Constraint
.
Verification details for xp-framework/unittest
Available since release 1.1.0.
In case xp-framework/unittest is present, \util\Objects::equal()
will be used.
In case the verification fails an \unittest\AssertionFailedError
will be
thrown.
Mocking injected functions
Available since release 3.1.0.
Sometimes it is necessary to mock a function. This can be cases like when PHP's
native fsockopen()
function is used. One way would be to redefine this
function in the namespace where it is called, and let this redefinition decide
what to do.
class Socket { public function connect(string $host, int $port, float $timeout) { $errno = 0; $errstr = ''; $resource = fsockopen($host, $port, $errno, $errstr, $timeout); if (false === $resource) { throw new ConnectionFailure( 'Connect to ' . $host . ':'. $port . ' within ' . $timeout . ' seconds failed: ' . $errstr . ' (' . $errno . ').' ); } // continue working with $resource } // other methods here }
However, this approach is not as optimal, as most likely it is required to not just mock the function, but to also evaluate whether it was called and maybe if it was called with the correct arguments.
bovigo/callmap suggests to use function injection for this. Instead of
hardcoding the usage of the fsockopen()
function or even to introduce a new
interface just for the sake of abstracting this function, why not inject the
function as a callable?
class Socket { private $fsockopen = 'fsockopen'; public function openWith(callable $fsockopen) { $this->fsockopen = $fsockopen; } public function connect(string $host, int $port, float $timeout) { $errno = 0; $errstr = ''; $fsockopen = $this->fsockopen; $resource = $fsockopen($host, $port, $errno, $errstr, $timeout); if (false === $resource) { throw new ConnectionFailure( 'Connect to ' . $host . ':'. $port . ' within ' . $timeout . ' seconds failed: ' . $errstr . ' (' . $errno . ').' ); } // continue working with $resource } // other methods here }
Now a mocked callable can be generated with bovigo/callmap:
class SocketTest extends \PHPUnit\Framework\TestCase { /** * @expectedException ConnectionFailure */ public function testSocketFailure() { $socket = new Socket(); $socket->openWith(NewCallable::of('fsockopen')->returns(false)); $socket->connect('example.org', 80, 1.0); } }
As with NewInstance::of()
the callable generated with NewCallable::of()
will
call the original function when no return value is specified via the returns()
method. In case the mocked function must not be called the callable can be
generated with NewCallable::stub()
instead:
$strlen = NewCallable::of('strlen'); // int(5), as original function will be called because no mapped return value defined var_dump($strlen('hello')); $strlen = NewCallable::stub('strlen'); // NULL, as no return value defined and original function not called var_dump($strlen('hello'));
As with a callmap for a method, several different invocation results can be set:
NewCallable::of('strlen')->returns(onConsecutiveCalls(5, 9, 10)); NewCallable::of('strlen')->returns(throws(new \Exception('failure!')));
For the latter, since release 3.2.0 a shortcut is available:
NewCallable::of('strlen')->throws(new \Exception('failure!'));
It is also possible to verify function invocations, as can be done with method invocations:
$strlen = NewCallable::of('strlen'); // do something with $strlen verify($strlen)->wasCalledOnce(); verify($strlen)->received('Hello world');
Everything that applies to method verification can be applied to function
verification, see above. The only difference is
that the second parameter for verify()
can be left away, as there is no method
that must be named.