第 12 章 测试驱动开发

单元测试是一些软件开发实践与流程的至关重要的组成部分,比如测试优先编程(Test-First Programming)、极限编程(Extreme Programming)测试驱动开发(Test-Driven Development)。它们还使得将契约式设计(Design-by-Contract)应用到语言结构上未支持这套方法的编程语言中成为可能。

可以在编程完成之后再用 PHPUnit 来编写测试。然而,在引入错误之后尽早编写测试其价值就更高。因此,与其在编码“完成”之后若干个月才编写测试,不如在可能引入缺陷之后几天、几小时甚至几分钟之内就编写测试。到此为止吗?为何不在可能引入缺陷之前就编写测试呢?

测试优先编程是极限编程和测试驱动开发的组成部分,它正是建立在这个观念之上,并将其推至极致。在现今的计算能力下,我们有机会每天都将成千上万测试运行成千上万遍。我们可以利用这些测试带来的反馈,用很小的步调进行编程,伴随着每个步调,都往现有的那些测试中再加上一个新的自动测试来为其担保。这些测试就像登山时使用的岩钉,一旦钉下,就能为你提供保证,不管发生了什么情况,你顶多下跌到岩钉处就会停住。

如果首先编写测试,它是无法运行的,因为调用了尚未进行编程的类和方法。在一开始可能会觉得这很诡异,但是很快就会习惯。请将测试优先编程视为一种务实的实践方法,它遵从对接口编程而不是对实现编程的面向对象编程原则:当你编写测试时,你正是在考虑被测对象的接口——这个对象从外部看是什么样的。当考虑如何能让测试真正运作起来是,你所考虑的则是纯粹的实现。那些失败的测试已经确定了接口。

 

测试驱动开发的要点是驱动出软件实际需要的功能,而不是程序员自认为软件应该有的功能。它实现这点的方式初看起来就算不是彻头彻尾的愚蠢也至少是有悖常理,但是它不仅是有道理的,还很快就成为一种自然而又优雅的软件开发方式。

 
 --Dan North

接下来应当是对测试驱动开发的简短介绍。可以在其他书里深入探索这个主题,例如 Kent Beck 写的《测试驱动开发(Test-Driven Development)》 [Beck2002],或者 Dave Astels 写的 《测试驱动开发实践指南(A Practical Guide to Test-Driven Development)》 [Astels2003].

银行账户(BankAccount)的例子

在本节,让我们来看一个例子,关于一个代表银行账户的类。BankAccount 类的契约要求这个类要有读写银行账户结余的方法,还要有存钱和取钱的方法。同时还指定了以下两个必须保证的条件:

  • 银行账户的初始结余应当是零。

  • 银行账户的结余不能变成负的。

在编写 BankAccount 类的实际代码之前先来编写针对它的测试。以合约条件作为测试的基础,并据此的命名测试方法,如例 12.1中所示。

例 12.1: BankAccount 类的测试

<?php
require_once 'BankAccount.php';

class BankAccountTest extends PHPUnit_Framework_TestCase
{
    protected $ba;

    protected function setUp()
    {
        $this->ba = new BankAccount;
    }

    public function testBalanceIsInitiallyZero()
    {
        $this->assertEquals(0, $this->ba->getBalance());
    }

    public function testBalanceCannotBecomeNegative()
    {
        try {
            $this->ba->withdrawMoney(1);
        }

        catch (BankAccountException $e) {
            $this->assertEquals(0, $this->ba->getBalance());

            return;
        }

        $this->fail();
    }

    public function testBalanceCannotBecomeNegative2()
    {
        try {
            $this->ba->depositMoney(-1);
        }

        catch (BankAccountException $e) {
            $this->assertEquals(0, $this->ba->getBalance());

            return;
        }

        $this->fail();
    }
}
?>


现在编写最少量的代码来让第一个测试 testBalanceIsInitiallyZero() 能够通过。在这个例子里,这个实现 BankAccount 类的 getBalance() 方法的代码量如例 12.2中所示。

例 12.2: 让 testBalanceIsInitiallyZero() 测试能够通过的代码

<?php
class BankAccount
{
    protected $balance = 0;

    public function getBalance()
    {
        return $this->balance;
    }
}
?>


针对第一个契约条件的测试现在能够通过了,不过第二个契约条件的测试依然失败,因为尚未实现其所调用的方法。

phpunit BankAccountTest
PHPUnit 3.7.0 by Sebastian Bergmann.

.
Fatal error: Call to undefined method BankAccount::withdrawMoney()

为了让确保第二个契约条件的测试能够通过,需要实现 withdrawMoney()depositMoney()setBalance() 方法,如例 12.3中所示。这些方法是这样编写的:如果使用某些会导致违背契约条件的非法值进行调用,就抛出 BankAccountException

例 12.3: 完整的 BankAccount 类

<?php
class BankAccount
{
    protected $balance = 0;

    public function getBalance()
    {
        return $this->balance;
    }

    protected function setBalance($balance)
    {
        if ($balance >= 0) {
            $this->balance = $balance;
        } else {
            throw new BankAccountException;
        }
    }

    public function depositMoney($balance)
    {
        $this->setBalance($this->getBalance() + $balance);

        return $this->getBalance();
    }

    public function withdrawMoney($balance)
    {
        $this->setBalance($this->getBalance() - $balance);

        return $this->getBalance();
    }
}
?>


用来确保第二个契约条件的测试现在也能够通过了:

phpunit BankAccountTest
PHPUnit 3.7.0 by Sebastian Bergmann.

...

Time: 0 seconds


OK (3 tests, 3 assertions)

另外,也可以用 PHPUnit_Framework_Assert 类所提供的静态断言方法来将契约条件以契约式设计的样式写入代码之中,如例 12.4中所示。如果这些断言之一失败,将会抛出 PHPUnit_Framework_AssertionFailedError 异常。通过这种方法,为检测契约条件所编写的代码就会更少,测试的可读性也会变得更好。不过这样就往项目中加入了对 PHPUnit 的运行时依赖。

例 12.4: 带有契约式设计断言的 BankAccount 类

<?php
class BankAccount
{
    private $balance = 0;

    public function getBalance()
    {
        return $this->balance;
    }

    protected function setBalance($balance)
    {
        PHPUnit_Framework_Assert::assertTrue($balance >= 0);

        $this->balance = $balance;
    }

    public function depositMoney($amount)
    {
        PHPUnit_Framework_Assert::assertTrue($amount >= 0);

        $this->setBalance($this->getBalance() + $amount);

        return $this->getBalance();
    }

    public function withdrawMoney($amount)
    {
        PHPUnit_Framework_Assert::assertTrue($amount >= 0);
        PHPUnit_Framework_Assert::assertTrue($this->balance >= $amount);

        $this->setBalance($this->getBalance() - $amount);

        return $this->getBalance();
    }
}
?>


通过将契约条件写入测试,我们使用契约式设计编制了 BankAccount 类。随后我们遵照测试优先编程方法编写了让测试能通过的代码。然而,我们忘了编写以不会违背契约条件的合法值调用 setBalance()depositMoney()withdrawMoney() 的测试。我们需要一种手段来测试我们的测试,或者至少是衡量它们的质量。这种方法就是代码覆盖率信息分析,接下来我们将要对它进行讨论。

请在 GitHub 上 开启任务单 来对本页提出改进建议。万分感谢!