PHP8-对象-模式和实践-九-

76 阅读33分钟

PHP8 对象、模式和实践(九)

原文:PHP 8 Objects, Patterns, and Practice

协议:CC BY-NC-SA 4.0

十八、将 PHPUnit 用于测试

系统中的每个组件都依赖于其对等组件的操作和接口的一致性来持续平稳运行。根据定义,发展会破坏系统。当您改进您的类和包时,您必须记住修改与它们一起工作的任何代码。对于某些更改,这会产生连锁反应,影响远离您最初更改的代码的组件。敏锐的警觉和对系统依赖性的广博知识有助于解决这个问题。当然,虽然这些都是优秀的优点,但是系统很快就会变得太复杂,以至于每一个不想要的效果都很难预测,尤其是因为系统经常结合了许多开发人员的工作。为了解决这个问题,定期测试每个组件是一个好主意。这当然是一项重复而复杂的任务;因此,它非常适合自动化。

在 PHP 程序员可用的测试解决方案中,PHPUnit 可能是最普遍的,当然也是功能最全的工具。在本章中,您将了解以下关于 PHPUnit 的内容:

  • 安装:使用 Composer 安装 PHPUnit

  • 编写测试:创建测试用例并使用断言方法

  • 异常处理:确认失败的策略

  • 运行多个测试:将测试收集到套件中

  • 构建断言逻辑:使用约束

  • 伪造组件:模拟和存根

  • 测试 web 应用:使用和不使用附加工具进行测试

功能测试和单元测试

测试在任何项目中都是必不可少的。即使你没有正式化这个过程,你也一定已经发现自己在开发非正式的行动列表,来测试你的系统。这个过程很快变得令人厌倦,这可能会导致你的项目出现交叉手指的情况。

一种测试方法是从项目的界面开始,模拟用户协商系统的各种方式。虽然有各种自动化过程的框架,但这可能是您手工测试时要走的路。这些功能测试有时被称为验收测试,因为成功执行的一系列操作可以被用作结束项目阶段的标准。使用这种方法,您通常将系统视为一个黑盒——您的测试仍然故意忽略协作形成被测系统的隐藏组件。

功能测试是从外部进行的,而单元测试是从内部进行的。单元测试倾向于关注类,测试方法在测试用例中组合在一起。每个测试用例对一个类进行严格的测试,检查每种方法是否如宣传的那样执行,是否如预期的那样失败。我们的目标是尽可能地从更广泛的背景中孤立地测试每个组件。这通常为您提供了一个清醒的结论,即您分离系统各部分的任务是成功的。

测试可以作为构建过程的一部分运行,直接从命令行运行,甚至可以通过网页运行。在这一章中,我将集中讨论命令行。

单元测试是确保系统设计质量的好方法。测试揭示了类和函数的职责。一些程序员甚至提倡测试优先的方法。他们说,你应该在开始上课之前写测试。这规定了一个类的目的,确保一个干净的接口和简短、集中的方法。就我个人而言,我从未渴望达到这种程度的纯净——这不适合我的编码风格。尽管如此,我还是试图边走边写测试。维护测试工具为我提供了重构代码所需的安全性。我可以下载并替换整个包,因为我知道我很有可能在系统的其他地方捕捉到意外的错误。

手工测试

在上一节中,我说过测试在每个项目中都是必不可少的。我可以说测试在每个项目中都是不可避免的。我们都会测试。悲剧的是,我们经常把这个好作品丢掉。

所以,让我们创建一些类来测试。这是一个存储和检索用户信息的类。为了便于演示,它生成数组,而不是您通常期望使用的User对象:

// listing 18.01
class UserStore
{
    private array $users = [];

    public function addUser(string $name, string $mail, string $pass): bool
    {
        if (isset($this->users[$mail])) {
            throw new \Exception(
                "User {$mail} already in the system"
            );
        }

        if (strlen($pass) <  5) {
            throw new \Exception(
                "Password must have 5 or more letters"
            );
        }

        $this->users[$mail] = [
            'pass' => $pass,
            'mail' => $mail,
            'name' => $name
        ];

        return true;
    }

    public function notifyPasswordFailure(string $mail): void
    {
        if (isset($this->users[$mail])) {
            $this->users[$mail]['failed'] = time();
        }
    }

    public function getUser(string $mail): array
    {
        return ($this->users[$mail]);
    }
}

这个类用addUser()方法接受用户数据,并通过getUser()检索它。用户的电子邮件地址用作检索的关键字。如果您和我一样,您将在开发时编写一些示例实现,只是为了检查事情是否如您设计的那样运行:

// listing 18.02
$store = new UserStore();
$store->addUser(
    "bob williams",
    "bob@example.com",
    "12345"
);
$store->notifyPasswordFailure("bob@example.com");
$user = $store->getUser("bob@example.com");
print_r($user);

以下是输出:

Array
(
    [pass] => 12345
    [mail] => bob@example.com
    [name] => bob williams
    [failed] => 1609766967
)

这是我在处理文件包含的类时可能会添加到文件底部的那种东西。当然,测试验证是手工执行的;由我来观察结果,并确认由UserStore::getUser()返回的数据与我最初添加的信息相符。然而,这是一种考验。

下面是一个客户端类,它使用UserStore来确认用户提供了正确的认证信息:

// listing 18.03
class Validator
{

    public function __construct(private UserStore $store)
    {
    }

    public function validateUser(string $mail, string $pass): bool
    {
        if (! is_array($user = $this->store->getUser($mail))) {
            return false;
        }

        if ($user['pass'] == $pass) {
            return  true;
        }

        $this->store->notifyPasswordFailure($mail);

        return false;
    }
}

该类需要一个UserStore对象,它将该对象保存在$store属性中。这个属性被validateUser()方法用来确保,首先,给定电子邮件地址引用的用户存在于存储中,其次,用户的密码与提供的参数匹配。如果这两个条件都满足,该方法返回true。再一次,我可能会边走边测试这一点:

// listing 18.04
$store = new UserStore();
$store->addUser("bob williams", "bob@example.com", "12345");
$validator = new Validator($store);
if ($validator->validateUser("bob@example.com", "12345")) {
    print "pass, friend!\n";
}

我实例化了一个UserStore对象,用数据填充并传递给一个新实例化的Validator对象。然后,我可以确认用户名和密码组合。

一旦我最终对自己的工作感到满意,我就可以完全删除这些健全检查,或者把它们注释掉。这是对宝贵资源的严重浪费。这些测试可以作为我开发时检查系统的工具的基础。PHPUnit 是可能帮助我做到这一点的工具之一。

PHPUnit 简介

PHPUnit 是 xUnit 测试工具家族的一员。其前身是 SUnit,这是一个由 Kent Beck 发明的框架,用于测试用 Smalltalk 语言构建的系统。xUnit 框架可能是作为一个流行的工具而建立的,然而,它是由 Java 实现 jUnit 以及像极限编程(XP)和 Scrum 这样的敏捷方法的兴起而建立的,所有这些都非常强调测试。

您可以使用 Composer 获得 PHPUnit:

{
    "require-dev":  {
        "phpunit/phpunit":  "⁹"
    }
}

一旦您运行了 composer install,您将在vendor/bin/phpunit找到phpunit脚本。或者,您可以下载一个 PHP 归档文件(。phar)文件。然后,您可以使归档文件成为可执行文件:

$ wget https://phar.phpunit.de/phpunit.phar
$ chmod 755 phpunit.phar
$ sudo mv phpunit.phar /usr/local/bin/phpunit

Note

我显示了在命令行输入的命令,并在前面加了一个$来表示命令提示符,以区别于它们可能产生的任何输出。

创建测试用例

有了 PHPUnit,我可以为UserStore类编写测试。每个目标组件的测试应该收集在一个扩展了PHPUnit\Framework\TestCase的类中,这是 PHPUnit 包提供的类之一。下面是如何创建一个最小的测试用例类:

// listing 18.05
namespace popp\ch18\batch01;
use PHPUnit\Framework\TestCase;
class UserStoreTest extends TestCase
{

    protected function setUp(): void
    {
    }

    protected function tearDown(): void
    {
    }

}

我将测试用例类命名为UserStoreTest。将测试放在与被测类相同的名称空间中通常是有用的。这将使您能够方便地访问被测试的类和它的同类,并且您的测试文件的结构将很可能反映您的系统的结构。请记住,由于 Composer 对 PSR-4 的支持,您可以在同一个包中为类文件维护单独的目录结构。

下面是我们在 Composer 中实现这一点的方法:

    "autoload": {
        "psr-4": {
            "popp\\": ["myproductioncode/", "mytestcode/"]
        }
    }

在这段代码中,我指定了两个映射到popp名称空间的目录。我现在可以并行维护这些代码,这样就可以很容易地将我的测试和生产代码分开。

每个测试方法都会自动调用setUp()方法,这允许我们为测试建立一个稳定的、适当准备的环境。在每个测试方法运行后调用tearDown()。如果您的测试改变了系统的大环境,您可以使用这个方法来重置状态。由setUp()tearDown()管理的公共平台被称为夹具

为了测试UserStore类,我需要它的一个实例。我可以在setUp()中实例化它,并将其分配给一个属性。让我们也创建一个测试方法:

// listing 18.06
namespace popp\ch18\batch01;
use PHPUnit\Framework\TestCase;
class UserStoreTest extends TestCase
{
    private UserStore $store;

    protected function setUp(): void
    {
        $this->store = new UserStore();
    }

    protected function tearDown(): void
    {
    }

    public function testGetUser(): void
    {
        $this->store->addUser("bob williams", "a@b.com", "12345");
        $user = $this->store->getUser("a@b.com");
        $this->assertEquals("a@b.com", $user['mail']);
        $this->assertEquals("bob williams", $user['name']);
        $this->assertEquals("12345", $user['pass']);
    }
}

Note

记住setUp()tearDown()对于你的类中的每个测试方法都被调用一次。如果你想包含在一个类中所有测试方法之前运行一次的代码,你可以实现setUpBeforeClass()方法。相反,对于应该在一个类中所有测试方法之后运行的代码,实现tearDownAfterClass()

测试方法应该以单词“Test”开始命名,并且不需要任何参数。这是因为测试用例类是使用反射操作的。

Note

反射在第五章中有详细介绍。

运行测试的对象查看类中的所有方法,并且只调用那些匹配这个模式的方法(即以“test”开头的方法)。

在示例中,我测试了用户信息的检索。我不需要为每个测试实例化UserStore,因为我在setUp()中处理了它。因为每个测试都会调用setUp(),所以$store属性肯定会包含一个新实例化的对象。

testgetUser()方法中,我首先向UserStore::addUser()提供虚拟数据,然后检索该数据并测试其每个元素。

在我们运行测试之前,还有一个问题需要注意。我使用了不带requirerequire_onceuse语句。换句话说,我依靠自动加载。如果在 Composer 中安装了 phpunit,并且项目的自动加载文件是在相同的上下文中生成的,那么查找和包含自动加载文件是自动处理的。然而,情况可能并不总是如此。例如,我可能正在运行一个全局 PHPUnit 命令,它对我的本地自动加载一无所知,或者我可能已经下载了一个 phar 文件。在这种情况下,我如何告诉我的测试如何定位生成的autoload.php文件?我可以在测试类(或超类)中放一个require_once语句,但是这将打破 PSR-1 规则,即类文件不应该有副作用。最简单的方法是从命令行告诉 PHPUnit 关于autoload.php文件的信息:

$ phpunit src/ch18/batch01/UserStoreTest.php --bootstrap vendor/autoload.php
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
.                                1 / 1 (100%)

Time: 00:00.012, Memory: 4.00 MB

OK (1 test, 3 assertions)

断言方法

编程中的断言是一种语句或方法,它允许您检查关于系统某个方面的假设。在使用断言时,你通常定义一个期望,即事情是这样的,即$cheese"blue"或者$pie"apple"。如果你的期望是混乱的,某种警告将会产生。断言是给系统增加安全性的一个很好的方式,PHP 原生地内联支持它们,并允许您在生产环境中关闭它们。

