第 10 章 代码覆盖率分析

 

测试之美不在力,在乎效率之间也。

知何物需测是为美,知何物已测亦为美。

 
 --Murali Nandigama

在本章中,你将学到 PHPUnit 代码覆盖率功能的一切。这个功能能洞察测试运行过程中执行了生产代码的哪些部分。他能够帮助回答诸如这些问题:

  • 如何找到尚未被测试的代码——或者换句话说,尚未被测试覆盖的?

  • 如何衡量测试的完整度?

关于代码覆盖率统计是什么意思,举个例子,假如有个方法有100行代码,而在测试运行过程中实际上只执行了其中的75行,那么这个方法就有75%的代码覆盖率。

PHPUnit 的代码覆盖率功能使用了 PHP_CodeCoverage 组件,这反过来又利用了 Xdebug 扩展为 PHP 提供的语句覆盖率功能。

注意

Xdebug 不随 PHPUnit 分发。如果在运行测试时收到了 Xdebug 扩展未加载的通知,就意味着 Xdebug 未安装或者未正确配置。在使用 PHPUnit 的代码覆盖率分析功能之前,你需要阅读 Xdebug 安装指南

让我们来为???中的 BankAccount 类生成一份代码覆盖率报告。

phpunit --coverage-html ./report BankAccountTest
PHPUnit 3.9.0 by Sebastian Bergmann.

...

Time: 0 seconds

OK (3 tests, 3 assertions)

Generating report, this may take a moment.

图 10.1是代码覆盖率报告的摘录。测试运行时被执行到的代码行高亮标为绿色,可执行但是未被执行到的代码行标为红色,“死代码”标为灰色。代码行左边的数字表明有多少测试覆盖了此行。

图 10.1. setBalance() 的代码覆盖情况

setBalance() 的代码覆盖情况


点击已覆盖的代码行的行号将会打开一个面板(参见图 10.2),显示出所有覆盖了本行的测试用例。

图 10.2. 带有覆盖本行代码的测试的信息的面板

带有覆盖本行代码的测试的信息的面板


对于 BankAccount 这个例子,代码覆盖率报告显示目前没有任何测试以合法值调用 setBalance()depositMoney()withdrawMoney() 方法。例 10.1展示了一个可以加到 BankAccountTest 测试用例类中来完全覆盖 BankAccount 类的测试。

例 10.1: 达成完全覆盖所缺少的测试

<?php
require_once 'BankAccount.php';

class BankAccountTest extends PHPUnit_Framework_TestCase
{
    // ...

    public function testDepositWithdrawMoney()
    {
        $this->assertEquals(0, $this->ba->getBalance());
        $this->ba->depositMoney(1);
        $this->assertEquals(1, $this->ba->getBalance());
        $this->ba->withdrawMoney(1);
        $this->assertEquals(0, $this->ba->getBalance());
    }
}
?>


图 10.3展示了加入额外的测试之后 setBalance() 方法的代码覆盖情况。

图 10.3. 加上附加方法之后 setBalance() 的代码覆盖情况

加上附加方法之后 setBalance() 的代码覆盖情况


指明要覆盖的方法

The @covers 标注(参见表 A.1)可以用在测试代码中来指明测试方法想要对哪些方法进行测试。如果提供了这个信息,那么只有指定的方法的代码覆盖率信息会被统计。例 10.2展示了一个例子。

例 10.2: 指明了要覆盖哪些方法的测试

<?php
require_once 'BankAccount.php';

class BankAccountTest extends PHPUnit_Framework_TestCase
{
    protected $ba;

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

    /**
     * @covers BankAccount::getBalance
     */
    public function testBalanceIsInitiallyZero()
    {
        $this->assertEquals(0, $this->ba->getBalance());
    }

    /**
     * @covers BankAccount::withdrawMoney
     */
    public function testBalanceCannotBecomeNegative()
    {
        try {
            $this->ba->withdrawMoney(1);
        }

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

            return;
        }

