phpspec/prophecy

专为 PHP 5.3+ 设计的具有高度意见的模拟框架

安装: 493 178 411

依赖者: 695

建议者: 4

安全性: 0

星星: 8 527

关注者: 32

分支: 241

开放问题: 101

v1.19.0 2024-02-29 11:52 UTC

README

Stable release Build

Prophecy 是一个具有高度意见但非常强大且灵活的 PHP 对象模拟框架。尽管最初它是为了满足 phpspec2 的需求而创建的,但它足够灵活,可以轻松地用于任何测试框架中。

一个简单的例子

<?php

class UserTest extends PHPUnit\Framework\TestCase
{
    private $prophet;

    public function testPasswordHashing()
    {
        $hasher = $this->prophet->prophesize('App\Security\Hasher');
        $user   = new App\Entity\User($hasher->reveal());

        $hasher->generateHash($user, 'qwerty')->willReturn('hashed_pass');

        $user->setPassword('qwerty');

        $this->assertEquals('hashed_pass', $user->getPassword());
    }

    protected function setUp()
    {
        $this->prophet = new \Prophecy\Prophet;
    }

    protected function tearDown()
    {
        $this->prophet->checkPredictions();
    }
}

安装

先决条件

Prophecy 需要 PHP 7.2.0 或更高版本。

通过 composer 安装

首先,将 Prophecy 添加到你的 composer.json 文件中依赖列表中

{
    "require-dev": {
        "phpspec/prophecy": "~1.0"
    }
}

然后简单地使用 composer 安装它

$> composer install --prefer-dist

你可以在其 官方网站 上了解更多关于 Composer 的信息。

如何使用它

首先,在 Prophecy 中每个词都有其逻辑意义,甚至库本身的名称(Prophecy)也是如此。当你开始感受到这一点时,你将非常熟练地使用这个工具。

例如,Prophecy 被命名为这样是因为它专注于用非常有限的知识描述对象的未来行为。但与其他预言一样,这些对象预言不能自己创建 - 应该有一个先知

$prophet = new Prophecy\Prophet;

先知通过 预言 创建预言

$prophecy = $prophet->prophesize();

prophesize() 方法调用的结果是 ObjectProphecy 类的新对象。是的,这就是你的特定对象预言,它描述了你的对象在不久的将来会如何表现。但首先,你需要指定你正在谈论哪个对象,对吗?

$prophecy->willExtend('stdClass');
$prophecy->willImplement('SessionHandlerInterface');

有两个有趣的调用 - willExtendwillImplement。前者告诉对象预言我们的对象应该扩展一个特定的类。后者表示它应该实现一些接口。显然,PHP 中的对象可以实现多个接口,但只能扩展一个父类。

虚拟对象

好的,现在我们有了我们的对象预言。我们能用它做什么?首先,我们可以通过揭示它的预言来获取我们的对象 虚拟对象

$dummy = $prophecy->reveal();

$dummy 变量现在持有了一个特殊的虚拟对象。虚拟对象是通过覆盖所有公共方法来扩展和/或实现预设类/接口的对象。虚拟对象的关键点在于它们不包含任何逻辑 - 它们只是什么也不做。虚拟对象的任何方法都会始终返回 null,并且虚拟对象永远不会抛出任何异常。如果你不关心这个替身的行为,只需要一个满足方法类型提示的标记对象,虚拟对象就是你的朋友。

你需要理解一件事 - 虚拟对象不是预言。你的对象预言仍然分配给 $prophecy 变量,并且为了操纵你的期望,你应该使用它。$dummy 是一个虚拟对象 - 一个简单的 php 对象,它试图满足你的预言。

存根

好的,现在我们知道如何创建基本的预言并从它们中揭示虚拟对象。如果我们不关心我们的 替身(反映原始对象的对象)之间的交互,那很棒。如果我们关心,我们需要使用 存根模拟

存根(Stub)是一种双重对象,它不对对象的行为有任何预期,但当放入特定环境时,会以特定方式行为。好吧,我知道这很晦涩,但请耐心听我说一分钟。简单来说,存根是一个哑元,根据被调用方法的签名执行不同的操作(具有逻辑)。要在Prophecy中创建存根:

$prophecy->read('123')->willReturn('value');

哇哦。我们刚刚对对象预言做了任意调用?是的,我们确实这样做了。这个调用返回给我们一个类MethodProphecy的新对象实例。是的,这是一个带有参数的具体方法的预言。方法预言使您能够创建方法承诺或预测。我们将在模拟部分后面讨论方法预测。

承诺

承诺是逻辑块,代表预言术语中的虚构方法,并由MethodProphecy::will(PromiseInterface $promise)方法处理。实际上,我们之前做出的调用(willReturn('value'))是一个简单的快捷方式来