Note

关于 PHP 对断言的支持的更多信息,请参见手册页 https://php.net/assert

PHPUnit 通过一组方法支持断言,这些方法可以静态调用,也可以在扩展了PHPUnit\Framework\TestCase的类的实例上调用。

在前面的例子中,我使用了一个TestCase方法,assertEquals()。此方法比较其提供的两个参数,并检查它们是否等价。如果它们不匹配,测试方法将被记为失败的测试。子类化了PHPUnit\Framework\TestCase之后,我可以使用一组断言方法。表 18-1 中列出了其中一些方法。

表 18-1

PHPUnit\Framework\TestCase断言方法

|

方法

|

描述

| | --- | --- | | assertEquals($val1, $val2, $message) | 如果$val1不等于$val2则失败 | | assertFalse($expression, $message) | 评估$expression;如果没有解析为false则失败 | | assertTrue($expression, $message) | 评估$expression;如果没有解析为true则失败 | | assertNotNull($val, $message) | 如果$valnull则失败 | | assertNull($val, $message) | 如果$val不是null则失败 | | assertSame($val1, $val2, $message) | 如果$val1$val2不是对同一个对象的引用,或者如果它们是不同类型或不同值的变量,则失败 | | assertNotSame($val1, $val2, $message) | 如果$val1$val2是对相同类型和值的相同对象或变量的引用,则失败 | | assertMatchesRegularExpression($regexp, $val, $message) | 如果正则表达式$regexp$val不匹配,则失败 |

测试异常

作为一名程序员,你的重点通常是让东西工作并且工作得很好。通常,这种心态会贯穿到测试中,尤其是当你测试你自己的代码时。诱惑是测试一个方法的行为是否如广告所说的那样。很容易忘记测试失败有多重要。方法的错误检查有多好?它会在应该抛出异常的时候抛出异常吗?它抛出了正确的异常吗?例如,如果在问题发生之前操作已经完成了一半,那么它会在错误发生后进行清理吗?作为测试人员,您的任务是检查所有这些。幸运的是,PHPUnit 可以提供帮助。

下面是一个测试,用于检查操作失败时UserStore类的行为:

// listing 18.07
public function testAddUserShortPass(): void
{
    try {
        $this->store->addUser("bob williams", "bob@example.com", "ff");
    } catch (\Exception $e) {
        $this->assertEquals("Password must have 5 or more letters", $e->getMessage());
        return;
    }

    $this->fail("Short password exception expected");
}

如果你回头看一下UserStore::addUser()方法,你会看到如果用户的密码少于五个字符,我会抛出一个异常。我的测试试图证实这一点。我在一个try子句中添加了一个拥有非法密码的用户。如果抛出了预期的异常,那么流程跳转到 catch 子句,一切正常。如果addUser()方法没有像预期的那样抛出异常,执行流程将到达fail()方法调用。

测试异常被抛出的另一种方法是使用名为expectException()的断言方法,该方法需要您期望抛出的异常类型的名称(或者是Exception或者是一个子类)。如果测试方法在没有抛出正确异常的情况下退出,测试将会失败。

Note

PHP 5.2.0 中加入了expectException()方法。

下面是之前测试的快速重新实现:

// listing 18.08
public function testAddUserShortPassNew(): void
{
    $this->expectException(\Exception::class);
    $this->store->addUser("bob williams", "bob@example.com", "ff");
}

那么,既然有一种测试异常的简洁方法,我为什么还要展示旧的方法呢?在大多数情况下,最简单的方法——使用expectException()——将是最好的。但是,有时您可能想要对异常、被测对象的状态执行进一步的测试,或者您可能想要清除一些副作用。在这种情况下,走老派路线可能还是有意义的。

运行测试套件

如果我在测试UserStore类,我也应该测试Validator。下面是一个名为ValidateTest的类的简化版本,它测试了Validator::validateUser()方法:

// listing 18.09
namespace popp\ch18\batch02;
use PHPUnit\Framework\TestCase;
class ValidatorTest extends TestCase
{
    private Validator $validator;

    protected function setUp(): void
    {
        $store = new UserStore();
        $store->addUser("bob williams", "bob@example.com", "12345");
        $this->validator = new Validator($store);
    }

    public function testValidateCorrectPass(): void
    {
        $this->assertTrue(
            $this->validator->validateUser("bob@example.com", "12345"),
            "Expecting successful validation"
        );
    }

}

那么现在我有了不止一个测试用例,我如何一起运行它们呢?最好的方法是将您的测试类放在一个公共的根目录下。然后您可以指定这个目录,PHPUnit 将运行它下面的所有测试:

$ phpunit src/ch18/batch02/

PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
........                         8 / 8 (100%)

Time: 00:00.026, Memory: 6.00 MB

OK (8 tests, 11 assertions)

限制

在大多数情况下,您将在测试中使用现成的断言。事实上,一口气下来,你可以独自完成很多事情。然而,从 PHPUnit 3.0 开始,PHPUnit\Framework\ TestCase包含了一组返回PHPUnit\Framework\Constraint对象的工厂方法。您可以将它们组合起来并传递给TestCase::AssertThat(),以便构建您自己的断言。

是时候快速举例了。UserStore对象不允许添加重复的电子邮件地址。这里有一个测试证实了这一点:

// listing 18.10

// UserStoreTest

public function testAddUserDuplicate()
{
    try {
        $ret = $this->store->addUser("bob williams", "a@b.com", "123456");
        $ret = $this->store->addUser("bob stevens", "a@b.com", "123456");
        $this->fail("Exception should have been thrown");
    } catch (\Exception $e) {
        $const = $this->logicalAnd(
            $this->logicalNot($this->containsEqual("bob stevens")),
            $this->isType('array'),
        );
        $this->AssertThat($this->store->getUser("a@b.com"), $const);
    }
}

这个测试将一个用户添加到UserStore对象,然后添加第二个具有相同电子邮件地址的用户。因此,测试确认第二次调用addUser()时抛出异常。在catch子句中,我使用方便的方法构建了一个约束对象。这些返回对应的PHPUnit\Framework\Constraint实例。让我们分解上一个示例中的复合约束:

$this->logicalNot($this->containsEqual("bob stevens"))

这将返回一个PHPUnit\Framework\Constraint\Traversable\TraversableContainsEqual对象。当传递给AssertThat时,如果测试主题不包含与给定值匹配的元素("bob stevens"),该对象将生成一个错误。不过,我否定了这一点,将这个约束传递给另一个:PHPUnit\Framework\Constraint\Not。我再次使用一个方便的方法,通过TestCase类(实际上是通过一个超类,Assert)可用:

$this->logicalNot( $this->contains("bob stevens"))

现在,如果测试值(必须是可遍历的)包含一个与字符串"bob stevens"匹配的元素,AssertThat断言将失败。通过这种方式,您可以构建非常复杂的逻辑结构。当我完成时,我的约束可以总结如下:“如果测试值是一个数组并且不包含字符串"bob stevens",就不要失败。”你可以用这种方式构建更多的约束。通过将两者都传递给AssertThat(),针对一个值运行约束。

当然,您可以用标准的断言方法实现所有这些,但是约束有两个优点。首先,它们形成了良好的逻辑块,组件之间有清晰的关系(尽管很好地使用格式可能是支持清晰性所必需的)。第二,也是更重要的,约束是可重用的。您可以建立一个复杂约束的库,并在不同的测试中使用它们。您甚至可以将复杂的约束相互结合:

$const = $this->logicalAnd(
    $a_complex_constraint,
    $another_complex_constraint
);

表 18-2 显示了TestCase类中可用的一些约束方法。

表 18-2

一些约束方法

|

测试用例方法

|

约束失败,除非…

| | --- | --- | | greaterThan($num) | 测试值大于$num | | containsEqual($val) | 测试值(可遍历)包含与$val匹配的元素 | | identicalTo($val) | 测试值是对与$val相同的对象的引用,或者对于非对象,是相同的类型和值 | | greaterThanOrEqual($num) | 测试值大于或等于$num | | lessThan($num) | 测试值小于$num | | lessThanOrEqual($num) | 测试值小于或等于$num | | equalTo($value) | 测试值等于$value | | equalTo($value, $delta) | 测试值等于$value$delta定义数值比较的误差范围 | | stringContains($str, $casesensitive=true) | 测试值包含$str。默认情况下,这是区分大小写的 | | matchesRegularExpression($pattern) | 测试值与$pattern中的正则表达式匹配 | | logicalAnd(PHPUnit_Framework_Constraint $const, [, $const..]) | 所有提供的约束都通过 | | logicalOr(PHPUnit_Framework_Constraint $const, [, $const..]) | 至少有一个提供的约束匹配 | | logicalNot(PHPUnit_Framework_Constraint $const) | 提供的约束未通过 |

模拟和存根

单元测试的目的是在尽可能大的程度上隔离包含组件的系统来测试组件。然而,真空中很少有组件存在。即使是很好的解耦类也需要访问其他对象作为方法参数。许多类还直接处理数据库或文件系统。

您已经看到了处理这个问题的一种方法。setUp()tearDown()方法可用于管理 fixture(即,测试的一组公共资源,可能包括数据库连接、配置的对象、文件系统上的暂存区等。).

另一种方法是伪造您正在测试的类的上下文。这包括创建假装做真实事情的对象。例如,您可能将一个假的数据库映射器传递给测试对象的构造函数。因为这个假对象与真正的映射器类共享一个类型(从一个公共的抽象基础扩展或者甚至覆盖真正的类本身),所以您的主体并不知道。你可以用有效数据填充假对象。为单元测试提供这种沙箱的对象被称为存根。它们可能是有用的,因为它们允许您将注意力集中在您想要测试的类上,而不会无意中同时测试您系统的整个架构。

然而,赝品可以比这更进一步。因为您正在测试的对象很可能以某种方式调用一个假对象,所以您可以启动它来确认您所期望的调用。以这种方式使用一个假对象作为间谍被称为行为验证,这是区分模拟对象和存根的地方。

您可以通过创建硬编码的类来返回某些值并报告方法调用,从而自己构建模拟。这是一个简单的过程,但可能很耗时。

PHPUnit 提供了更简单、更动态的解决方案。它将为您动态生成模拟对象。它通过检查您想要模仿的类并构建一个覆盖其方法的子类来实现这一点。一旦有了这个模拟实例,就可以在其上调用方法,用数据填充它,并为成功设置条件。

让我们建立一个例子。UserStore类包含一个名为notifyPasswordFailure()的方法,它为给定用户设置一个字段。当设置密码失败时,这个函数应该由Validator调用。在这里,我模拟了UserStore类,这样它既向Validator对象提供数据,又确认它的notifyPasswordFailure()方法按预期被调用:

// listing 18.11

// ValidatorTest

    public function testValidateFalsePass(): void
    {
        $store = $this->createMock(UserStore::class);
        $this->validator = new Validator($store);

        $store->expects($this->once())
            ->method('notifyPasswordFailure')
            ->with($this->equalTo('bob@example.com'));

        $store->expects($this->any())
            ->method("getUser")
            ->will($this->returnValue([
                "name" => "bob williams",
                "mail" => "bob@example.com",
                "pass" => "right"
            ]));

        $this->validator->validateUser("bob@example.com", "wrong");
    }

模拟对象使用一个流畅的接口;也就是说,它们有类似语言的结构。这些使用起来比描述起来容易得多。这种结构从左到右工作,每次调用返回一个对象引用,然后可以通过进一步的修改方法调用(本身返回一个对象)来调用。这有助于简单使用,但调试起来很痛苦。

在前面的例子中,我调用了TestCase方法createMock(),并将我希望模仿的类的名称"UserStore"传递给它。这将动态生成一个类,并从中实例化一个对象。我将这个模拟对象存储在$store中,并将其传递给Validator。这不会导致错误,因为对象新生成的类扩展了UserStore。我已经骗过了Validator接受了一名间谍加入其中。

