首页 > 文章列表 > 深入理解PhpSpec

深入理解PhpSpec

断言 测试驱动开发 BDD(行为驱动开发)
464 2023-09-01

如果你将 PhpSpec 与其他测试框架进行比较,你会发现它是一个非常复杂且固执己见的工具。原因之一是 PhpSpec 不是一个像您已经知道的那样的测试框架。

相反,它是一种有助于描述软件行为的设计工具。使用 PhpSpec 描述软件行为的一个副作用是,您最终会得到随后也可用作测试的规范。

在本文中,我们将深入了解 PhpSpec 的内部结构,并尝试更深入地了解它的工作原理以及如何使用它。

如果您想温习 phpspec,请查看我的入门教程。

在本文中...

  • PhpSpec 内部结构快速浏览
  • TDD 和 BDD 之间的区别
  • PhpSpec(与 PHPUnit)有何不同
  • PhpSpec:设计工具

PhpSpec 内部结构快速浏览

让我们首先看看构成 PhpSpec 的一些关键概念和类。

了解 $this

了解 $this 所指的内容是了解 PhpSpec 与其他工具有何不同的关键。基本上,$this 指的是被测试的实际类的实例。让我们尝试对此进行更多研究,以便更好地理解我们的意思。

首先,我们需要一个规范和一个类来使用。如您所知,PhpSpec 的生成器使我们变得非常简单:

$ phpspec desc "SuhmHelloWorld"
$ phpspec run
Do you want me to create `SuhmHelloWorld` for you? y

接下来,打开生成的规范文件,让我们尝试获取有关 $this 的更多信息:

<?php

namespace specSuhm;

use PhpSpecObjectBehavior;
use ProphecyArgument;

class HelloWorldSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType('SuhmHelloWorld');

        var_dump(get_class($this));
    }
}

get_class() 返回给定对象的类名。在本例中,我们只需将 $this 扔进去即可查看它返回的内容:

$ string(24) "specSuhmHelloWorldSpec"

好吧,这并不奇怪,get_class() 告诉我们 $thisspecSuhmHelloWorldSpec 的实例。这是有道理的,因为毕竟这只是普通的旧 PHP 代码。如果我们使用 get_parent_class(),我们将得到 PhpSpecObjectBehavior,因为我们的规范扩展了此类。

记住,我刚刚告诉过您 $this 实际上引用了被测试的类,在我们的例子中是 SuhmHelloWorld ?如您所见,get_class($this) 的返回值与 $this->shouldHaveType('SuhmHelloWorld'); 相矛盾。

让我们尝试一下其他方法:

<?php

namespace specSuhm;

use PhpSpecObjectBehavior;
use ProphecyArgument;

class HelloWorldSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType('SuhmHelloWorld');

        var_dump(get_class($this));

        $this->dumpThis()->shouldReturn('specSuhmHelloWorldSpec');
    }
}

通过上述代码,我们尝试在 HelloWorld 实例上调用名为 dumpThis() 的方法。我们将期望链接到方法调用,期望函数的返回值是包含 "specSuhmHelloWorldSpec" 的字符串。这是上面一行中 get_class() 的返回值。

同样,PhpSpec 生成器可以帮助我们构建一些脚手架:

$ phpspec run
Do you want me to create `SuhmHelloWorld::dumpThis()` for you? y

让我们也尝试从 dumpThis() 内部调用 get_class()

<?php

namespace Suhm;

class HelloWorld
{

    public function dumpThis()
    {
        return get_class($this);
    }
}

同样,毫不奇怪,我们得到:

  10  ✘ it is initializable
      expected "specSuhmHelloWorldSpec", but got "SuhmHelloWorld".

看起来我们在这里遗漏了一些东西。我一开始就告诉您 $this 并不是指您认为的内容,但到目前为止,我们的实验没有显示出任何意外的结果。除了一件事:在 $this->dumpThis() 存在之前,我们如何调用它而不让 PHP 尖叫?

为了理解这一点,我们需要深入研究 PhpSpec 源代码。如果您想亲自查看,可以阅读 GitHub 上的代码。

查看 src/PhpSpec/ObjectBehavior.php(我们的规范扩展的类)中的以下代码:

/**
 * Proxies all call to the PhpSpec subject
 *
 * @param string $method
 * @param array  $arguments
 *
 * @return mixed
 */
public function __call($method, array $arguments = array())
{
    return call_user_func_array(array($this->object, $method), $arguments);
}

