/callmap

Allows to stub and mock method calls by applying a callmap.

Primary LanguagePHPBSD 3-Clause "New" or "Revised" LicenseBSD-3-Clause

bovigo/callmap

Allows to stub and mock method and function calls by applying a callmap. Compatible with any unit test framework.

Package status

Tests Coverage Status

Latest Stable Version Latest Unstable Version

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:

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.

  1. 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.

  2. Traits: When instantiated with NewInstance::of() the default return value will be the value a call to the according method returns.
    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.

  3. 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 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.

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.