        $this->fail();
    }

    /**
     * @covers BankAccount::depositMoney
     */
    public function testBalanceCannotBecomeNegative2()
    {
        try {
            $this->ba->depositMoney(-1);
        }

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

            return;
        }

        $this->fail();
    }

    /**
     * @covers BankAccount::getBalance
     * @covers BankAccount::depositMoney
     * @covers BankAccount::withdrawMoney
     */

    public function testDepositWithdrawMoney()
    {
        $this->assertEquals(0, $this->ba->getBalance());
        $this->ba->depositMoney(1);
        $this->assertEquals(1, $this->ba->getBalance());
        $this->ba->withdrawMoney(1);
        $this->assertEquals(0, $this->ba->getBalance());
    }
}
?>


可以用 @coversNothing 标注来指定一个测试不覆盖任何方法。(参见“@coversNothing”一节)。这可以在编写集成测试时用来确保只生成单元测试的代码覆盖率。

例 10.3: 指明了不覆盖任何方法的测试

<?php
class GuestbookIntegrationTest extends PHPUnit_Extensions_Database_TestCase
{
    /**
     * @coversNothing
     */
    public function testAddEntry()
    {
        $guestbook = new Guestbook();
        $guestbook->addEntry("suzy", "Hello world!");

        $queryTable = $this->getConnection()->createQueryTable(
            'guestbook', 'SELECT * FROM guestbook'
        );
        $expectedTable = $this->createFlatXmlDataSet("expectedBook.xml")
                              ->getTable("guestbook");
        $this->assertTablesEqual($expectedTable, $queryTable);
    }
}
?>
      


忽略代码块

有时候有一些代码块是无法对其进行测试的,因此希望在代码覆盖率分析中忽略它们。PHPUnit 允许你用 @codeCoverageIgnore@codeCoverageIgnoreStart@codeCoverageIgnoreEnd 标注做到这点,如例 10.4中所示。

例 10.4: @codeCoverageIgnore@codeCoverageIgnoreStart@codeCoverageIgnoreEnd 标注的使用

<?php
/**
 * @codeCoverageIgnore
 */
class Foo
{
    public function bar()
    {
    }
}

class Bar
{
    /**
     * @codeCoverageIgnore
     */
    public function foo()
    {
    }
}

if (FALSE) {
    // @codeCoverageIgnoreStart
    print '*';
    // @codeCoverageIgnoreEnd
}
?>


代码中被忽略掉的行(用标注标记为忽略)将会计为已执行(如果它们是可执行的),并且不会在代码覆盖情况中被高亮标记。

包含与排除文件

默认情况下,报告中包括(且只包括)所有包含至少一行已被执行的代码的源代码文件。可以通过黑名单或者白名单来对报告中包含哪些源代码文件进行过滤。

在黑名单中,会预先填充进 PHPUnit 自身的所有源代码文件,以及测试本身的所有源代码文件。如果白名单为空(默认情况),将会使用黑名单机制。如果白名单非空,那么将会使用白名单机制。白名单中的每个文件都会加入代码覆盖率报告中,不管它是否被执行到。此类文件的所有行,包括那些非可执行文件,都按未执行进行计数。

如果在 PHPUnit 配置信息(参见“为代码覆盖率包含或排除文件”一节)中设置 processUncoveredFilesFromWhitelist="true",那么所有这些文件将会由 PHP_CodeCoverage 进行包含,并正确计算其可执行行数。

注意

请注意,当设置了 processUncoveredFilesFromWhitelist="true" 时将会进行源代码文件的读取,这有可能会导致一些问题,比如,源代码文件包含有处于类或者函数作用域之外的代码时。

PHPUnit 的 XML 配置文件(参见“为代码覆盖率包含或排除文件”一节)可以用于控制黑名单与白名单。使用白名单来控制代码覆盖率报告所包含的文件是推荐的最佳实践。

边缘情况

大多数情况下可以放心地说 PHPUnit 提供的是“基于行的”代码覆盖率信息。不过鉴于搜集信息的方式,有一些值得注意的边缘情况。

例 10.5:

<?php
// 因为覆盖率是“基于行的”而不是基于语句的,
// 每行只会有一种覆盖状态。
if(false) this_function_call_shows_up_as_covered();

// 由于代码覆盖率的内部工作方式,这两行显得很特别。
// 这一行会显示为非可执行。
if(false)
    // 这一行会显示为已覆盖,
    // 实际上是上一行的 if 语句的覆盖信息显示在这了!
    will_also_show_up_as_coveraged();

// 为了避免这种情况,必须使用大括号
if(false) {
    this_call_will_never_show_up_as_covered();
}
?>


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