评论泄露了大部分内容:“代理所有对 PhpSpec 主题的调用”。 PHP __call 方法是一种神奇方法,每当方法不可访问(或不存在)时就会自动调用。

这意味着当我们尝试调用 $this->dumpThis() 时,该调用显然已代理到 PhpSpec 主题。如果您查看代码,您可以看到方法调用被代理到 $this->object。 (我们实例上的属性也是如此。它们也都使用其他魔法方法代理到主题。请查看源代码以亲自查看。)

让我们再查询一下 get_class() ,看看它对 $this->object 有何说明:

<?php

namespace specSuhm;

use PhpSpecObjectBehavior;
use ProphecyArgument;

class HelloWorldSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType('SuhmHelloWorld');

        var_dump(get_class($this->object));
    }
}

看看我们得到了什么:

string(23) "PhpSpecWrapperSubject"

有关 主题

Subject 是一个包装器并实现 PhpSpecWrapperWrapperInterface。它是 PhpSpec 的核心部分,并允许该框架发挥所有[看似]的魔力。它包装了我们正在测试的类的实例,以便我们可以执行各种操作,例如调用不存在的方法和属性以及设置期望。

如前所述,PhpSpec 对于您应该如何编写和规范代码非常有自己的看法。一种规格对应一种类别。每个规范只有一个主题,PhpSpec 会仔细为您包装。需要注意的重要一点是,这允许您使用 $this 就好像它是实际实例一样,并形成真正可读且有意义的规范。

PhpSpec 包含一个 Wrapper ,它负责实例化 Subject。它将 Subject 与我们正在指定的实际对象打包在一起。由于 Subject 实现了 WrapperInterface ,因此它必须有一个 getWrappedObject() 方法来让我们访问该对象。这是我们之前使用 get_class() 搜索的对象实例。

让我们再尝试一下:

<?php

namespace specSuhm;

use PhpSpecObjectBehavior;
use ProphecyArgument;

class HelloWorldSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType('SuhmHelloWorld');

        var_dump(get_class($this->object->getWrappedObject()));

        // And just to be completely sure:
        var_dump($this->object->getWrappedObject()->dumpThis());
    }
}

就这样:

$ vendor/bin/phpspec run
string(15) "SuhmHelloWorld"
string(15) "SuhmHelloWorld"

尽管幕后发生了很多事情,但最终我们仍在使用 SuhmHelloWorld 的实际对象实例。一切都很好。

之前,当我们调用 $this->dumpThis() 时,我们了解到该调用实际上是如何代理到 Subject 的。我们还了解到 Subject 只是一个包装器,而不是实际的对象。

有了这些知识,很明显,如果没有其他神奇的方法,我们就无法在 Subject 上调用 dumpThis()Subject 也有一个 __call() 方法:

/**
 * @param string $method
 * @param array  $arguments
 *
 * @return mixed|Subject
 */
public function __call($method, array $arguments = array())
{
  if (0 === strpos($method, 'should')) {
      return $this->callExpectation($method, $arguments);
  }

  return $this->caller->call($method, $arguments);
}

此方法执行以下两件事之一。首先,它检查方法名称是否以“should”开头。如果是,则为期望,并且调用将委托给名为 callExpectation() 的方法。如果不是,则调用将委托给 PhpSpecWrapperSubjectCaller 的实例。

我们暂时忽略 Caller。它也包含包装的对象并知道如何调用它的方法。 Caller 在调用主题的方法时返回一个包装实例,允许我们将期望链接到方法,就像我们对 dumpThis() 所做的那样。

我们来看看 callExpectation() 方法:

/**
 * @param string $method
 * @param array  $arguments
 *
 * @return mixed
 */
private function callExpectation($method, array $arguments)
{
    $subject = $this->makeSureWeHaveASubject();

    $expectation = $this->expectationFactory->create($method, $subject, $arguments);

    if (0 === strpos($method, 'shouldNot')) {
        return $expectation->match(lcfirst(substr($method, 9)), $this, $arguments, $this->wrappedObject);
    }

    return $expectation->match(lcfirst(substr($method, 6)), $this, $arguments, $this->wrappedObject);
}

此方法负责构建 PhpSpecWrapperSubjectExpectationExpectationInterface 的实例。此接口规定了 match() 方法,callExpectation() 调用该方法来检查期望。有四种不同类型的期望:PositiveNegativePositiveThrowNegativeThrow。每个期望都包含 PhpSpecMatcherMatcherInterface 的实例,match() 方法使用该实例。接下来让我们看看匹配器。