PHPUnit生成的模拟对象有一个expects()方法。这个方法需要一个匹配器对象(实际上它的类型是PHPUnit\Framework\MockObject\Matcher\Invocation,但是不用担心;您可以使用TestCase中的便利方法来生成您的匹配器)。匹配器定义期望的基数;也就是说,它规定了一个方法应该被调用的次数。

表 18-3 显示了TestCase类中可用的匹配器方法。

表 18-3

一些匹配器方法

|

测试用例方法

|

匹配失败,除非…

| | --- | --- | | any() | 对相应的方法进行了零次或多次调用(对于返回值但不测试调用的存根对象很有用) | | never() | 不会调用相应的方法 | | atLeastOnce() | 对相应的方法进行了一次或多次调用 | | once() | 对相应的方法进行了一次调用 | | exactly($num) | $num调用相应的方法 | | at($num) | 在$num索引处对相应方法的调用(对模拟的每个方法调用都被记录和索引) |

设置了匹配需求之后,我需要指定一个方法来应用它。例如,expects()返回一个对象(PHPUnit\Framework\MockObject\Builder\ InvocationMocker,如果你一定要知道的话),这个对象有一个叫做method()的方法。我可以简单地用方法名来调用它。这足以让一些真正的嘲笑完成:

// listing 18.12
$store->expects($this->once())
    ->method('notifyPasswordFailure');

然而,我需要更进一步,检查传递给notifyPasswordFailure()的参数。InvocationMocker::method()返回它被调用的对象的实例。InvocationMocker包含一个方法名with(),它接受一个可变的参数列表进行匹配。它还接受约束对象,因此您可以测试范围等等。有了这些,您就可以完成语句并确保预期的参数被传递给notifyPasswordFailure():

// listing 18.13
$store->expects($this->once())
    ->method('notifyPasswordFailure')
    ->with($this->equalTo('bob@example.com'));

你可以看到为什么这被称为流畅的界面。它读起来有点像一个句子:“$store对象期望notifyPasswordFailure()方法with参数bob@example.com的单次调用。”

注意,我向with()传递了一个约束。其实那是多余的;任何裸参数都在内部被转换为约束,所以我可以这样写语句:

// listing 18.14
$store->expects($this->once())
    ->method('notifyPasswordFailure')
    ->with('bob@example.com');

有时,您只想使用 PHPUnit 的模拟作为存根,也就是说,作为返回值以允许您的测试运行的对象。在这种情况下,您可以从对method()的调用中调用InvocationMocker::will()will()方法需要返回值(或者多个值,如果该方法要被重复调用的话),相关联的方法应该准备好返回这些值。你可以用TestCase::returnValue()或者TestCase::onConsecutiveCalls()来传递这个返回值。再说一次,做起来比描述起来容易得多。这是我之前的例子中的片段,在这个例子中,我让UserStore返回一个值:

Note

TestCase::returnValue()TestCase::onConsecutiveCalls()并不是唯一可以用存根设置返回值的方法。还有returnValueMap()returnArguments()returnCallback()returnSelf()

// listing 18.15
$store->expects($this->any())
    ->method("getUser")
    ->will($this->returnValue([
        "name" => "bob@example.com",
        "pass" => "right"
    ]));

我准备好UserStore模拟来期待对getUser()的任意数量的调用。现在,我关心的是提供数据,而不是测试电话。接下来,我调用will(),结果是用我想要返回的数据调用TestCase::returnValue()(这恰好是一个PHPUnit\ Framework\MockObject\Stub\ReturnStub对象,尽管如果我是你,我只会记得你用来获取它的便利方法)。

您也可以将调用TestCase::onConsecutiveCalls()的结果传递给will()。它接受任意数量的参数,每个参数都将在被重复调用时被您模仿的方法返回。

失败时测试成功

尽管大多数人都认为测试是一件好事,但是只有在它救了你几次之后,你才会真正爱上它。让我们模拟一种情况,系统中某个部分的变化会对其他部分产生意想不到的影响。

UserStore类已经运行了一段时间,在一次代码审查中,大家一致认为该类生成User对象比生成关联数组更简洁。下面是新版本:

// listing 18.16
namespace  popp\ch18\batch03;

class UserStore
{
    private array $users = [];

    public function addUser(string $name, string $mail, string $pass): bool
    {
        if (isset($this->users[$mail]))   {
            throw new \Exception(
                "User {$mail} already in the system"
           );
        }

        $this->users[$mail] = new User($name, $mail, $pass);

        return true;
    }

    public function notifyPasswordFailure(string $mail): void
    {
        if (isset($this->users[$mail])) {
            $this->users[$mail]->failed(time());
        }
    }

    public function getUser(string $mail): ?User
    {
        if (isset($this->users[$mail])) {
            return ( $this->users[$mail] );
        }

        return null;
    }
}

下面是简单的User类:

// listing 18.17
namespace popp\ch18\batch03;

class User
{
    private string $pass; private ?string $failed;

    public function __construct(private string $name, private string $mail, string $pass)
    {
        if (strlen($pass) <  5) {
            throw new \Exception(
                "Password must have 5 or more letters"
           );
        }

        $this->pass = $pass;
    }

    public function getMail(): string
    {
        return $this->mail;
    }

    public function getPass(): string
    {
        return $this->pass;
    }

    public function failed(string $time): void
    {
        $this->failed = $time;
    }
}

当然,我修改了UserStoreTest类来解释这些变化。请考虑以下设计用于数组的代码:

// listing 18.18
public function testGetUser()
{
    $this->store->addUser("bob williams", "a@b.com", "12345");
    $user = $this->store->getUser("a@b.com");
    $this->assertEquals($user['mail'], "a@b.com");
    $this->assertEquals($user['name'], "bob williams");
    $this->assertEquals($user['pass'], "12345");
}

现在,它被转换成用于对象的代码,如下所示:

// listing 18.19
public function testGetUser(): void
{
    $this->store->addUser("bob williams", "a@b.com", "12345");
    $user = $this->store->getUser("a@b.com");
    $this->assertEquals($user->getMail(), "a@b.com");
}

然而,当我开始运行我的测试套件时,我得到了一个警告,我的工作还没有完成:

$ phpunit src/ch18/batch03/

PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

....F                            5 / 5 (100%)

Time: 00:00.019, Memory: 6.00 MB

There was 1 failure:

1) popp\ch18\batch03\ValidatorTest::testValidateCorrectPass Expecting successful validation
Failed asserting that false is true.
/var/popp/src/ch18/batch03/ValidatorTest.php:26

FAILURES!
Tests: 5, Assertions: 5, Failures: 1.

虽然我的与User相关的测试通过了,但是我的ValidatorTest类发现了一个事实,即我没有更新Validator来解释新的返回值。下面是失败的测试:

// listing 18.20
public function testValidateCorrectPass(): void
{
    $this->assertTrue(
        $this->validator->validateUser("bob@example.com", "12345"),
        "Expecting successful validation"
    );
}

下面是让我失望的Validator::validateUser()方法:

// listing 18.21
public function validateUser($mail, $pass): bool
{
    if (! is_array($user = $this->store->getUser($mail))) {
        return false;
    }

    if ($user['pass'] == $pass) {
        return  true;
    }

    $this->store->notifyPasswordFailure($mail);

    return false;
}

所以User::getUser()现在返回一个对象而不是一个数组。getUser()成功时返回包含用户数据的数组,失败时返回null。我通过使用is_array()函数检查数组来验证用户。当然,现在这个条件永远不会满足,并且validateUser()方法将总是返回false。如果没有测试框架,Validator会简单地拒绝所有无效用户,而不会大惊小怪或发出警告。

使validateUser()方法一致是一个相对快速的解决方法。

// listing 18.22
public function validateUser($mail, $pass): bool
{
    $user = $this->store->getUser($mail);
    if (is_null($user))  {
        return false;
    }
    $testpass = $user->getPass();
    if ($testpass == $pass) {
        return true;
    }

    $this->store->notifyPasswordFailure($mail);
    return false;
}

现在,想象一下在一个没有测试框架的周五晚上对UserStore::getUser()做了一点小小的修改。想想那些会把你从酒吧、扶手椅或餐馆拽出来的疯狂短信:“你做了什么?我们所有的顾客都被关在外面了!”

最阴险的错误不会导致解释器报告有问题。它们隐藏在完全合法的代码中,悄悄地破坏你系统的逻辑。许多错误不会在你工作的地方显现出来;它们是在那里造成的,但几天甚至几周后,影响会在其他地方出现。一个测试框架至少可以帮助您发现其中的一些问题,防止而不是发现系统中的问题。

编写代码时编写测试,并经常运行它们。如果有人报告了一个 bug,首先在你的框架中添加一个测试来确认它。接下来,修复 bug 以便通过测试。虫子有一个有趣的习惯,就是在同一个地方重复出现。编写测试来证明错误,然后防止后续问题,这被称为回归测试。顺便说一句,如果你有一个单独的回归测试目录,记得用描述性的方式命名你的文件。在一个项目中,我们的团队决定用 Bugzilla 票号来命名我们的回归测试。我们最终得到了一个包含 400 个测试文件的目录,每个文件都有一个类似于test_973892.php的名字。寻找一个单独的测试变成了一件乏味的苦差事!

编写 Web 测试

您应该以这样一种方式来设计您的 web 系统,使得它们可以很容易地从命令行或 API 调用中被调用。在第十二章中,你看到了一些可能对你有所帮助的技巧。特别是,如果创建一个Request类来封装 HTTP 请求,那么从命令行或方法参数列表中填充实例就像从请求参数中填充一样容易。然后,系统可以在不知道其上下文的情况下运行。

如果您发现一个系统很难在不同的环境中运行,这可能表明存在设计问题。例如,如果您有许多硬编码到组件中的文件路径,那么您很可能会遇到紧耦合问题。您应该考虑将把您的组件与其上下文联系起来的元素移动到封装对象中,这些对象可以从中央存储库中获得。在第十二章中也提到了注册表模式,它可能会帮助你解决这个问题。

一旦您的系统可以通过方法调用直接运行,您会发现无需任何额外的工具就可以相对容易地编写高级 web 测试。

然而,您可能会发现,即使是考虑最周全的项目也需要一些重构来为测试做好准备。根据我的经验,这几乎总能带来设计上的改进。我将通过改装第十二章和第十三章中 Woo 例子的一个方面来演示这一点。

重构 Web 应用进行测试

从测试人员的角度来看,我们实际上把 Woo 的例子留在了一个合理的状态。因为系统使用单个前端控制器,所以有一个简单的 API 接口。下面是我命名为Runner.php的文件中的一个简单脚本:

// listing 18.23
require_once("vendor/autoload.php");

use popp\ch18\batch04\woo\controller\Controller;

Controller::run();

这很容易添加到单元测试中,对吗?但是命令行参数呢?在某种程度上,这已经在Request类中进行了处理:

// listing 18.24
public function init()
{
    if (isset($_SERVER['REQUEST_METHOD'])) {
        if ($_SERVER['REQUEST_METHOD']) {
            $this->properties = $_REQUEST;
                return;
        }
    }

    foreach ($_SERVER['argv'] as $arg) {
        if (strpos($arg, '=')) {
            list($key, $val) = explode("=", $arg);
                 $this->setProperty($key,  $val);
        }
    }
}

Note

只是提醒一下,如果你实现了自己的Request类,你应该分别捕获和存储GETPOST,甚至PUT属性,而不是将它们转储到一个单独的$request属性中。

init()方法检测流程是否在服务器上下文中运行,并相应地填充$properties数组(直接或通过setProperty())。这对于命令行调用来说很好。例如,这意味着我可以运行这样的程序:

$ php src/ch18/batch04/Runner.php cmd=AddVenue venue_name=bob

前面一行生成了以下响应:

<html>
<head>
<title>Add a Space for venue bob</title>
</head>
<body>
<h1>Add a Space for Venue 'bob'</h1>

