bovigo/callmap

通过应用调用映射来允许模拟和存根方法调用。

v8.0.6 2024-01-17 13:53 UTC

README

通过应用调用映射来允许模拟和存根方法和函数调用。兼容任何 单元测试框架

包状态

Tests Coverage Status

Latest Stable Version Latest Unstable Version

安装

bovigo/callmapComposer 包的形式分发。要将它作为您包的开发依赖项安装,请使用以下命令

composer require --dev bovigo/callmap ^8.0

要将它作为您包的运行时依赖项安装,请使用以下命令

composer require bovigo/callmap ^8.0

要求

bovigo/callmap 至少需要 PHP 8.2。

对于参数验证,需要以下包之一

此处指定的顺序也是验证逻辑将选择用于参数验证的断言的顺序。这意味着即使您使用 PHPUnit 运行测试,但 bovigo/assert 也存在,参数验证也将使用后者进行。

用法

探索 测试 以了解如何使用 bovigo/callmap。对于非常急切的人,这里有一个包含几乎所有可能性的代码示例

// 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');

但是,如果您喜欢文本而不是代码,请继续阅读。

注意:为了简洁起见,以下假设使用的类和函数通过以下方式导入到当前命名空间中

use bovigo\callmap\NewInstance;
use bovigo\callmap\NewCallable;
use function bovigo\callmap\throws;
use function bovigo\callmap\onConsecutiveCalls;
use function bovigo\callmap\verify;

指定方法调用的返回值

作为第一步,您需要获取您想要指定返回值的类、接口或特性的实例。为此,bovigo/callmap 提供了两种可能性。第一种是创建一个新的实例,其中此实例是实际类的代理

$yourClass = NewInstance::of(YourClass::class, ['some', 'arguments']);

这创建了一个实例,其中每个方法调用都会传递给原始类,如果未通过调用映射指定返回值。它还会调用类的构造函数以实例化。如果没有构造函数,或者您创建了一个接口或特性的实例,则可以省略构造函数参数列表。

另一种选项是创建一个完整的存根

$yourClass = NewInstance::stub(YourClass::class);

以此方式创建的实例不会转发方法调用。

好吧,我们已经创建了一个我们想要指定返回值的实例,那么该如何做呢?

$yourClass->returns([
    'aMethod'     => 303,
    'otherMethod' => function() { return 'yeah'; }
]);

我们只需将调用映射传递给 returns() 方法。现在,如果某个东西调用 $yourClass->aMethod(),返回值将始终是 303。在 $yourClass->otherMethod() 的情况下,可调用对象将被评估,并返回其返回值。

请注意,与 returns() 方法一起提供的数组应包含所有应该被存根的方法。如果您再次调用此方法,则将替换完整的调用映射

$yourClass->returns(['aMethod' => 303]);
$yourClass->returns(['otherMethod' => function() { return 'yeah'; }]);

因此,$yourClass->aMethod() 将不再设置为返回 303

默认返回值

根据实例化和方式的不同,对于未传递调用映射但实际调用的方法,将存在默认返回值。

  1. 接口:默认返回值总是null,除非返回类型声明指定了接口本身且不是可选的,或者文档注释中的@return类型提示指定了接口本身的简短类名或完全限定类名,或者接口继承的任何其他接口。在这种情况下,默认返回值将是实例本身。

  2. 特性:当使用NewInstance::of()实例化时,默认返回值将是调用相应方法返回的值。
    当使用NewInstance::stub()实例化,对于抽象方法,默认返回值是null,除非文档注释中的@return类型提示指定了$thisself

  3. 类:当使用NewInstance::of()实例化时,默认返回值将是原始类的相应方法返回的值。
    当使用NewInstance::stub()实例化,对于抽象方法,默认返回值是null,除非返回类型声明指定了类本身且不是可选的,或者文档注释中的@return类型提示指定了$thisselfstatic(自6.2版本以来),短类名或类的完全限定类名、父类或类实现的任何接口。例外情况:如果返回类型是\Traversable且类实现了此接口,返回值将是null

注意:对文档注释中@return注解的支持已被弃用,并将随着9.0.0版本的发布而删除。从9.0.0版本开始,只支持显式的返回类型声明。

指定一系列返回值

有时一个方法被调用多次,你需要为每次调用指定不同的返回值。

$yourClass->returns(['aMethod' => onConsecutiveCalls(303, 808, 909)]);

这将按指定的返回值顺序返回不同的值。如果方法调用的次数多于指定的返回值,则后续的调用将返回默认返回值,就像没有指定调用映射一样。

