测试覆盖率

有单元测试固然很好,但如果我们只测试解决方案的几个部分,那么无意中破坏代码库的可能性就更大了。不过,有几个单元测试总比没有单元测试好。我不知道理想测试代码覆盖率的行业标准数字或百分比。有人说 80%95% 的测试覆盖率很好,但这取决于项目。我仍然认为,50% 的测试覆盖率比 0% 的测试覆盖率要好,但每个项目的情况都可能大不相同。测试覆盖率也可以配置为不包括代码库中的某些部分,因此 100% 的测试覆盖率并不意味着代码库中的所有代码都 100% 被自动化测试覆盖。尽管如此,了解我们的解决方案有多少测试覆盖率仍然是件好事。对于刚刚开始使用单元测试的开发人员来说,有必要指出,有几个测试总比不写单元测试要好。如果代码覆盖率报告给出的数字较低,也不要害怕或失去动力;了解这一点至少能让你了解测试覆盖率的数据或真相。

为了让 PHPUnit 知道某个测试函数测试特定的解决方案代码,我们将使用 @covers 注释。PHP 中的注释是一种添加到类、函数、属性等的元数据。在 PHP 中,我们在文档块中声明注释。

声明注解

PHP 注解就像注释一样 – PHP 库使用它们从 PHP 中的函数、属性或类获取元数据。

打开 CalculationTest.php 文件并在 testCanCalculateTotal 函数上方添加以下 @covers 注释:

codebase/symfony/tests/Unit/CalculationTest.php
/**
* @covers \App\Example\Calculator::calculateTotal
*/
public function testCanCalculateTotal()

您会注意到,我们在 @covers 注释之后声明了 \App\Example\Calculator::calculateTotal 类和 calculateTotal 方法。我们基本上是告诉 PHPUnit 这个特定的 testCanCalculateTotal 测试函数将覆盖 \App\Example\Calculator 类中的方法或函数。

现在,运行以下 CLI 命令来运行具有测试覆盖率的 PHPUnit

/var/www/html/symfony# export XDEBUG_MODE=coverage
/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest

这一次,我们添加了 --coverage-text 选项。我们告诉 PHPUnit 将覆盖率分析报告输出回终端窗口。您现在将收到以下结果:

image 2023 10 23 15 49 45 446
Figure 1. Figure 5.4 – First test coverage

恭喜!您刚刚收到第一份测试覆盖率报告!这意味着 Calculation.php 类的计算方法被单元测试覆盖。然而,在现实生活中,我们最终会在类中拥有更多函数。如果我们开始向 Calculation.php 添加函数会发生什么? 好吧,让我们找出答案。

当你设置 export XDEBUG_MODE=coverage 时,Xdebug 会启用 代码覆盖率分析 功能。这意味着:

生成覆盖率数据

在运行 PHP 脚本时,Xdebug 会记录哪些代码行被执行,哪些未被执行。

生成覆盖率报告

结合工具(如 PHPUnit),可以生成详细的代码覆盖率报告,帮助开发者了解测试用例覆盖了多少代码。

export XDEBUG_MODE=coverage
phpunit --coverage-html ./coverage-report

这会在 ./coverage-report 目录下生成一个 HTML 格式的覆盖率报告。

向解决方案类添加更多函数

我们之前创建的 CalculationTest 类有一个覆盖 calculateTotal 函数的测试。运行覆盖率测试时,我们得到了 100% 的测试覆盖率结果。如果我们在解决方案类中添加更多的函数,我们将不再得到 100% 的覆盖率测试结果。这意味着什么呢?实际上,这意味着我们的自动测试没有覆盖解决方案类的某些部分。这并不是世界末日,但这将有助于公司的开发人员确定自动化测试覆盖了系统的多少部分。这将影响企业对代码库更新的信心水平,从而也会影响需要进行的手动测试的数量,或影响企业对发布新代码的信心。

打开 Calculation.php 类并添加以下方法:

codebase/symfony/src/Example/Calculator.php
<?php

namespace App\Example;

class Calculator
{
    public function calculateTotal(int $a, int $b, int $c) : int
    {
        return $a + $b + $c;
    }

    public function add(int $a, int $b): int
    {
        return $a + $b;
    }
}