$prophecy->read('123')->will(new Prophecy\Promise\ReturnPromise(array('value')));

这个承诺将导致任何对双重的read()方法具有确切一个参数——'123'的调用都始终返回'value'。但这只是对于这个承诺,还有许多其他的你可以使用

  • ReturnPromise->willReturn(1) - 从方法调用返回一个值
  • ReturnArgumentPromise->willReturnArgument($index) - 返回调用中第n个方法参数
  • ThrowPromise->willThrow($exception) - 使方法抛出特定的异常
  • CallbackPromise->will($callback) - 提供一种快速定义自定义逻辑的方式

请注意,您可以通过实现Prophecy\Promise\PromiseInterface来添加更多承诺。

方法预言的幂等性

Prophecy强制执行相同的方法预言,以及由此产生的相同承诺和预测。这意味着

$methodProphecy1 = $prophecy->read('123');
$methodProphecy2 = $prophecy->read('123');
$methodProphecy3 = $prophecy->read('321');

$methodProphecy1 === $methodProphecy2;
$methodProphecy1 !== $methodProphecy3;

这很有趣,对吧?现在你可能会问我如何定义更复杂的行为,其中一些方法调用的行为会改变其他方法。在PHPUnit或Mockery中,你通过预测方法将被调用的次数来做这件事。在Prophecy中,你将使用承诺来做这件事

$user->getName()->willReturn(null);

// For PHP 5.4
$user->setName('everzet')->will(function () {
    $this->getName()->willReturn('everzet');
});

// For PHP 5.3
$user->setName('everzet')->will(function ($args, $user) {
    $user->getName()->willReturn('everzet');
});

// Or
$user->setName('everzet')->will(function ($args) use ($user) {
    $user->getName()->willReturn('everzet');
});

现在,无论方法被调用的次数或顺序如何,这都不重要。重要的是它们的行为以及你如何伪造它们。

注意:如果方法被调用多次,您可以使用以下语法为每次调用返回不同的值

$prophecy->read('123')->willReturn(1, 2, 3);

实际上,这个特性在大多数情况下并不推荐使用。依赖于相同参数的调用顺序往往会使测试变得脆弱,因为添加一个额外的调用可能会破坏一切。

参数通配符

前面的例子很棒(至少我希望对你来说是这样的),但这还不够优化。我们在期望中硬编码了'everzet'。没有更好的方法吗?事实上,确实有,但这需要你理解这个'everzet'到底是什么。

你看,即使方法预言创建期间使用的方法参数看起来像是简单的参数,但实际上并不是。它们是参数通配符。事实上,->setName('everzet')看起来像一个简单的调用,因为Prophecy自动将其转换为

$user->setName(new Prophecy\Argument\Token\ExactValueToken('everzet'));

这些参数通配符是简单的PHP类,它们实现了Prophecy\Argument\Token\TokenInterface,并告诉Prophecy如何比较真实参数与您的期望。是的,这些类名非常大。这就是为什么有一个快捷类Prophecy\Argument,您可以使用它来创建这样的通配符

use Prophecy\Argument;

$user->setName(Argument::exact('everzet'));

ExactValueToken在我们的情况下并不很有用,因为它迫使我们硬编码用户名。这就是为什么Prophecy附带了许多其他的通配符

  • IdenticalValueTokenArgument::is($value) - 检查参数是否与特定值相同
  • ExactValueTokenArgument::exact($value) - 检查参数是否与特定值匹配
  • TypeTokenArgument::type($typeOrClass) - 检查参数是否匹配特定的类型或类名
  • ObjectStateTokenArgument::which($method, $value) - 检查参数方法返回的特定值
  • CallbackTokenArgument::that(callback) - 检查参数是否匹配自定义回调
  • AnyValueTokenArgument::any() - 匹配任何参数
  • AnyValuesTokenArgument::cetera() - 匹配签名中剩余的任何参数
  • StringContainsTokenArgument::containingString($value) - 检查参数是否包含特定的字符串值
  • InArrayTokenArgument::in($array) - 检查值是否在数组中
  • NotInArrayTokenArgument::notIn($array) - 检查值是否不在数组中

您还可以通过实现 TokenInterface 并使用自己的自定义类来添加更多。

现在,让我们使用参数令牌重构我们的初始 {set,get}Name() 逻辑

use Prophecy\Argument;

$user->getName()->willReturn(null);

// For PHP 5.4
$user->setName(Argument::type('string'))->will(function ($args) {
    $this->getName()->willReturn($args[0]);
});

// For PHP 5.3
$user->setName(Argument::type('string'))->will(function ($args, $user) {
    $user->getName()->willReturn($args[0]);
});