<table>
<tr>
<td>
'bob' added (22)</td></tr><tr><td>please add name for the space</td>
</tr>
</table> [add space]
<form method="post">
    <input type="text" value="" name="space_name"/>
    <input type="hidden" name="cmd" value="AddSpace" />
    <input type="hidden" name="venue_id" value="22" />
    <input type="submit" value="submit" />
</form>

</body>
</html>

尽管这适用于命令行,但是通过方法调用传递参数仍然有点棘手。一个不恰当的解决方案是在调用控制器的run()方法之前手动设置$argv数组。不过,我不太喜欢这样。直接使用魔法数组感觉是完全错误的,而且每一端涉及的字符串操作会加重罪过。然而,更仔细地观察Controller类,会发现一个可以帮助我们的设计决策:

// listing 18.25

// Controller

public function handleRequest()
{
    $request = ApplicationRegistry::getRequest();
    $app_c = ApplicationRegistry::appController();

    while ($cmd = $app_c->getCommand($request)) {
        $cmd->execute($request);
    }

    $this->invokeView($app_c->getView($request));
}

这个方法被设计成由静态的run()方法调用。注意Request对象是如何不被直接实例化的。相反,我是从ApplicationRegistry那里获得的。当注册表保存一个对象的单个实例时,比如Request,我可以获取对它的引用,并在通过调用控制器启动系统运行之前,从我的测试中加载数据。这样,我可以模拟一个 web 请求。因为我的系统使用一个Request对象作为 web 请求的唯一接口,所以它与数据源是分离的。只要Request是正常的,系统就不会关心它的数据最终是来自测试还是来自网络服务器。作为一般原则,在可能的情况下,我更喜欢将实例化推回注册中心。

如果我的所有对象都是由一个单独的ApplicationRegistry创建的,那么我可以重载静态注册表工厂方法(ApplicationRegistry::instance),并且完全控制我的应用在测试期间使用的所有数据。如果设置了标志,这种方法将返回一个用假组件填充的模拟注册表,从而创建一个完全模拟的环境。我喜欢愚弄我的系统。

然而,在这里,我将通过用测试数据预加载我的Request对象来演示第一个更保守的技巧。

简单的 Web 测试

这里有一个测试用例,它在 Woo 系统上执行一个非常基本的测试:

// listing 18.26
namespace popp\ch18\batch04;

use popp\ch18\batch04\woo\base\ApplicationRegistry;
use popp\ch18\batch04\woo\controller\ApplicationHelper;
use PHPUnit\Framework\TestCase;

class AddVenueTest extends TestCase
{
    public function testAddVenueVanilla(): void
    {
        $this->runCommand("AddVenue",  ["venue_name"  =>  "bob"]);
    }

    private function runCommand($command = null, array $args = null): void
    {
        $reg = ApplicationRegistry::instance();
        $applicationHelper = ApplicationHelper::instance();
        $applicationHelper->init();
        $request = ApplicationRegistry::getRequest();

        if (! is_null($args)) {
            foreach ($args as $key => $val) {
                $request->setProperty($key, $val);
            }
        }

        if (! is_null($command)) {
            $request->setProperty('cmd', $command);
        }

        woo\controller\Controller::run();
    }
}

事实上,与其说它测试了什么,不如说它证明了系统可以被调用。真正的工作是在runCommand()方法中完成的。这里没有什么特别聪明的地方。我从ApplicationRegistry中获得一个Request对象,并用方法调用中提供的键和值填充它。因为Controller将对它的Request对象使用相同的源,我知道它将使用我设置的值。

运行该测试确认一切正常。我看到了我期望的输出。问题是这个输出是由视图打印的,所以很难测试。我可以通过缓冲输出很容易地解决这个问题:

// listing 18.27
namespace popp\ch18\batch04;

use popp\ch18\batch04\woo\base\ApplicationRegistry;
use popp\ch18\batch04\woo\controller\ApplicationHelper;
use PHPUnit\Framework\TestCase;

class AddVenueTest2 extends TestCase
{

    public function testAddVenueVanilla(): void
    {
        $output = $this->runCommand("AddVenue", ["venue_name" => "bob"]);
        self::AssertMatchesRegularExpression("/added/", $output);
    }

    private function runCommand($command = null, array $args = null): string
    {
        $applicationHelper = ApplicationHelper::instance();
        $applicationHelper->init(); ob_start();
        $request = ApplicationRegistry::getRequest();

        if (! is_null($args)) {
            foreach ($args as $key => $val) {
                $request->setProperty($key, $val);
            }
        }

        if (! is_null($command)) {
            $request->setProperty('cmd', $command);
        }

        woo\controller\Controller::run();
        $ret = ob_get_contents();
        ob_end_clean();

        return $ret;
    }
}

通过在缓冲区中捕获系统的输出,我能够从runCommand()方法返回它。接下来,我对返回值应用一个简单的断言来进行检查。当然,这种方法存在多个问题。

以下是来自命令行的视图:

$ phpunit src/ch18/batch04/AddVenueTest2.php

PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

.                                1 / 1 (100%)

Time: 00:00.029, Memory: 6.00 MB

OK (1 test, 1 assertion)

如果您打算以这种方式在一个系统上运行大量测试,那么创建一个 web UI 超类来保存runCommand()是有意义的。

我在这里掩饰了一些你将在自己的测试中面临的细节。您需要确保系统能够与可配置的存储位置一起工作。您不希望您的测试进入您用于开发环境的同一个数据存储。这是设计改进的另一个机会。寻找硬编码的文件路径和 DSN 值,并将它们推回到注册表中。接下来,确保您的测试在沙箱中工作,但是在您的测试用例的setUp()方法中设置这些值。最后,考虑交换一个MockRequestRegistry,你可以用存根、模拟和各种其他偷偷摸摸的假货来充电。

像这样的方法非常适合测试 web 应用的输入和输出。然而,有一些明显的限制。这种方法无法捕捉浏览器体验。在 web 应用使用 JavaScript 和其他客户端技巧的情况下,测试系统生成的文本不会告诉您用户看到的界面是否正常。

幸运的是,有一个解决方案。

介绍硒

Selenium ( www.selenium.dev/ )由一组自动化 web 测试的命令组成。它还提供了用于创作和运行浏览器测试的工具和 API。

在这个简短的介绍中,我将为我在第十二章中创建的 Woo 系统创建一个快速测试。该测试将通过一个名为 php-webdriver 的 API 与 Selenium 服务器协同工作。

获取硒

您可以在 www.selenium.dev/downloads/ 下载 Selenium 组件。出于本例的目的,您将需要 Selenium 独立服务器。

一旦你下载了这个包,你应该会找到一个名为selenium-server-standalone-3.141.59.jar的文件(当然,你的版本号可能会不同)。把这个文件复制到中心的某个地方。要继续,您需要在系统上安装 Java。一旦确认了这一点,就可以启动 Selenium 服务器了。

在这里,我将服务器复制到/usr/local/lib目录。然后我启动服务器:

$ sudo cp selenium-server-standalone-3.141.59.jar  /usr/local/lib/
$ java -jar /usr/local/lib/selenium-server-standalone-3.141.59.jar

17:58:20.098 INFO [GridLauncherV3.parse] - Selenium server version: 3.141.59, revision: e82be7d358
17:58:20.200 INFO [GridLauncherV3.lambda$buildLaunchers$3] - Launching a standalone
Selenium Server on port 4444
2020-09-13 17:58:20.254:INFO::main: Logging initialized @678ms to org.seleniumhq.jetty9.util.log.StdErrLog
17:58:20.459 INFO [WebDriverServlet.<init>] - Initialising WebDriverServlet
17:58:20.541 INFO [SeleniumServer.boot] - Selenium Server is up and running on port 4444

请注意,启动输出告诉我们应该使用哪个端口来与服务器通信。这个以后会派上用场的。

然而,我们可能只是走了一半。为了减少模糊错误的机会,我发现最好下载正确版本的 ChromeDriver,这是一个向浏览器传递 UI 命令的库。目前,Chrome 似乎是用 Selenium 进行测试的最佳浏览器选择。如果你还没有在你的本地系统上安装 Chrome,那就从安装开始吧。查看Help :: About Google Chrome菜单,确定您的浏览器版本。然后在 https://sites.google.com/a/chromium.org/chromedriver/downloads 下载与你的浏览器版本对应的 ChromeDriver 版本。有了这个库,您可以再次启动 Selenium:

$ java -jar -Dwebdriver.chrome.driver="./chromedriver" /usr/local/lib/selenium-server- standalone-3.141.59.jar

现在我准备好继续了。

PHPUnit 和硒

尽管 PHPUnit 过去已经提供了使用 Selenium 的 API,但是它的支持一直是不完整的,它的文档更是如此。因此,为了尽可能多地访问 Selenium 的特性,将 PHPUnit 与一个旨在提供我们需要的绑定的工具结合使用是有意义的。

php-webdriver 简介

WebDriver 是 Selenium 控制浏览器的机制,它是在 Selenium 2 中引入的。Selenium 开发人员为 WebDriver 提供了 Java、Python 和 C # APIs。有一些可用的 PHP APIs。我选择使用 php-webdriver,它是由脸书的开发者开发的。它正在积极开发中,并反映了官方的 API。当你想查找一项技术时,这是非常方便的,因为你在网上找到的许多例子都是用 Java 提供的,这意味着只要移植一点代码,它们就可以很容易地应用于 php-webdriver。

您可以使用 Composer 将 php-webdriver 添加到项目中:

{
    "require-dev": {
        "phpunit/phpunit": "9.*",
        "php-webdriver/webdriver" : "*"
    }
}

更新您的composer.json文件,运行composer update,您应该准备好了。

创建测试框架

我将使用 Woo 应用的一个实例,它将在我的系统上运行,URL 为: http://popp.vagrant.internal/webwoo/

我将从一个样板测试类开始:

// listing 18.28
namespace popp\ch18\batch04;

use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\WebDriverCapabilityType;
use PHPUnit\Framework\TestCase;

class SeleniumTest1 extends TestCase
{
    protected function setUp(): void
    {
    }

    public function testAddVenue(): void
    {
    }
}

我指定了一些我将使用的 php-webdriver 类,然后创建了一个基本的测试类。现在是让这个测试有所作为的时候了。

连接到 Selenium

记住,在启动时,服务器输出它的连接 URL。为了连接到 Selenium,我需要将这个 URL 和一个功能数组传递给一个名为RemoteWebDriver的类:

// listing 18.29
namespace popp\ch18\batch04;

use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\WebDriverCapabilityType;
use PHPUnit\Framework\TestCase;

class SeleniumTest2 extends TestCase
{
    private $driver;

    protected function setUp(): void
    {
        $options = new ChromeOptions();
        $capabilities = DesiredCapabilities::chrome();
        $capabilities->setCapability(ChromeOptions::CAPABILITY, $options);

        $this->driver = RemoteWebDriver::create(
            "http://127.0.0.1:4444/wd/hub",
            $capabilities
        );

    }

    public function testAddVenue(): void
    {
    }
}

如果您用 Composer 安装了 php-webdriver,您可以在vendor/php-webdriver/webdriver/lib/Remote/WebDriverCapabilityType.php的类文件中看到完整的功能列表。然而,对于我目前的目的,我真的只需要指定浏览器名称。我将主机字符串和$capabilities数组传递给静态的RemoteWebDriver::create()方法,并将结果对象引用存储在$driver属性中。当我运行这个测试时,我应该看到 Selenium 启动了一个新的浏览器窗口,为我的测试做准备。

编写测试

我想测试一个简单的工作流程。我将导航到AddVenue页面,添加一个地点,然后添加一个空间。这涉及到与三个网页的交互。

这是我的测试:

// listing 18.30
namespace  popp\ch18\batch04;

use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\WebDriverCapabilityType;
use Facebook\WebDriver\WebDriverBy;
use PHPUnit\Framework\TestCase;

class SeleniumTest3 extends TestCase
{