正如您在前面的代码块中看到的,我们添加了一个名为 add 的新函数。该函数仅返回 $a$b 的总和。由于我们没有针对这个新函数的单元测试,让我们看看再次运行测试时会发生什么。运行以下命令:

/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest

运行上述命令后,我们会注意到测试覆盖率结果发生了变化:

image 2023 10 23 15 54 34 926
Figure 2. Figure 5.5 – Test coverage has decreased

您会注意到,在 Calculator.php 类中添加 add 函数之前,我们的测试覆盖率为 100%。现在,我们只有 50% 的测试覆盖率。显然,这是因为我们没有负责测试 add 函数的单元测试。为了提高测试覆盖率,我们为 add 函数添加一个单元测试:

codebase/symfony/tests/Unit/CalculationTest.php
<?php

namespace App\Tests\Unit;
use App\Example\Calculator;
use PHPUnit\Framework\TestCase;

class CalculationTest extends TestCase
{
    /**
    * @covers \App\Example\Calculator::calculateTotal
    */
    public function testCanCalculateTotal()
    {
        // Expected result:
        $expectedTotal = 6;

        // Test data:
        $a = 1;
        $b = 2;
        $c = 3;

        $calculator = new Calculator();
        $total = $calculator->calculateTotal($a, $b, $c);

        $this->assertEquals($expectedTotal, $total);
    }

    /**
    * @covers \App\Example\Calculator::add
    */
    public function testCanAddIntegers()
    {
        // Expected Result
        $expectedSum = 7;

        // Test Data
        $a = 2;
        $b = 5;

        $calculator = new Calculator();
        $sum = $calculator->add($a, $b);

        $this->assertEquals($expectedSum, $sum);
    }
}

在前面的代码块中,我们添加了 testCanAddIntegers 测试函数。通过使用 @covers 注释,我们还声明该函数测试 Calculation.php 类中的 add 函数。

让我们再次运行测试,看看我们是否提高了测试覆盖率结果。再次运行以下命令:

/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest

现在,我们应该看到以下结果:

image 2023 10 23 15 58 40 239
Figure 3. Figure 5.6 – Back to 100% test coverage

很好 现在,我们又有了 100% 的测试覆盖率。我们在 Calculation.php 类中有两个函数,我们还有两个单元测试,分别测试这两个函数。

现在,想象一下你正在与其他开发人员一起开发一个项目,这很常见。如果其他开发人员开始重构一个经过单元测试的类,并开始在该类中添加函数,但却没有添加测试来覆盖这些函数,那么当你的团队运行覆盖测试时,你的团队就会轻松、快速地发现该类中存在自动化测试未覆盖的新函数或功能。

如果你在 Calculation.php 类中创建了一个 private 函数怎么办?如果需要测试 private 方法,可以通过测试调用 private 方法的方法来间接测试私有方法,或者使用 PHP 的反射功能。

利用 PHP 的反射特性直接测试私有方法

私有方法不应该被外部对象访问,但它们可以被间接测试,这将在下一节中解释。如果你真的想直接测试某个私有方法,可以使用这种方法。打开 Calculator.php 类,添加私有 getDifference 方法:

codebase/symfony/src/Example/Calculator.php
<?php

namespace App\Example;

class Calculator
{
    public function calculateTotal(int $a, int $b, int $c) : int
    {
        return $a + $b + $c;
    }

    public function add(int $a, int $b): int
    {
        return $a + $b;
    }

    private function getDifference(int $a, int $b): int
    {
        return $a - $b;
    }
}

如果再次运行测试,您会发现测试覆盖率再次下降,即使您刚刚添加了私有方法:

image 2023 10 23 16 02 50 985
Figure 4. Figure 5.7 – No test for private method

现在,我们有未经测试的代码,由于它是私有方法,因此测试也很棘手。要对此进行测试,请打开 CalculationTest.php 测试类并添加 testCanGetDifference 方法:

codebase/symfony/tests/Unit/CalculationTest.php
/**
* @covers \App\Example\Calculator::getDifference
*/
public function testCanGetDifference()
{
    // Expected Result
    $expectedDifference = 4;

    // Test Data
    $a = 10;
    $b = 6;

    // Reflection
    $calculatorClass = new \ReflectionClass(Calculator::class);
    $privateMethod = $calculatorClass->getMethod("getDifference");
    $privateMethod->setAccessible(true);

    // Instance
    $calculatorInstance = new Calculator();

    // Call the private method
    $difference = $privateMethod->invokeArgs($calculatorInstance, [$a, $b]);

    $this->assertEquals($expectedDifference, $difference);
}