// Or
$user->setName(Argument::type('string'))->will(function ($args) use ($user) {
    $user->getName()->willReturn($args[0]);
});

就这样。现在,我们的 {set,get}Name() 预言将能够处理提供的任何字符串参数。我们已经描述了存根对象应该如何表现,即使原始对象可能没有任何行为。

关于参数的最后一点。您可能会问,在什么情况下

use Prophecy\Argument;

$user->getName()->willReturn(null);

// For PHP 5.4
$user->setName(Argument::type('string'))->will(function ($args) {
    $this->getName()->willReturn($args[0]);
});

// For PHP 5.3
$user->setName(Argument::type('string'))->will(function ($args, $user) {
    $user->getName()->willReturn($args[0]);
});

// Or
$user->setName(Argument::type('string'))->will(function ($args) use ($user) {
    $user->getName()->willReturn($args[0]);
});

$user->setName(Argument::any())->will(function () {
});

什么也没有。您的存根将继续按照之前的方式行为。这是因为参数通配符的工作方式。每个参数令牌类型都有一个不同的得分级别,通配符随后使用这个得分来计算最终的参数匹配得分,并使用得分最高的方法预言承诺。在这种情况下,如果成功,Argument::type() 得分为 5,而 Argument::any() 得分为 3。因此,类型令牌获胜,就像第一个 setName() 方法预言和其承诺一样。简单地说,更精确的令牌总是获胜。

获取存根对象

好的,现在我们知道了如何定义预言方法承诺,让我们从它那里获取存根

$stub = $prophecy->reveal();

如您所见,我们获取模拟对象和存根之间的唯一区别是,在存根中,我们描述了每个对象会话,而不是仅仅同意返回 null(对象是 模拟的)。实际上,在您定义了第一个承诺(方法调用)之后,预言将强制您定义所有的通信 - 如果在调用存根之前没有使用对象预言来描述任何调用,它将抛出 UnexpectedCallException

模拟

现在我们知道如何定义没有行为(模拟)的代理和有行为但无期望的代理(存根)。剩下的就是有某些期望的代理,这些被称为模拟,在预言中,它们看起来几乎与存根完全相同,只是它们在方法预言上定义的是 预测 而不是 承诺

$entityManager->flush()->shouldBeCalled();

预测

这里的 shouldBeCalled() 方法将 CallPrediction 分配给我们的方法预言。预测是您预言的延迟行为检查。您知道,在整个模拟的生命周期中,预言记录了您在代码中对其进行的每一次调用。之后,预言可以使用收集到的信息来检查它是否与定义的预测匹配。您可以使用 MethodProphecy::should(PredictionInterface $prediction) 方法将预测分配给方法预言。事实上,我们之前使用的 shouldBeCalled() 方法只是一个快捷方式

$entityManager->flush()->should(new Prophecy\Prediction\CallPrediction());

它检查您的目标方法(匹配方法名和参数通配符)是否被调用了一次或多次。如果预测失败,它将抛出异常。这个检查什么时候发生?每当您在主预言对象上调用 checkPredictions()

$prophet->checkPredictions();

在 PHPUnit 中,您希望将此调用放入 tearDown() 方法。如果没有定义任何预测,它将不会执行任何操作。因此,在每次测试后调用它不会有任何坏处。

还有很多其他的预测可以玩。

  • CallPredictionshouldBeCalled() - 检查方法是否被调用了一次或多次
  • NoCallsPredictionshouldNotBeCalled() - 检查该方法没有被调用
  • CallTimesPredictionshouldBeCalledTimes($count) - 检查该方法被调用 $count
  • CallbackPredictionshould($callback) - 将方法与您自己的自定义回调进行比较

当然,您随时可以通过实现 PredictionInterface 来创建您自己的自定义预测。

间谍

Prophecy 中的最后一部分神奇之处是内置的间谍支持。正如我在上一节中所述,Prophecy 记录了模拟对象整个生命周期期间的所有调用。这意味着您不需要记录预测以进行检查。您也可以通过使用 MethodProphecy::shouldHave(PredictionInterface $prediction) 方法手动完成

$em = $prophet->prophesize('Doctrine\ORM\EntityManager');

$controller->createUser($em->reveal());

$em->flush()->shouldHaveBeenCalled();

这种对双份对象的操作称为间谍行为。在 Prophecy 中,这只需简单设置即可。

常见问题解答(FAQ)

我能否在预言(prophesized)类上调用原始方法?

Prophecy 不支持在预言类上调用原始方法。如果您发现自己需要在模拟某些方法的同时调用其他方法的原始版本,这可能是一个迹象,表明您的类违反了 单一职责原则,并且应该进行重构。