    protected function setUp(): void
    {
        $options = new ChromeOptions();

        // uncomment this line to run in 'headless' mode
        // $options->addArguments(['--headless', '--no-sandbox']);

        $capabilities = DesiredCapabilities::chrome();
        $capabilities->setCapability(ChromeOptions::CAPABILITY, $options);

        $this->driver = RemoteWebDriver::create(
            "http://127.0.0.1:4444/wd/hub",
            $capabilities
        );
    }

    public function testAddVenue(): void
    {
        $this->driver->get("http://popp.vagrant.internal/webwoo/AddVenue.php");
        $venel = $this->driver->findElement(WebDriverBy::name("venue_name"));
        $venel->sendKeys("my_test_venue");

        $venel->submit();

        $tdel = $this->driver->findElement(WebDriverBy::xpath("//td[1]"));
        $this->assertMatchesRegularExpression("/'my_test_venue' added/", $tdel->getText());

        $spacel = $this->driver->findElement(WebDriverBy::name("space_name"));
        $spacel->sendKeys("my_test_space");
        $spacel->submit();

        $el = $this->driver->findElement(WebDriverBy::xpath("//td[1]"));
        $this->assertMatchesRegularExpression("/'my_test_space' added/", $el->getText());
    }
}

下面是我在命令行上运行这个测试时发生的情况:

$ phpunitsrc/ch18/batch04/SeleniumTest3.php

PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

..                               1 / 1 (100%)

Time: 00:00.029, Memory: 6.00 MB
OK (1 test, 2 assertion)

当然,并不全是这样。Selenium 还启动一个浏览器窗口,并在其上执行我指定的操作。不得不承认,我觉得这个效果有点诡异!

让我们浏览一下代码。首先,我调用WebDriver::get(),它获取我的起始页。注意,这个方法需要一个完整的 URL(不需要位于 Selenium 服务器主机的本地)。在这种情况下,我在一个流浪的虚拟机上配置了一个 Apache 服务器来提供一个模拟的AddVenue.php脚本。Selenium 会将指定的文档加载到它已经启动的浏览器中。你可以在图 18-1 中看到这一页。

img/314621_6_En_18_Fig1_HTML.jpg

图 18-1

Selenium 加载的 AddVenue 页面

一旦页面加载完毕,我就可以通过 WebDriver API 访问它了。我可以使用RemoteWebDriver::findElement()方法获取对页面元素的引用。这需要一个类型为WebDriverBy的对象。WebDriverBy类提供了一组工厂方法,每个方法返回一个WebDriverBy对象,该对象被配置为指定定位元素的特定方式。我的表单元素有一个设置为"venue_name"name属性,所以我使用WebDriverBy::name()方法告诉findElement()以这种方式查找元素。表 18-4 列出了所有可用的工厂方法。

表 18-4

WebDriverBy 工厂方法

|

方法

|

描述

| | --- | --- | | className() | 通过 CSS 类名查找元素 | | cssSelector() | 通过 CSS 选择器查找元素 | | id() | 通过 id 查找元素 | | name() | 按名称属性查找元素 | | linkText() | 通过链接文本查找元素 | | partialLinkText() | 通过链接文本片段查找元素 | | tagName() | 按标签查找元素 | | xpath() | 查找匹配 Xpath 表达式的元素 |

一旦我有了对类型为RemoteWebElement的对象venue_name表单元素的引用,我就可以使用它的sendKeys()方法来设置一个值。需要注意的是sendKeys()不仅仅是设置一个值。它还模拟向元素中键入内容的行为。这对于测试使用 JavaScript 捕获键盘事件的系统非常有用。

使用新的值集,我提交表单。API 在这方面很聪明。当我在一个元素上调用submit()时,Selenium 定位包含它的表单并提交它。

当然,提交表单会导致加载一个新页面。因此,接下来我检查一切是否如我所料。我再次使用了WebDriver::findElement(),不过这次我传递给它一个为 Xpath 配置的WebDriverBy对象。如果我的搜索成功,findElement()将返回一个新的RemoteWebElement对象。另一方面,如果我的搜索失败了,那么产生的异常将导致我的测试失败。假设一切正常,我使用RemoteWebElement::getText()方法获取元素的值。

在这个阶段,我已经提交了表单,并检查了返回的 web 页面的状态。可以看到图 18-2 中的页面。

img/314621_6_En_18_Fig2_HTML.jpg

图 18-2

AddSpace 页面

现在剩下的就是再次填充表单,提交并检查新页面。我使用你已经遇到的技术来达到这个目的。

当然,我在这里只是触及了硒的皮毛。但我希望这次讨论足以让你对可能性有所了解。如果你想了解更多,在 www.selenium.dev/documentation/en/ 有完整的硒手册。

一个警告

人们很容易被自动化测试所带来的好处冲昏头脑。我将单元测试添加到我的项目中,并使用 PHPUnit 进行功能测试。也就是说,我在系统级和类级进行测试。我看到了实实在在的好处,但我相信这是有代价的。

测试给你的开发增加了很多成本。例如,当您将安全性构建到项目中时,您也在构建过程中添加了时间惩罚,这会影响发布。编写测试所需的时间是其中的一部分,但是运行测试所需的时间也是其中的一部分。在一个系统上,我们可能有几套针对多个数据库和多个版本控制系统运行的功能测试。再添加几个类似的上下文变量,我们就会面临运行测试套件的真正障碍。当然,没有运行的测试是没有用的。对此的一个答案是完全自动化您的测试,因此运行由类似于cron的调度应用启动。另一个方法是维护一个测试子集,开发人员可以在提交代码时轻松运行这个子集。这些应该和你更长更慢的测试一起进行。

另一个需要考虑的问题是许多测试工具的脆弱性。您的测试可能会让您有信心进行更改,但是随着您的测试覆盖范围随着您的系统的复杂性而增加,打破多个测试变得更加容易。当然,这往往是你想要的。您想知道预期行为何时不会发生,或者意外行为何时会发生。

然而,测试工具经常会因为相对微小的变化而中断,例如反馈字符串的措辞。每一个中断的测试都是一件紧急的事情,但是不得不改变 30 个测试用例来解决架构或输出中的一个小变更是令人沮丧的。单元测试不太容易出现这种问题,因为总的来说,它们独立地关注每个组件。

保持测试与发展中的系统同步所涉及的成本是您必须考虑的一个权衡。总的来说,我相信收益是值得付出的。

您还可以做一些事情来减少测试工具的脆弱性。在某种程度上,编写测试时考虑到变化是一个好主意。例如,我倾向于使用正则表达式来测试输出,而不是直接的等式测试。当我从输出字符串中删除换行符时,测试几个关键字不太可能使我的测试失败。当然,让你的测试过于宽容也是一种危险,所以这是一个运用你的判断力的问题。

另一个问题是,除了您希望测试的组件之外,您应该在多大程度上使用 mocks 和 stubs 来伪造系统。有些人坚持认为你应该尽可能地隔离你的组件,并模仿它周围的一切。这在一些项目中对我有效。然而,在其他情况下,我发现维护一个模拟系统会成为一个时间陷阱。您不仅需要花费成本来保持您的测试与您的系统保持一致,而且您必须保持您的模拟是最新的。想象一下改变一个方法的返回类型。如果您未能更新相应存根对象的方法以返回新的类型,客户端测试可能会错误地通过。对于一个复杂的伪系统来说,存在着 bug 潜入模仿对象的真实危险。调试测试是令人沮丧的工作,尤其是当系统本身没有问题的时候。

我倾向于见机行事。默认情况下,我使用 mocks 和 stubs,但是如果成本开始增加,我不会对转移到真正的组件感到抱歉。您可能会失去对测试主题的一些关注,但是这带来了额外的好处,即源自组件上下文的错误至少是系统的真实问题。当然,你可以结合使用真实和虚假的元素。例如,我经常在测试模式下使用内存数据库。

正如您可能已经收集到的,当涉及到测试时,我不是一个理论家。我经常通过组合真实的和模拟的组件来“欺骗”;因为启动数据是重复的,我经常将测试设备集中到马丁·福勒称之为对象母亲的地方。这些类是简单的工厂,生成用于测试的对象。一些人讨厌这种共享设备。

在指出了测试可能迫使你面对的一些问题之后,有必要重申几点,在我看来,这胜过了所有的反对意见。测试完成几件事:

  • 它有助于防止错误(在开发和重构过程中发现错误的程度)。

  • 它帮助您发现 bug(当您扩展测试覆盖时)。

  • 它鼓励你专注于系统的设计。

  • 它让您可以改进代码设计,而不必担心更改会导致比它们解决的问题更多的问题。

  • 当你发布代码时,它给你信心。

在我编写测试的每个项目中,我迟早会有机会感激这个事实。

摘要

在这一章中,我回顾了我们作为开发人员编写的,但是经常被不加思考地抛弃的测试。从那以后,我介绍了 PHPUnit,它允许您在开发过程中编写相同类型的一次性测试,但之后保留它们并感受持久的好处!我创建了一个测试用例实现,并且介绍了可用的断言方法。我还研究了约束并探索了模拟对象的复杂世界。接下来,我展示了测试重构如何改进设计,演示了一些测试 web 应用的技术——首先使用 PHPUnit,然后使用 Selenium。最后,我冒着激怒某些人的风险,警告了测试所带来的成本,并讨论了相关的权衡。

十九、将 Phing 用于自动构建

如果版本控制是硬币的一面,那么自动化构建就是另一面。版本控制允许多个开发人员在单个项目上协同工作。随着许多编码人员各自在自己的空间部署一个项目,自动化构建很快变得必不可少。一个开发人员可能在/usr/local/apache/htdocs中有她的面向 web 的目录;另一个可能使用/home/bibble/public_html。开发人员可能使用不同的数据库密码、库目录或邮件机制。一个灵活的代码库可能很容易适应所有这些差异,但是更改设置和手动复制文件系统中的目录以使工作正常进行的工作很快就会变得令人厌倦——特别是如果您需要一天几次(或者一小时几次)安装正在进行的代码。

您已经看到 Composer 自动化了包的安装。您几乎肯定希望通过 Composer 或 PEAR 包将项目交付给最终用户,因为这种机制对用户来说很简单,而且包管理系统可以处理依赖性。但是在创建一个包之前,有许多工作可能需要自动化。例如,您可能需要生成模板生成的代码。您应该运行测试并提供创建和更新数据库表的机制。最后,您可能希望自动化生产就绪包的创建。在这一章中,我将向您介绍 Phing,它处理的就是这样的工作。本章将涵盖以下内容:

  • 获取并安装 Phing :谁构建构建器?

  • 属性:设置和获取数据

  • 类型:描述项目的复杂部分

  • 目标:将一个构建分解成可调用的、相互依赖的>功能集

  • 任务:完成工作的事情

什么是 Phing?

Phing 是一个用于构建项目的 PHP 工具。它非常接近于非常流行(也非常强大)的 Java 工具 Ant 的模型。蚂蚁之所以如此命名,是因为它很小,但却能够建造非常大的东西。Phing 和 Ant 都使用一个 XML 文件(通常命名为build.xml)来决定如何安装或处理项目。

PHP 世界真的需要一个好的构建解决方案。认真的开发者在过去有许多选择。首先,可以使用make,这是一个无处不在的 Unix 构建工具,仍然用于大多数 C 和 Perl 项目。然而,make对语法非常挑剔,需要相当多的 shell 知识,甚至包括脚本——这对于一些没有通过 Unix 或 Linux 命令行编程的 PHP 程序员来说是一个挑战。更重要的是,make只提供了很少的内置工具用于常见的构建操作,比如转换文件名和内容。它实际上只是外壳命令的粘合剂。这使得编写跨平台安装的程序变得困难。不是所有的环境都有相同版本的make或者根本没有。即使你有make,你也可能没有 makefile(驱动make的配置文件)需要的所有命令。

PHing 和make的关系从它的名字就可以说明:Phing 代表 Phing 不是 GNU make。这种有趣的递归是一个常见的编码器笑话(例如,GNU 本身代表 GNU 不是 Unix)。