我想返回一个可调用对象,但它是在方法调用时执行的

因为可调用对象是在方法调用时执行的,所以需要将它们包装到另一个可调用对象中。为了简化这个过程,提供了wrap()函数。

$yourClass->returns(['aMethod' => wrap(function() {  })]);

$this->assertTrue(is_callable($yourClass->aMethod()); // true

之所以这样做,是因为你更有可能用可调用对象计算返回值,而不是简单地作为方法调用的结果返回可调用对象。

让我们抛出一个异常

有时你不需要指定一个返回值,但想在调用方法时抛出异常。当然,你可以通过在调用映射中提供一个抛出异常的可调用对象来实现这一点,但有一个更方便的方法可用。

$yourClass->returns(['aMethod' => throws(new \Exception('error'))]);

现在每次调用此方法都会抛出这个异常。自3.1.0版本以来,也可以抛出\Error(基本上,任何\Throwable)。

$yourClass->returns(['aMethod' => throws(new \Error('error'))]);

当然,这可以与一系列返回值结合使用。

$yourClass->returns(['aMethod' => onConsecutiveCalls(303, throws(new \Exception('error')))]);

在这里,$yourClass->aMethod()的第一次调用将返回303,而第二次调用将导致抛出异常。

如果方法调用次数多于使用onConsecutiveCalls()定义的结果,则将回退到默认返回值(见上文)。

是否有方法访问传递的参数?

在返回值之前使用传递给方法的自定义参数可能很有用。如果你指定了一个可调用对象,则该可调用对象将接收到传递给方法的所有参数。

$yourClass->returns(['aMethod' => function($arg1, $arg2) { return $arg2;}]);

echo $yourClass->aMethod(303, 'foo'); // prints foo

然而,如果一个方法有可选参数,并且默认值在实际方法调用中未给出,则不会将默认值作为参数传递。只有明确传递的参数才会转发给可调用对象。

我必须指定一个闭包吗?我可以使用任意可调用对象吗?

您可以

$yourClass->returns(['aMethod' => 'strtoupper']);

echo $yourClass->aMethod('foo'); // prints FOO

如何指定一个对象返回自身?

实际上,您不需要这样做。bovigo/callmap足够智能,能够在没有提供方法映射时检测到应该返回对象实例而不是null。为了实现这一点,bovigo/callmap会尝试从返回类型提示或方法的文档注释中检测方法的返回类型。如果指定的返回类型是类或接口本身,它将返回实例而不是null,除非返回类型提示允许null。

如果没有定义返回类型,并且文档注释中指定的返回类型是$thisselfstatic(自6.2版本起)、类的短名或全限定名、父类的全限定名或类实现的任何接口,它将返回实例而不是null。

例外情况:如果返回类型是\Traversable,则不适用,即使该类实现了此接口。

请注意,不支持@inheritDoc

如果这导致错误解释,并且实例被返回,而实际上不应该返回,您可以通过在callmap中明确声明返回值来覆盖这一点。

注意:对文档注释中@return注解的支持已被弃用,并将随着9.0.0版本的发布而删除。从9.0.0版本开始,只支持显式的返回类型声明。

可以在callmap中使用哪些方法?

只能使用非静态、非最终的公共和受保护的方法。

如果您想映射私有方法、最终方法或静态方法,那么您就没有运气了。可能您应该重新思考您的设计。

当然,您不能在声明为final的类上使用所有这些。

如果callmap中指定的方法不存在会发生什么?

如果callmap包含一个不存在的方法或不适于映射的方法(见上文),则returns()将抛出\InvalidArgumentException。这也防止了输入错误和疑惑为什么某些事情没有按预期工作。

验证方法调用

有时需要确保一个方法被调用了一定次数。为此,bovigo/callmap提供了verify()函数

verify($yourClass, 'aMethod')->wasCalledOnce();

如果没有恰好调用一次,这将抛出CallAmountViolation。否则,它将简单地返回true。

当然,即使您没有在callmap中指定该方法,您也可以验证调用次数。

以下是verify()返回的实例用于验证方法调用次数的方法列表

  • wasCalledAtMost($times):断言该方法最多被调用给定次数。
  • wasCalledAtLeastOnce():断言该方法至少被调用一次。
  • wasCalledAtLeast($times):断言该方法至少被调用给定次数。
  • wasCalledOnce():断言该方法恰好被调用一次。
  • wasCalled($times):断言该方法恰好被调用给定次数。
  • wasNeverCalled():断言该方法从未被调用。

如果要检查的方法不存在或不适于映射(见上文),则所有这些方法都会抛出\InvalidArgumentException。这同样防止了输入错误和疑惑为什么某些事情没有按预期工作。

顺便说一句,如果PHPUnit可用,CallAmountViolation将扩展PHPUnit\Framework\ExpectationFailedException。如果不可用,它将简单地扩展\Exception

验证传递的参数

请注意,为了使用此功能,必须存在提供断言的框架。请参阅上文的“需求”部分以获取当前支持的断言框架列表。

在某些情况下,验证实例是否接收到了正确的参数是有用的。您也可以使用verify()来做这件事

verify($yourClass, 'aMethod')->received(303, 'foo');

这将验证预期的每个参数与该方法的第一次调用的实际接收参数相匹配。如果您想验证其他调用,我们也有解决方案。

verify($yourClass, 'aMethod')->receivedOn(3, 303, 'foo');

此功能将验证方法在第三次调用时接收到的参数。

还有一个快捷方式可以验证方法没有接收任何参数。

verify($yourClass, 'aMethod')->receivedNothing(); // received nothing on first invocation
verify($yourClass, 'aMethod')->receivedNothing(3); // received nothing on third invocation

如果方法没有被调用(这么多次),将抛出MissingInvocation异常。如果方法接收到的参数少于预期,将抛出ArgumentMismatch异常。当receivedNothing()检测到至少接收了一个参数时,也会抛出异常。

请注意,每个方法都有自己的调用次数(与PHPUnit中的调用次数针对整个模拟对象不同)。此外,调用次数从1开始,而不是从0开始。

如果验证成功,它将简单地返回true。如果验证失败,将抛出异常。具体取决于可用的断言框架。

bovigo/assert的验证详情

自2.0.0版本起可用。

同时,reveived()receivedOn()也接受任何bovigo\assert\predicate\Predicate的实例。

verify($yourClass, 'aMethod')->received(isInstanceOf('another\ExampleClass'));

如果传递了一个裸值,则假定是bovigo\assert\predicate\equals()。此外,还接受PHPUnit\Framework\Constraint\Constraint的实例,以及bovigo/assert知道如何处理这些。

如果验证失败,将抛出bovigo\assert\AssertionFailure。如果同时有PHPUnit可用,此异常也是PHPUnit\Framework\ExpectationFailedException的实例。

PHPUnit的验证详情

同时,reveived()receivedOn()也接受任何PHPUnit\Framework\Constraint\Constraint的实例。

verify($yourClass, 'aMethod')->received($this->isInstanceOf('another\ExampleClass'));

如果传递了一个裸值,则假定是PHPUnit\Framework\Constraint\IsEqual

如果验证失败,将使用所用的PHPUnit\Framework\Constraint\Constraint抛出PPHPUnit\Framework\ExpectationFailedException

xp-framework/unittest的验证详情

自1.1.0版本起可用。

如果存在xp-framework/unittest,将使用\util\Objects::equal()

如果验证失败,将抛出\unittest\AssertionFailedError

模拟注入函数

自3.1.0版本起可用。

有时需要模拟一个函数。这可能是当使用PHP的本地fsockopen()函数时。一种方法是在调用该函数的命名空间中重新定义此函数,并让此重新定义决定做什么。

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
}