与之前的测试方法一样,我们还对该测试进行了注释,以表明它测试 Calculator.php 类中的 getDifference 方法。由于我们正在尝试测试一个私有方法,如果我们只是实例化一个 Calculator 对象,该方法显然无法访问,因此我们需要使用 PHPReflectionClass。我们手动指定了 getDifference 类的可见性,并间接调用了私有 getDifference 方法。如果我们再次运行测试,我们现在将看到以下内容:

image 2023 10 23 16 05 56 062
Figure 5. Figure 5.8 – Private method tested

现在,我们又回到了 100% 的测试覆盖率,测试了两个 public 方法和一个 private 方法,但这有必要吗?我个人认为这并不实用。如果我有一个 private 方法,我显然会在另一个可公开访问的方法中使用该私有方法。我会做的是测试该公开访问方法。如果 private 方法中的指令非常复杂,我认为无论如何它都不应该是类中的 private 方法。它可能需要有自己的类,或者需要进一步细分。我见过很多好类(什么都能做的类)都有非常复杂的 private 方法,维护这类类很头疼。

间接测试私有方法

如果我有一个 private 方法,我会测试使用 private 方法的公共方法,而不是通过反射途径。如果过于复杂,我会考虑将此测试从单元测试套件中移除。你可以阅读本章后面关于集成测试的内容,了解更多信息。

打开 Calculator.php 类,将其内容替换为以下内容:

codebase/symfony/src/Example/Calculator.php
<?php

namespace App\Example;

class Calculator
{
    public function calculateTotal(int $a, int $b, int $c) : int
    {
        return $a + $b + $c;
    }

    public function add(int $a, int $b): int
    {
        return $a + $b;
    }

    public function subtract(int $a, int $b): int
    {
        return $this->getDifference($a, $b);
    }

    private function getDifference(int $a, int $b): int
    {
        return $a - $b;
    }
}

我们保留了私有 getDifference 方法,但我们还添加了一个新的可公开访问的方法,称为 subtract,该方法又使用 getDifference 方法。

打开 CalculationTest.php 文件并将反射测试替换为以下内容:

codebase/symfony/tests/Unit/CalculationTest.php
/**
* @covers \App\Example\Calculator::subtract
* @covers \App\Example\Calculator::getDifference
*/
public function testCanSubtractIntegers()
{
    // Expected Result
    $expectedDifference = 4;

    // Test Data
    $a = 10;
    $b = 6;

    $calculator = new Calculator();
    $difference = $calculator->subtract($a, $b);

    $this->assertEquals($expectedDifference, $difference);
}

在前面的代码块中,我们删除了使用 PHP 的 ReflectionClass 方法的 testCanGetDifference 测试。是否要使用反射手动单独测试私有方法或受保护方法取决于您。

在这个新的 testCanSubtractIntegers 方法中,您会注意到现在有两个 @cover 注释。我们明确声明此特定测试方法将涵盖公共 subtract 方法和私有 getDifference 方法。

运行以下命令再次执行覆盖率测试,看看是否仍然通过测试:

/var/www/html/symfony# php bin/phpunit --coverage-text --filter CalculationTest

您应该看到以下 100% 覆盖率结果:

image 2023 10 23 16 15 41 910
Figure 6. Figure 5.9 – Two methods covered by one test

您会注意到覆盖率报告指出我们已经测试了四种方法。从技术上讲,我们的 CalculationTest.php 测试类中只有三个测试,由测试结果报告:

OK (3 tests, 3 assertions)

由于我们已经声明 testCanSubtractIntegers 测试将涵盖 subtractgetDifference 方法,因此我们能够获得 Calculator.php 类的完整测试覆盖率:

Methods: 100.00% (4/4)

现在,我们能够通过运行单元测试、使用 Xdebug 进行断点调试并获得测试覆盖率结果了。接下来,我们将创建自己的小工具,让运行测试变得更简单,这样就不必总是写很长的命令了。