Phing 是一个本地 PHP 应用,它解释用户创建的 XML 文件,以便对项目执行操作。这种操作通常包括将文件从一个分发目录复制到不同的目标目录,但是还有更多的内容要做。Phing 可用于生成文档、运行测试、调用命令、运行任意 PHP 代码、创建包、替换文件中的关键字、去除注释以及生成 tar/gzipped 包版本。即使 Phing 还不能满足您的需求,但它被设计成易于扩展。

因为 Phing 本身就是一个 PHP 应用,所以运行它只需要一个最新的 PHP 引擎。由于 Phing 是一个用于安装 PHP 应用的应用,所以 PHP 可执行文件的存在是一个相当安全的赌注。

获取和安装 Phing

如果安装一个安装工具很困难,那么一定是哪里出了问题!然而,假设您的系统上有 PHP 5 或更好的版本(如果您没有,这本书不适合您!),安装 Phing 再简单不过了。

您可以通过 Composer 获得并安装 Phing。您应该将此添加到您的composer.json文件中:

{
    "require-dev":  {
        "phing/phing": "2.*"
    }
}

Note

当我写这篇文章的时候,Phing 版本 3 还在 alpha 中。本章(以及第二十一章中)的所有例子都可以很好地使用它,但是安装需要一些非正统的黑客技术。希望当你读到这里的时候,这些小淘气已经被解决了。在 Phing 主页查看安装说明: www.phing.info/#install

撰写构建文档

您现在应该准备好开始使用 Phing 了!让我们测试一下:

$ phing vendor/bin/phing -v

Phing 2.16.3

phing命令的-v标志使脚本返回版本信息。当您读到本文时,版本号可能已经更改,但是当您在系统上运行该命令时,应该会看到类似的消息。

Note

如果您使用 Composer 安装了 Phing,可运行的脚本文件将安装在您的本地vendor/bin/目录中。要运行 Phing,您应该将该目录添加到您的$PATH环境变量中,或者使用可执行文件的显式路径。在以后的例子中,我将省略路径。

现在我将不带参数运行phing命令:

$ phing

Buildfile: build.xml does not exist!

如你所见,Phing 在没有指令的情况下丢失了。默认情况下,它会寻找一个名为build.xml的文件。让我们构建一个最小化文档,这样至少可以消除错误消息:

// listing 19.01
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz" default="main" description="my project" basedir="/tmp">
    <target name="main"/>
</project>

这是您在构建文件中可以做到的最低限度。如果我们将前面的例子保存为build.xml并再次运行phing,我们应该会得到一些更有趣的输出:

$ phing

Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'main' has no tasks or dependencies

megaquiz > main:

BUILD FINISHED

Total time: 0.0976 seconds

你可能会想,付出很多努力却一无所获,但我们必须从某个地方开始!如您所见,Phing 还指出了这个构建文件没有什么非常有用的地方。再次查看构建文件。因为我们处理的是 XML,所以我包含了一个 XML 声明。您可能知道,XML 注释看起来像这样:

<!-- this is an XML comment. OK? -->

因此,因为它是一个注释,所以我的构建文件中的第二行被忽略。您可以在构建文件中放入任意多的注释,随着注释的增加,您应该充分利用这一事实。如果没有合适的注释,大型构建文件可能很难理解。

任何构建文件的真正开始都是project元素。project元素最多可以包含五个属性。其中,namedefault是必选的。name属性建立了项目的名称;default定义一个在命令行上没有指定的情况下运行的目标。可选的description属性可以提供汇总信息。您可以使用一个basedir属性来指定构建的上下文目录。如果省略,将采用当前工作目录。最后,您可以使用phingVersion指定构建文件应该使用的 Phing 应用的最低版本。您可以在表 19-1 中看到这些属性的汇总。

表 19-1

项目元素的属性

|

属性

|

需要

|

描述

|

缺省值

| | --- | --- | --- | --- | | Name | 是 | 项目的名称 | 没有人 | | Description | 不 | 简要的项目总结 | 没有人 | | Default | 是 | 要运行的默认目标 | 没有人 | | phingVersion | 不 | 要运行的 Phing 的最低版本 | 没有人 | | Basedir | 不 | 将在其中运行生成的文件系统上下文 | 当前目录(。) | | Strict | 不 | 在严格模式下运行:将警告视为错误一旦我定义了一个project元素,我必须创建至少一个目标——我在default属性中引用的目标 | false |

目标

在某种意义上,目标类似于函数。目标是为了实现一个目标而组合在一起的一组动作:例如,将一个目录从一个地方复制到另一个地方,或者生成文档。

在我之前的例子中,我包括了一个目标的最简单的实现:

// listing 19.02
<target name="main"/>

如您所见,目标必须至少定义一个name属性。我在project元素中利用了这一点。因为默认元素指向main目标,所以只要 Phing 在没有命令行参数的情况下运行,就会调用这个目标。输出证实了这一点:

megaquiz > main:

目标可以被组织成相互依赖。通过在一个目标和另一个目标之间建立依赖关系,您告诉 Phing,在它所依赖的目标运行之前,第一个目标不应该运行。现在,我将向我的构建文件添加一个依赖项:

// listing 19.03
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main">

    <target name="runfirst" />
    <target name="runsecond" depends="runfirst"/>
    <target name="main" depends="runsecond"/>
</project>

如您所见,我为目标元素引入了一个新的属性。depends告诉 Phing 被引用的目标应该在当前目标之前执行,所以我可能希望将某些文件复制到某个目录的目标在对该目录中的所有文件运行转换的目标之前被调用。我在示例中添加了两个新目标:main依赖的runsecondrunsecond依赖的runfirst。下面是我用这个构建文件运行 Phing 时发生的情况:

$ phing

Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'runfirst' has no tasks or dependencies

megaquiz > runfirst:

megaquiz > runsecond:

megaquiz > main:

BUILD FINISHED

Total time: 0.1250 seconds

如您所见,依赖关系是受尊重的。Phing 遇到了main目标,看到了它的依赖关系,并返回到runsecondrunsecond自有依赖,Phing 调用runfirst

满足其依赖性后,Phing 可以调用runsecond。最后,main被调用。depends属性可以一次引用多个目标。可以提供一个以逗号分隔的依赖项列表,每个依赖项将依次得到尊重。

现在我有多个目标可以使用,我可以从命令行覆盖项目元素的default属性:

$ phing runsecond

Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'runfirst' has no tasks or dependencies

megaquiz > runfirst:

megaquiz > runsecond:

BUILD FINISHED

Total time: 0.1043 seconds

通过传入一个目标名称,我导致默认属性被忽略。匹配我的参数的target被调用(以及它所依赖的target)。这对于调用专门的任务很有用,例如清理构建目录或运行安装后脚本。

target元素还支持一个可选的description属性,您可以为其分配目标用途的简要描述:

// listing 19.04
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main"
         description="A quiz engine">
    <target name="runfirst"
            description="The first target" />
    <target name="runsecond"
            depends="runfirst, housekeeping"
            description="The second target" />
    <target name="main"
            depends="runsecond"
            description="The main target" />
</project>

向目标添加描述对正常的构建过程没有任何影响。但是,如果用户使用- projecthelp标志运行 Phing,描述将用于总结项目:

$ phing -projecthelp

Buildfile: /var/popp/src/ch19/build.xml
Warning: target 'runfirst' has no tasks or dependencies
A quiz engine
Default target:
-------------------------------------------------------------------
Main        The main target

Main targets:
-------------------------------------------------------------------
Main        The main target
Runfirst    The first target
Runsecond   The second target

注意,我也向project元素添加了description属性。如果您想从这样的清单中隐藏一个目标,您可以添加一个隐藏属性。这对于提供内务处理功能的目标很有用,但这些功能不应直接从命令行调用:

// listing 19.05
<target name="housekeeping" hidden="true">
    <!-- useful things that should not be called directly -->
</target>

性能

Phing 允许您使用property元素来设置这些值。

属性类似于脚本中的全局变量。因此,它们通常被声明在项目的顶部,以便开发人员能够很容易地确定构建文件中的内容。在这里,我创建了一个处理数据库信息的构建文件:

// listing 19.06
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main">

    <property name="dbname" value="megaquiz" />
    <property name="dbpass" value="default" />
    <property name="dbhost" value="localhost" />

    <target name="main">
        <echo>database: ${dbname}</echo>
        <echo>pass:     ${dbpass}</echo>
        <echo>host:     ${dbhost}</echo>
    </target>
</project>

我引入了一个新元素:propertyproperty需要namevalue属性。我已经在目标main中添加了三个property的实例。

我还引入了echo元素。这是一个任务的例子。我将在下一节更全面地探讨任务。不过现在,只要知道echo做了您所期望的事情就足够了——它导致其内容被输出。注意这里引用属性值的语法。通过使用美元符号,并用花括号将属性名括起来,告诉 Phing 用属性值替换字符串:

${propertyname}

这个构建文件实现的只是声明三个属性,并将它们打印到标准输出中。这就是它的作用:

$ phing

Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:

     [echo] database: megaquiz
     [echo] pass:     default
     [echo] host:     localhost

BUILD FINISHED

Total time: 0.0989 seconds

既然已经介绍了属性,我可以结束对目标的探索了。元素接受两个额外的属性:ifunless。每一个都应该用属性的名称来设置。当您使用带有属性名的if时,只有设置了给定的属性,才会执行目标。如果未设置属性,目标将静默退出。这里,我注释掉了dbpass属性,并使用if属性使main任务需要它:

// listing 19.07
<project name="megaquiz"
         default="main">

    <property name="dbname" value="megaquiz" />
    <!--<property name="dbpass" value="default" />-->
    <property name="dbhost" value="localhost" />

   <target name="main" if="dbpass">
       <echo>database: ${dbname}</echo>
       <echo>pass:     ${dbpass}</echo>
       <echo>host:     ${dbhost}</echo>
    </target>
</project>

让我们再次运行phing:

$ phing

Buildfile: /var/popp/src/ch19/build.xml
megaquiz > main:

BUILD FINISHED

Total time: 0.0957 seconds

如您所见,我没有提出错误,但是main任务没有运行。为什么我会想这么做?还有另一种在项目中设置属性的方法。它们可以在命令行上指定。您告诉 Phing,您要传递给它一个带有-D标志的属性,后跟一个属性赋值。所以论点应该是这样的:

-Dname=value

在我的例子中,我希望通过命令行使用dbname属性:

$ phing -Ddbpass=userset

Buildfile: /var/popp/src/ch19/build.xml

megaquiz > main:
     [echo] database: megaquiz
     [echo] pass:     userset
     [echo] host:     localhost

BUILD FINISHED

Total time: 0.0978 seconds

主目标的if属性满足dbpass属性的存在,目标被允许执行。

如您所料,unless属性与if相反。如果设置了一个属性,并且在目标的unless属性中引用了该属性,那么目标将不会运行。如果您希望能够从命令行抑制特定的目标,这将非常有用。因此,我可能会在主目标中添加类似这样的内容:

// listing 19.08
<target name="main" unless="suppressmain">
    <echo>database: ${dbname}</echo>
    <echo>pass:     ${dbpass}</echo>
    <echo>host:     ${dbhost}</echo>
</target>

除非suppressmain属性存在,否则main将被执行:

$ phing -Dsuppressmain

我已经包装了target元素;表 19-2 显示了其属性的汇总。

表 19-2

目标元素的属性

|

属性

|

需要

|

描述

| | --- | --- | --- | | name | 是 | 目标的名称 | | depends | 不 | 当前所依赖的目标 | | if | 不 | 仅当给定属性存在时执行目标 | | unless | 不 | 仅当给定属性不存在时执行目标 | | logskipped | 不 | 如果目标被跳过(例如,由于if / unless),则在输出中添加一个通知 | | hidden | 不 | 从列表和摘要中隐藏目标 | | description | 不 | 目标目的的简短摘要 |

当在命令行上设置属性时,它会重写生成文件中的任何和所有属性声明。还有一种情况是属性值会被覆盖。默认情况下,如果一个属性被声明了两次,那么原始值将优先。您可以通过在第二个property元素中设置一个名为override的属性来改变这种行为。这里有一个例子:

// listing 19.09
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main">

    <property name="dbpass" value="default" />

    <target name="main">
        <property name="dbpass" override="yes" value="specific" />
        <echo>pass: ${dbpass}</echo>
    </target>

</project>

我设置了一个名为dbpass的属性,赋予它初始值"default"。在主目标中,我再次设置属性,添加一个设置为"yes"override属性,并提供一个新值。新值反映在输出中:

$ phing

Buildfile: /var/popp/src/ch19/build.xml

megaquiz > main:
     [echo] pass: specific

BUILDFINISHED

Total time: 0.0978 seconds

如果我没有在第二个属性元素中设置override元素,那么"default"的原始值将保持不变。需要注意的是,目标不是函数:没有局部范围的概念。如果在任务中重写某个属性,该属性将在整个生成文件中对所有其他任务保持重写状态。当然,您可以通过在重写之前将属性值存储在临时属性中,然后在完成本地工作后重置它来解决这个问题。

到目前为止,我已经处理了您自己定义的属性。Phing 还提供了内置属性。您引用这些属性的方式与您引用自己声明的属性的方式完全相同。这里有一个例子:

// listing 19.10
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main">

    <target name="main">
        <echo>name: ${phing.project.name}</echo>
        <echo>base: ${project.basedir}</echo>
        <echo>home: ${user.home}</echo>
        <echo>pass: ${env.DBPASS}</echo>
    </target>

</project>

我只引用了几个内置的 Phing 属性。phing.project.name解析为在project元素的name属性中定义的项目名称;project.basedir给出起始目录;并且user.home提供执行用户的主目录(这对于提供默认安装位置很有用)。

最后,属性引用中的env前缀表示操作系统环境变量。所以通过指定$,我在寻找一个名为DBPASS的环境变量。在这里,我对这个文件运行 Phing:

$ phing

Buildfile: /var/popp/src/ch19/build.xml

megaquiz > main:
     [echo] name: megaquiz
     [echo] base: /var/popp/src/ch19
     [echo] home: /home/vagrant
     [echo] pass: ${env.DBPASS}

BUILD FINISHED

Total time: 0.1056 seconds

请注意,最后一个属性尚未翻译。这是找不到属性时的默认行为,引用该属性的字符串保持不变。如果我设置了DBPASS环境变量并再次运行,我应该会在输出中看到该变量:

$ export DBPASS=wooshpoppow
$ phing

Buildfile: /var/popp/src/ch19/build.xml

megaquiz > main:
     [echo] name: megaquiz
     [echo] base: /var/popp/src/ch19
     [echo] home: /home/vagrant
     [echo] pass: wooshpoppow

BUILD FINISHED

Total time: 0.1044 seconds

现在您已经看到了设置属性的三种方式:property元素、命令行参数和环境变量。

有第四种方法可以补充这些方法。您可以使用单独的文件来指定属性值。随着我的项目越来越复杂,我倾向于这种方法。让我们回到一个基本的构建文件:

// listing 19.11
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main">

    <target name="main">
        <echo>database: ${dbname}</echo>
        <echo>pass:     ${dbpass}</echo>
        <echo>host:     ${dbhost}</echo>
    </target>
</project>

正如您所看到的,这个构建文件只是输出属性,而没有首先声明它们或者检查它们的值是否存在。这是我不带参数运行这个程序时得到的结果:

$ phing

...

     [echo] database: ${dbname}
     [echo] pass:     ${dbpass}
     [echo] host:     ${dbhost}

...

现在我将在一个单独的文件中声明我的属性。我把它叫做megaquiz.properties:

dbname=filedb
dbpass=filepass
dbhost=filehost

现在我可以用 Phing 的propertyfile选项将这个文件应用到我的构建过程中:

$ phing -propertyfile megaquiz.properties

...

     [echo] database: filedb
     [echo] pass:     filepass
     [echo] host:     filehost

...

我发现这种机制比管理一长串命令行选项要方便得多。但是,您需要注意不要将您的属性文件签入到您的版本控制系统中!

您可以使用目标来确保填充属性。比方说,我的项目需要一个dbpass属性。我希望用户在命令行上设置dbpass(这总是优先于其他属性赋值方法)。如果失败,我应该寻找一个环境变量。最后,我应该放弃使用默认值:

// listing 19.12
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main" basedir=".">

    <target name="setenvpass" if="env.DBPASS" unless="dbpass">
        <property name="dbpass" override="yes" value="${env.DBPASS}" />
    </target>

    <target name="setpass" unless="dbpass" depends="setenvpass">
        <property name="dbpass" override="yes" value="default" />
    </target>

    <target name="main" depends="setpass">
        <echo>pass: ${dbpass}</echo>
    </target>

</project>

因此,像往常一样,首先调用默认目标main。这有一个依赖集,所以 Phing 返回到setpass目标。然而setpass依赖于setenvpass,所以我从那里开始。setenvpass配置为仅在dbpass未设置且env.DBPASS存在时运行。如果满足这些条件,那么我使用property元素设置dbpass属性。在这个阶段,dbpass要么由命令行参数填充,要么由环境变量填充。如果这两者都不存在,那么该属性在此阶段将保持未设置状态。现在执行setpass目标,但仅当dbpass尚未出现时。在这种情况下,它将属性设置为默认字符串:"default"

使用条件任务有条件地设置属性值

前面的例子建立了一个相当复杂的赋值逻辑。然而,更常见的情况是,您需要一个简单的默认值。condition任务允许您根据可配置的条件设置属性值。这里有一个例子:

// listing 19.13
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main">
    <condition property="dbpass" value="default">
         <not>
             <isset property="dbpass" />
         </not>
    </condition>

    <target name="main">
        <echo>pass: ${dbpass}</echo>
    </target>

</project>

condition任务需要一个property属性。它还可选地接受一个value属性,如果嵌套的测试子句解析为true,则该属性被分配给该属性。如果没有提供 value 属性,那么如果嵌套测试解析为true,该属性将被设置为true

test 子句是许多标记中的一个,其中一些标记,如本例中的not,接受它们自己的嵌套元素。我使用了isset元素,如果设置了引用的属性,它将返回true。因为我想给属性dbpass赋值,如果它是而不是的话,我需要通过将它包装在not标签中来否定这个结果。这将反转它所包含的标签的分辨率。因此,就 PHP 语法而言,我的示例中的condition任务与此类似:

if (! isset($dbpass)) {
    $dbpass = "default";
}

类型

您可能认为已经查看了属性,现在就可以处理数据了。事实上,Phing 支持一组称为类型的特殊元素。这些封装了对构建过程有用的不同种类的信息。

文件集

假设您需要在构建文件中表示一个目录。正如你所想象的,这是一种常见的情况。当然,您可以使用一个属性来表示这个目录,但是如果您的开发人员使用支持不同目录分隔符的不同平台,您会马上遇到问题。答案是文件集数据类型。文件集是独立于平台的,所以如果您在路径中用正斜杠表示一个目录,当构建在 Windows 机器上运行时,它们将在后台自动转换成反斜杠。您可以像这样定义一个最小的fileset元素:

<fileset dir="src/lib" />

如您所见,我使用了dir属性来设置我希望表示的目录。您可以选择添加一个id属性,这样您就可以在以后引用fileset:

<fileset dir="src/lib" id="srclib">

FileSet数据类型在指定要包含或排除的文档类型时特别有用。安装一组文件时,您可能不希望包含那些符合特定模式的文件。您可以在一个excludes属性中处理这样的条件:

<fileset dir="src/lib" id="srclib"
    excludes="**/*_test.php **/*Test.php" />

注意我在excludes属性中使用的语法。双星号代表src/lib内的任何目录或子目录。单个星号代表零个或多个字符。所以我指定我想排除在所有目录中以_test.phpTest.php结尾的文件,这些目录位于在dir属性中定义的起点之下。excludes属性接受由空格分隔的多种模式。

我可以将相同的语法应用于一个includes属性。也许我的src/lib目录包含许多对开发人员有用的非 PHP 文件,但是它们不应该出现在安装中。当然,我可以排除那些文件,但是定义我可以包括的文件种类可能更简单。在这种情况下,如果文件不是以。php,它不会被安装:

<fileset dir="src/lib" id="srclib"
    excludes="**/*_test.php **/*Test.php"
    includes="**/*.php" />

随着你建立起includeexclude规则,你的fileset元素可能会变得过长。幸运的是,您可以提取出单独的exclude规则,并将每个规则放入其自己的exclude子元素中。对于include规则也可以这样做。我现在可以像这样重写我的文件集:

<fileset dir="src/lib" id="srclib">
    <exclude name="**/*_test.php" />
    <exclude name="**/*Test.php" />
    <include name="**/*.php" />
</fileset>

你可以在表 19-3 中看到fileset元素的一些属性。

表 19-3

文件集元素的一些属性

|

属性

|

需要

|

描述

| | --- | --- | --- | | refid | 不 | 当前fileset是给定 ID 的fileset的参考 | | dir | 不 | 起始目录 | | expandsymboliclinks | 不 | 如果设置为'true',跟随符号链接 | | includes | 不 | 以逗号分隔的模式列表—匹配的模式将被包括在内 | | excludes | 不 | 以逗号分隔的模式列表—匹配的模式将被排除 |

图案集

当你在你的fileset元素(和其他元素)中构建模式时,有一个危险是你将开始重复excludeinclude元素的组合。在我之前的例子中,我为测试文件和常规代码文件定义了模式。随着时间的推移,我可能会添加这些内容(也许我希望在我的代码文件定义中包含.conf.inc扩展)。如果我定义了其他也使用这些模式的fileset元素,我将被迫对所有相关的fileset元素进行调整。

您可以通过将模式分组到patternset元素中来解决这个问题。patternset元素对includeexclude元素进行分组,以便以后可以从其他类型中引用它们。这里,我从我的fileset示例中提取了includeexclude元素,并将它们添加到patternset元素中:

// listing 19.14
<patternset id="inc_code">
    <include name="**/*.php" />
    <include name="**/*.inc" />
    <include name="**/*.conf" />
</patternset>

<patternset id="exc_test">
    <exclude name="**/*_test.php" />
    <exclude name="**/*Test.php" />
</patternset>

我创建了两个patternset元素,将它们的id属性分别设置为inc_codeexc_testinc_code包含用于包含代码文件的include元素,exc_test包含用于排除测试文件的exclude文件。我现在可以在一个fileset中引用这些patternset元素:

// listing 19.15
<fileset dir="src/lib" id="srclib">
    <patternset refid="inc_code" />
    <patternset refid="exc_test" />
</fileset>

要引用现有的patternset,必须使用另一个patternset元素。第二个元素必须设置一个属性:refidrefid属性应该引用您希望在当前上下文中使用的patternset元素的id。这样,我可以跨不同的fileset元素重用patternset元素:

<fileset dir="src/views" id="srcviews">
    <patternset refid="inc_code" />
</fileset>

我对inc_code patternset所做的任何更改都会自动更新使用它的任何类型。与FileSet一样,您可以将exclude规则放在一个excludes属性或一组exclude子元素中。include规则也是如此。

表 19-4 中总结了一些patternset元素属性。

表 19-4

Patternset 元素的一些属性

|

属性

|

需要

|

描述

| | --- | --- | --- | | id | 不 | 引用元素的唯一句柄 | | excludes | 不 | 排除模式列表 | | includes | 不 | 包含的模式列表 | | refid | 不 | 当前patternset是给定 ID 的patternset的参考 |

过滤器链

到目前为止,我所遇到的类型已经提供了选择文件集的机制。相反,FilterChain 提供了一种灵活的机制来转换文本文件的内容。

与所有类型一样,定义一个filterchain元素本身不会引起任何变化。元素及其子元素必须首先与一个任务相关联——也就是说,一个告诉 Phing 采取一系列行动的元素。稍后我将回到任务。