匹配器

匹配器是我们用来确定对象行为的东西。每当我们写 should...shouldNot... 时,我们都在使用匹配器。您可以在我的个人博客上找到 PhpSpec 匹配器的完整列表。

PhpSpec 中包含许多匹配器,所有这些都扩展了 PhpSpecMatcherBasicMatcher 类,该类实现了 MatcherInterface。匹配器的工作方式非常简单。让我们一起看一下,我鼓励您也看一下源代码。

作为示例,我们来看看 IdentityMatcher 中的这段代码:

/**
 * @var array
 */
private static $keywords = array(
    'return',
    'be',
    'equal',
    'beEqualTo'
);

/**
 * @param string $name
 * @param mixed  $subject
 * @param array  $arguments
 *
 * @return bool
 */
public function supports($name, $subject, array $arguments)
{
    return in_array($name, self::$keywords)
        && 1 == count($arguments)
    ;
}

supports() 方法由 MatcherInterface 指定。在本例中,为 $keywords 数组中的匹配器定义了四个别名。这将允许匹配器支持: shouldReturn()shouldBe()shouldEqual()shouldBeEqualTo()shouldNotReturn() shouldNotBe() , shouldNotEqual()shouldNotBeEqualTo()

BasicMatcher 继承了两个方法:positiveMatch() NegativeMatch()。它们看起来像这样:

/**
 * @param string $name
 * @param mixed  $subject
 * @param array  $arguments
 *
 * @return mixed
 *
 *   @throws FailureException
 */
final public function positiveMatch($name, $subject, array $arguments)
{
    if (false === $this->matches($subject, $arguments)) {
        throw $this->getFailureException($name, $subject, $arguments);
    }

    return $subject;
}

如果 matches() 方法(匹配器必须实现的抽象方法)返回 false,则 positiveMatch() 方法会引发异常。 maleMatch() 方法的工作方式相反。 IdentityMatchermatches() 方法使用 === 运算符将 $subject 与提供给匹配器方法的参数进行比较:

/**
 * @param mixed $subject
 * @param array $arguments
 *
 * @return bool
 */
protected function matches($subject, array $arguments)
{
   return $subject === $arguments[0];
}

我们可以像这样使用匹配器:

$this->getUser()->shouldNotBeEqualTo($anotherUser);

最终会调用 negativeMatch() 并确保 matches() 返回 false。

看看其他一些匹配器,看看他们做了什么!

更多魔法的承诺

在结束 PhpSpec 内部结构的简短游览之前,让我们看一下另外一个神奇之处:

<?php

namespace specSuhm;

use PhpSpecObjectBehavior;
use ProphecyArgument;

class HelloWorldSpec extends ObjectBehavior
{
    function it_is_initializable(StdClass $object)
    {
        $this->shouldHaveType('SuhmHelloWorld');

        var_dump(get_class($object));
    }
}

通过将类型暗示的 $object 参数添加到我们的示例中,PhpSpec 将自动使用反射来注入该类的实例以供我们使用。但是根据我们已经看到的情况,我们真的相信我们真的得到了 StdClass 的实例吗?让我们再查询一下 get_class()

$ vendor/bin/phpspec run
string(28) "PhpSpecWrapperCollaborator"

不。我们得到的是 PhpSpecWrapperCollaborator 的实例,而不是 StdClass。这是关于什么的?

SubjectCollaborator 是一个包装器并实现 WrapperInterface。它包装了 ProphecyProphecyObjectProphecy 的实例,该实例源于 Prophecy,与 PhpSpec 一起提供的模拟框架。 PhpSpec 为我们提供了一个模拟,而不是 StdClass 实例。这使得使用 PhpSpec 进行模拟变得非常容易,并允许我们向对象添加承诺,如下所示:

$user->getAge()->willReturn(10);

$this->setUser($user);
$this->getUserStatus()->shouldReturn('child');

通过对 PhpSpec 内部结构部分的简短介绍,我希望您看到它不仅仅是一个简单的测试框架。

TDD 和 BDD 之间的区别

PhpSpec是一个做SpecBDD的工具,所以为了更好的理解,我们来看看测试驱动开发(TDD)和行为驱动开发(BDD)之间的区别。之后,我们将快速了解一下 PhpSpec 与 PHPUnit 等其他工具的不同之处。

TDD 是让自动化测试驱动代码的设计和实现的概念。通过为每个功能编写小测试,在实际实现它们之前,当我们通过测试时,我们知道我们的代码满足该特定功能。通过测试后,重构后,我们停止编码并编写下一个测试。口头禅是“红”、“绿”、“重构”!

