bovigo/callmap
Allows to stub and mock method and function calls by applying a callmap.
Compatible with any unit test framework., (*1)
Package status
, (*2)
, (*3)
Installation
bovigo/callmap is distributed as Composer package.
To install it as a development dependency of your package use the following
command:, (*4)
composer require --dev bovigo/callmap ^8.0
To install it as a runtime dependency for your package use the following command:, (*5)
composer require bovigo/callmap ^8.0
Requirements
bovigo/callmap requires at least PHP 8.2., (*6)
For argument verification one of the following packages is required:, (*7)
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., (*8)
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:, (*9)
// 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., (*10)
Note: for the sake of brevity below it is assumed the used classes and functions
are imported into the current namespace via, (*11)
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:, (*12)
$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., (*13)
The other option is to create a complete stub:, (*14)
$yourClass = NewInstance::stub(YourClass::class);
Instances created that way don't forward method calls., (*15)
Ok, so we created an instance of the thing that we want to specify return values
for, how to do that?, (*16)
$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., (*17)
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:, (*18)
$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
., (*19)
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., (*20)
-
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., (*21)
-
Traits:
When instantiated with NewInstance::of()
the default return value will be
the value a call to the according method returns.br/
When instantiated with NewInstance::stub()
and for abstract methods the
default return value is null
, except the @return
type hint in the doc
comment specifies $this
or self
., (*22)
-
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.br/
When instantiated with NewInstance::stub()
and for abstract methods the
default return value is null
, 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
or static
(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 be null
., (*23)
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., (*24)
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., (*25)
$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., (*26)
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:, (*27)
$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., (*28)
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:, (*29)
$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):, (*30)
$yourClass->returns(['aMethod' => throws(new \Error('error'))]);
Of course this can be combined with a series of return values:, (*31)
$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., (*32)
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)., (*33)
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:, (*34)
$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., (*35)
Do I have to specify a closure or can I use an arbitrary callable?
You can:, (*36)
$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., (*37)
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., (*38)
Exception to this: if the return type is \Traversable
this doesn't apply, even
if the class implements this interface., (*39)
Please note that @inheritDoc
is not supported., (*40)
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., (*41)
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., (*42)
Which methods can be used in the callmap?
Only non-static, non-final public and protected methods can be used., (*43)
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., (*44)
Oh, and of course you can't use all of this with a class which is declared as
final., (*45)
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., (*46)
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:, (*47)
verify($yourClass, 'aMethod')->wasCalledOnce();
In case it was not called exactly once, this will throw a CallAmountViolation
.
Otherwise, it will simply return true., (*48)
Of course you can verify the call amount even if you didn't specify the method
in the callmap., (*49)
Here is a list of methods that the instance returned by verify()
offers for
verifying the amount of method invocations:, (*50)
-
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., (*51)
By the way, if PHPUnit is available, CallAmountViolation
will extend
PHPUnit\Framework\ExpectationFailedException
. In case it isn't available it
will simply extend \Exception
., (*52)
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., (*53)
In some cases it is useful to verify that an instance received the correct
arguments. You can do this with verify()
as well:, (*54)
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:, (*55)
verify($yourClass, 'aMethod')->receivedOn(3, 303, 'foo');
This applies verification to the arguments the method received on the third
invocation., (*56)
There is also a shortcut to verify that the method didn't receive any arguments:, (*57)
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., (*58)
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., (*59)
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., (*60)
Verification details for bovigo/assert
Available since release 2.0.0., (*61)
Both reveived()
and receivedOn()
also accept any instance of
bovigo\assert\predicate\Predicate
:, (*62)
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., (*63)
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
., (*64)
Verification details for PHPUnit
Both reveived()
and receivedOn()
also accept any instance of PHPUnit\Framework\Constraint\Constraint
:, (*65)
verify($yourClass, 'aMethod')->received($this->isInstanceOf('another\ExampleClass'));
In case a bare value is passed it is assumed that PHPUnit\Framework\Constraint\IsEqual
., (*66)
In case the verification fails an PPHPUnit\Framework\ExpectationFailedException
will be thrown by the used PHPUnit\Framework\Constraint\Constraint
., (*67)
Verification details for xp-framework/unittest
Available since release 1.1.0., (*68)
In case xp-framework/unittest is present, \util\Objects::equal()
will be used., (*69)
In case the verification fails an \unittest\AssertionFailedError
will be
thrown., (*70)
Mocking injected functions
Available since release 3.1.0., (*71)
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., (*72)
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., (*73)
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?, (*74)
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:, (*75)
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:, (*76)
$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:, (*77)
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:, (*78)
NewCallable::of('strlen')->throws(new \Exception('failure!'));
It is also possible to verify function invocations, as can be done with method
invocations:, (*79)
$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., (*80)