一个filterchain元素将任意数量的过滤器组合在一起。过滤器像管道一样对文件进行操作——第一个过滤器改变其文件并将结果传递给第二个过滤器,第二个过滤器进行自己的改变,依此类推。通过在一个filterchain元素中组合多个过滤器,您可以实现灵活的转换。

在这里,我直接进入并创建了一个filterchain,它从传递给它的任何文本中删除 PHP 注释:

// listing 19.16
<filterchain>
    <stripphpcomments />
</filterchain>

StripPhpComments 任务顾名思义就是这样做的。如果您在源代码中提供了详细的 API 文档,您可能会让开发人员的工作变得轻松,但是您也给项目增加了很多负担。因为所有重要的工作都发生在您的源目录中,所以没有理由不删除关于安装的注释。

Note

如果您在项目中使用构建工具,请确保没有人对已安装的代码进行更改。安装程序将复制任何更改过的文件,更改将会丢失。我目睹了这一切的发生。

让我们先看一下下一部分,并将filterchain元素放在一个任务中:

// listing 19.17
<target name="main">
    <copy todir="build/lib">
        <fileset refid="srclib"/>
        <filterchain>
            <stripphpcomments />
        </filterchain>
    </copy>
</target>

复制任务可能是你用得最多的任务。它将文件从一个地方复制到另一个地方。如您所见,我在todir属性中定义了目标目录。文件的源由我在上一节中创建的fileset元素定义。然后是filterchain元素。复制任务复制的任何文件都将应用此转换。

Phing 支持许多操作的过滤器,包括剥离新行(StripLineBreaks)和用空格替换制表符(TabToSpaces)。甚至还有一个 XsltFilter,用于对源文件应用 XSLT 转换!然而,也许最常用的过滤器是ReplaceTokens。这允许您将源代码中的标记交换为构建文件中定义的属性,无论是从环境变量中提取还是在命令行中传递。这对于定制安装非常有用。将您的令牌集中到一个中央配置文件中是一个好主意,这样可以方便地查看项目的各个方面。

ReplaceTokens可选地接受两个属性,begintokenendtoken。您可以使用这些来定义描述令牌边界的字符。如果省略这些,Phing 将采用默认字符@。为了识别和替换令牌,您必须将token元素添加到replacetokens元素中。现在我将在我的例子中添加一个replacetokens元素:

// listing 19.18
<target name="main">
    <copy todir="build/lib">
        <fileset refid="srclib"/>
        <filterchain>
            <stripphpcomments />
            <replacetokens>
                <token key="dbname" value="${dbname}" />
                <token key="dbhost" value="${dbhost}" />
                <token key="dbpass" value="${dbpass}" />
            </replacetokens>
        </filterchain>
    </copy>
</target>

如您所见,token元素需要keyvalue属性。让我们来看看在我的项目中的一个文件上运行这个任务及其转换的效果。原始文件位于源目录中,src/lib/Config.php:

// listing 19.19
/*
 * Quick and dirty Conf class
 *
 */
class Config
{
    public string $dbname ="@dbname@";
    public string $dbpass ="@dbpass@";
    public string $dbhost ="@dbhost@";
}

运行包含先前定义的复制任务的主目标会产生以下输出:

$ phing

Buildfile: /home/bob/working/megaquiz/build.xml

megaquiz > main:
     [copy] Copying 8 files to /home/bob/working/megaquiz/build/lib

BUILD FINISHED

Total time: 0.1413 seconds

当然,原始文件没有被改动,但是由于复制任务,它在build/lib/Config.php被复制:

class Config {
    public string $dbname ="megaquiz";
    public string $dbpass ="default";
    public string $dbhost ="localhost";
}

不仅注释被删除了,而且标记也被替换成了它们的等价属性。

任务

任务是构建文件中完成工作的元素。不使用一个任务你不会有很大的成就,这就是为什么我已经欺骗和使用了两个。我将重新介绍这些。

回声

Echo 任务非常适合强制性的“Hello World”示例。在现实世界中,你可以用它来告诉用户你将要做什么或者你已经做了什么。您还可以通过显示属性值来检查您的构建过程。如您所见,放置在echo元素的开始和结束标签中的任何文本都将被打印到浏览器中:

<echo>The pass is '${dbpass}', shhh!</echo>

或者,您可以将输出消息添加到一个msg属性中:

<echo msg="The pass is '${dbpass}', shhh!" />

这与将以下内容打印到标准输出具有相同的效果:

[echo] The pass is 'default', shhh!

复制

复制实际上就是安装的全部。通常,您将创建一个目标,该目标从源目录中复制文件,并将它们汇编到一个临时的构建目录中。然后,您将拥有另一个目标,它将组装(和转换)的文件复制到它们的输出位置。将安装分成单独的构建和安装阶段并不是绝对必要的,但这意味着您可以在提交覆盖生产代码之前检查初始构建的结果。您还可以更改属性并再次安装到不同的位置,而不需要再次运行可能非常昂贵的复制/替换阶段。

简单来说,复制任务允许您指定源文件和目标目录或文件:

<copy file="src/lib/Config.php" todir="build/conf" />

如您所见,我使用file属性指定了源文件。您可能已经熟悉了todir属性,它用于指定目标目录。如果目标目录不存在,Phing 会为您创建一个。

如果您需要指定一个目标文件,而不是包含目录,您可以使用tofile属性来代替todir:

<copy file="src/lib/Config.php" tofile="build/conf/myConfig.php" />

如果需要的话,再次创建build/conf目录,但是这一次,Config.php被重命名为myConfig.php

如您所见,要一次复制多个文件,您需要向copy添加一个fileset元素:

// listing 19.20
<copy todir="build/lib">
    <fileset refid="srclib"/>
</copy>

源文件是由srclib fileset元素定义的,所以您只需在copy中设置todir属性。

Phing 足够聪明,可以测试源文件在目标文件创建后是否被修改过。如果没有改变,那么 Phing 不会复制。这意味着您可以进行多次构建,并且只有在此期间发生更改的文件才会被安装。这很好,只要其他事情不太可能改变。例如,如果一个文件是根据一个replacetokens元素的配置转换的,那么您可能希望确保每次调用复制任务时文件都被转换。您可以通过设置一个overwrite属性来做到这一点:

// listing 19.21
<copy todir="build/lib" overwrite="yes">
    <fileset refid="srclib"/>
    <filterchain>
        <stripphpcomments />
        <replacetokens>
            <token key="dbname" value="${dbname}" />
        </replacetokens>
    </filterchain>
</copy>

现在,无论何时运行 copy,由fileset元素匹配的文件都会被替换,不管源文件最近是否被更新过。

你可以在表 19-5 中看到copy元素和它的一些属性。

表 19-5

复制元素的一些属性

|

属性

|

需要

|

描述

|

缺省值

| | --- | --- | --- | --- | | file | 不 | 要复制的文件 | 没有人 | | todir | 是(如果tofile不存在) | 要复制到的目录 | 没有人 | | tofile | 是(如果todir不存在) | 要复制到的文件 | 没有人 | | tstamp or preservelastmodified | 不 | 匹配任何被覆盖文件的时间戳(它将显示为未更改) | false | | preservemode or preservepermissions | 不 | 匹配任何被覆盖文件的权限 | false | | includeemptydirs | 不 | 复制空目录 | false | | mode | 不 | 设置(八进制)模式 | 755 | | haltonerror | 不 | 如果遇到错误,构建过程将停止 | true | | overwrite | 不 | 如果目标已经存在,则覆盖它 | no |

投入

您已经看到了echo元素用于向用户发送输出。为了从用户那里收集输入*,我使用了不同的方法,包括命令行和环境变量。然而,这些机制既没有很好的结构化,也没有很好的交互性。*

Note

允许用户在构建时设置值的一个原因是考虑到构建环境之间的灵活性。在数据库密码的情况下,另一个好处是这些敏感数据不会保存在构建文件本身中。当然,一旦构建已经运行,密码将被保存到一个源文件中,因此由开发人员来确保他的系统的安全性!

元素允许您向用户显示一条提示消息。Phing 然后等待输入,并将其分配给一个属性。这里有一个例子:

// listing 19.22
<?xml version="1.0"?>
<!-- build xml -->

<project name="megaquiz"
         default="main" >

    <target name="setpass" unless="dbpass">
        <input message="You don't seem to have set a db password"
            propertyName="dbpass"
            defaultValue="default"
            promptChar=" >" />
    </target>

    <target name="main" depends="setpass">
        <echo>pass: ${dbpass}</echo>
    </target>
</project>

同样,我有一个默认目标:main。这依赖于另一个目标setpass,它负责确保填充dbpass属性。为此,我使用了目标元素的unless属性,这确保了如果已经设置了dbpass,它将不会运行。

setpass目标由一个单独的input任务元素组成。一个input元素需要一个message属性,该属性应该包含一个用户提示。propertyName属性是必需的,它定义了由用户输入填充的属性。如果用户在没有设置值的情况下在提示符下按 Enter 键,那么如果设置了defaultValue属性,该属性将被赋予一个回退值。最后,您可以使用promptChar属性定制提示字符——这为用户输入数据提供了视觉提示。让我们使用前面的目标运行 Phing:

$ phing

Buildfile: /var/popp/src/ch19/build.xml

megaquiz > setpass:

You don't seem to have set a db password [default] > mypass

megaquiz > main:
     [echo] pass: mypass

     BUILD FINISHED

     Total time: 3.8878 seconds

表 19-6 中总结了input元素。

表 19-6

输入元素的属性

|

属性

|

需要

|

描述

| | --- | --- | --- | | propertyName | 是 | 要用用户输入填充的属性 | | message | 不 | 提示信息 | | defaultValue | 不 | 如果用户不提供输入,则为属性分配一个值 | | validArgs | 不 | 以逗号分隔的可接受输入值列表。如果用户输入一个不在这个列表中的值,Phing 将重新显示提示 | | promptChar | 不 | 用户应该提供输入的视觉提示 | | hidden | 不 | 如果设置,隐藏用户输入 |

删除

安装通常是关于创建、复制和转换文件。然而,删除也有它的位置。当您希望执行全新安装时尤其如此。正如我已经讨论过的,对于自上次构建以来发生变化的源文件,文件通常只从源文件复制到目标文件。通过删除构建目录,您可以确保完整的编译过程将会发生。

在这里,我删除一个目录:

// listing 19.23
<target name="clean">
    <delete dir="build" />
</target>

当我使用参数clean(目标的名称)运行phing时,我的delete任务元素被调用。以下是 Phing 的输出:

$ phing clean

Buildfile: /var/popp/src/ch19/build.xml

megaquiz > clean:

    [delete] Deleting directory /var/popp/src/ch19/build

BUILD FINISHED

Total time: 0.1000 seconds

delete元素接受一个属性file,它可以用来指向一个特定的文件。或者,您可以通过向delete添加一个fileset子元素来微调您的删除。

摘要

真正的发展很少发生在一个地方。代码库需要从它的安装中分离出来,这样进行中的工作就不会污染需要一直保持功能的生产代码。版本控制允许开发人员签出一个项目,并在他们自己的空间中处理它。这要求他们应该能够为自己的环境轻松地配置项目。最后,也可能是最重要的,客户(即使客户是一年后的你,那时你已经忘记了代码的来龙去脉)应该能够在看一眼自述文件后安装你的项目。

在这一章中,我已经介绍了 Phing 的一些基础知识,Phing 是一个非常棒的工具,它将 Apache Ant 的许多功能带到了 PHP 世界。我只是触及了 Phing 能力的表面。然而,一旦您启动并运行了这里讨论的目标、任务、类型和属性,您会发现为高级特性添加新元素很容易,比如创建 tar/gzipped 发行版、自动生成 PEAR 包安装,以及直接从构建文件运行 PHP 代码。

如果 Phing 不能满足您所有的构建需求,您会发现,像 Ant 一样,它被设计成可扩展的——去构建您自己的任务吧!即使不添加 Phing,也应该花点时间检查一下源代码。Phing 完全是用面向对象的 PHP 编写的,它的代码中充满了设计示例。