然而,这种方法并不最优,因为很可能不仅需要模拟函数,还需要评估它是否被调用,也许如果它是否用正确的参数调用。

bovigo/callmap建议使用函数注入。而不是硬编码fsockopen()函数的使用,甚至为了抽象此函数而引入新的接口,为什么不将其作为可调用的注入呢?

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
}

现在可以使用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);
    }
}

NewInstance::of()一样,使用NewCallable::of()生成的可调用对象在未通过returns()方法指定返回值时将调用原始函数。如果模拟的函数必须不被调用,可以使用NewCallable::stub()而不是。

$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'));

与方法的调用图一样,可以设置多个不同的调用结果。

NewCallable::of('strlen')->returns(onConsecutiveCalls(5, 9, 10));
NewCallable::of('strlen')->returns(throws(new \Exception('failure!')));

对于后者,自3.2.0版本起有一个快捷方式。

NewCallable::of('strlen')->throws(new \Exception('failure!'));

也可以像方法调用一样验证函数调用。

$strlen = NewCallable::of('strlen');
// do something with $strlen
verify($strlen)->wasCalledOnce();
verify($strlen)->received('Hello world');

适用于方法验证的一切都可以应用于函数验证,见上面。唯一的区别是,对于verify()的第二个参数可以省略,因为没有必须命名的函数。