BDD 起源于 - 并且与 - TDD 非常相似。老实说,这主要是一个措辞问题,这确实很重要,因为它可以改变我们作为开发人员的思维方式。 TDD 谈论测试,BDD 谈论描述行为。

使用 TDD,我们专注于验证我们的代码是否按照我们期望的方式工作,而使用 BDD,我们专注于验证我们的代码实际上按照我们希望的方式运行。 BDD作为TDD的替代方案出现的一个主要原因是避免使用“测试”这个词。对于 BDD,我们并不是真正对测试代码的实现感兴趣,我们更感兴趣的是测试它的作用(它的行为)。当我们进行 BDD(而不是 TDD)时,我们有故事和规范。这些使得编写传统测试变得多余。

故事和规格与项目利益相关者的期望密切相关。撰写故事(使用 Behat 等工具)最好与利益相关者或领域专家一起进行。这些故事涵盖了外部行为。我们使用规范来设计完成故事步骤所需的内部行为。故事中的每个步骤可能需要通过编写规范和实现代码进行多次迭代才能得到满足。我们的故事和我们的规格帮助我们确保我们不仅构建了一个可用的东西,而且它也是正确的东西。因此,BDD 与沟通有很大关系。

PhpSpec 与 PHPUnit 有何不同?

几个月前,PHP 社区的著名成员 Mathias Verraes 在 Twitter 上发布了“推文中的单元测试框架”。重点是将功能单元测试框架的源代码放入一条推文中。正如您从要点中看到的,该代码确实具有功能性,并且允许您编写基本的单元测试。单元测试的概念实际上非常简单:检查某种断言并通知用户结果。

当然,大多数测试框架,例如 PHPUnit,确实比 Mathias 的框架更先进,并且可以做更多的事情,但它仍然显示了一个重要的点:你断言某些东西,然后你的框架为你运行该断言.

让我们看一下一个非常基本的 PHPUnit 测试:

public function testTrue()
{
   $this->assertTrue(false);
}

您能否编写一个可以运行此测试的测试框架的超级简单实现?我很确定答案是“是”,你可以做到。毕竟,assertTrue() 方法唯一要做的就是将值与 true 进行比较,如果失败则抛出异常。从本质上讲,所发生的事情实际上非常简单。

那么 PhpSpec 有何不同?首先,PhpSpec 不是一个测试工具。测试代码并不是 PhpSpec 的主要目标,但如果您使用它通过增量添加行为规范 (BDD) 来设计软件,它就会产生副作用。

其次,我想上面几节应该已经讲清楚了 PhpSpec 的不同之处。不过,让我们比较一些代码:

// PhpSpec
function it_is_initializable()
{
    $this->shouldHaveType('SuhmHelloWorld');
}

// PHPUnit
function testIsInitializable()
{
    $object = new SuhmHelloWorld();

    $this->assertInstanceOf('SuhmHelloWorld', $object);
}

因为 PhpSpec 非常固执己见,并对我们的代码如何设计做出了一些断言,所以它为我们提供了一种非常简单的方法来描述我们的代码。另一方面,PHPUnit 不会对我们的代码做出任何断言,而是让我们做我们想做的事情。基本上,在这个示例中,PHPUnit 为我们所做的一切就是针对 instanceof 运算符运行 $object

尽管 PHPUnit 看起来可能更容易上手(我不这么认为),但如果你不小心,你很容易陷入糟糕的设计和架构的陷阱,因为它几乎可以让你做任何事情。话虽如此,PHPUnit 对于许多用例来说仍然非常有用,但它不是像 PhpSpec 这样的设计工具。没有指导 - 你必须知道你在做什么。

PhpSpec:设计工具

从 PhpSpec 网站,我们可以了解到 PhpSpec 是:

通过规范驱动紧急设计的 php 工具集。

我再说一遍:PhpSpec 不是一个测试框架。它是一个开发工具。一种软件设计工具。它不是一个比较值并抛出异常的简单断言框架。它是一个帮助我们设计和构建精心设计的代码的工具。它要求我们考虑代码的结构并强制执行某些架构模式,其中一个类映射到一个规范。如果你打破了单一责任原则并需要部分模拟某些东西,你将不被允许这样做。

祝您规格愉快!

哦!最后,=由于 PhpSpec 本身已被规范,我建议您转到 GitHub 并探索源代码以了解更多信息。