2020年PHP代码覆盖率的改进

您是否知道您的代码覆盖率指标在说谎?



2003年,Derick Rethans发布了Xdebug 1.2。在PHP生态系统中,这是第一次可以收集代码覆盖率数据。 2004年,塞巴斯蒂安·伯格曼(Sebastian Bergmann)发布了PHPUnit 2,他在那里首次使用它。开发人员现在可以使用覆盖率报告来衡量其测试套件的性能。



从那时起,该功能已移至通用的独立php-code-coverage组件PHPDBGPCOV已成为替代驱动程序。但从根本上说,开发人员的核心流程在过去16年中没有改变。



到2020年8月,随着php-code-coverage 9.0的发布及其相关的版本PHPUnit 9.3behat-c​​ode-coverage 5.0,提供了一种估计覆盖率的新方法。



今天我们将考虑



  1. 快速基础知识
  2. 局限性
  3. 替代指标
  4. 分行覆盖
  5. 覆盖路径
  6. 包括新指标
  7. 使用哪个指标?
  8. 是否有任何理由不包括新指标?
  9. 结果


快速基础知识



大多数PHP开发人员都熟悉自动代码测试的想法。代码覆盖率的想法与自动化测试密切相关,并且是关于衡量已执行的代码或被测试``覆盖''的代码的百分比。例如,如果您具有以下代码:



<?php
class PercentCalculator
{
    public function __construct(int $numerator, int $denominator)
    {
        $this->numerator = $numerator;
        $this->denominator = $denominator;
    }

    public function calculatePercent(): float
    {
        return round($this->numerator / $this->denominator * 100, 1);
    }
}


然后我们可以编写一个PHPUnit测试,如下所示:



<?php
class PercentCalculatorTest extends PHPUnit\Framework\TestCase
{
    public function testTwentyIntoForty(): void
    {
        $calculator = new PercentCalculator(20, 40);
        self::assertEquals(50.0, $calculator->calculatePercent());
    }
}


运行测试后,PHPUnit确认在这个简单的示例中我们已达到100%的覆盖率:







局限性



但是,在上面的示例中,存在一个小的潜在错误。如果$分母0,则得到除以零的误差。让我们修复它,看看会发生什么:



<?php
class PercentCalculator
{
    public function __construct(int $numerator, int $denominator)
    {
        $this->numerator = $numerator;
        $this->denominator = $denominator;
    }

    public function calculatePercent(): float
    {
        //     ,
        //     
        //   
        return $this->denominator ? round($this->numerator / $this->denominator * 100, 1) : 0.0;
    }
}






即使第12行现在使用三元if / else语句(并且我们甚至都没有编写测试来验证我们的空处理是否正确),该报告仍告诉我们我们仍然具有100%的代码覆盖率。



如果测试覆盖了部分线,则将整个线标记为Covered这可能会误导您!



通过简单地计算是否执行一行,其他代码构造通常会遇到相同的问题,例如:



if ($a || $b || $c) { //  ** 
    doSomething();    //     100% 
}

public function pluralise(string $thing, int $count): string
{
    $string = $count . ' ' . $thing;

    if ($count > 1) {   //     $count >= 2,  - 100%
        $string .= 's'; //      $count === 1,
    }                   //      , 

    return $string;
}


替代指标



从2.3版开始,Xdebug不仅可以收集熟悉的逐行度量,还可以收集替代的分支和路径覆盖度量。Derik的博客文章谈到此功能时以臭名昭著的声明结束:

“还有待等到塞巴斯蒂安(或其他人)有时间更新PHP_CodeCoverage以显示分支和路径覆盖率。骇客入侵!

Derik Retans,2015年1月,“


在等待了这个神秘的“别人” 5年之后,我决定尝试自己实现所有这些。非常感谢Sebastian Bergman接受我的要求



分行覆盖



在除最简单的代码之外的所有代码中,都有执行路径可以分成两个或更多路径的地方。这发生在每个决策点,例如每个if / elsewhile。这些分歧点的每一侧都是一个单独的分支。如果没有决策点,则流程仅包含一个分支。



请注意,尽管使用了树隐喻,但此上下文中的分支与版本控制分支不同,请不要混淆两者!



启用分支和路径覆盖后,将使用php-code-coverage生成HTML报告除了常规线路覆盖率报告外,还包括用于显示分支和路径覆盖率的加载项。这是使用与之前相同的代码示例所显示的分支覆盖范围:







如您所见,页面顶部的枢轴框立即表明,尽管我们具有完整的逐行覆盖范围,但这不适用于分支覆盖范围和路径覆盖范围(路径将在下一部分中详细讨论)。



此外,第12行以黄色突出显示,表示其覆盖范围不完整(覆盖率0%的行将照常以红色显示)。



最后,更细心的人可能会注意到,与逐行覆盖不同,更多行以彩色突出显示。这是因为分支是根据PHP解释器内部的执行流计算的。输入该功能后,每个功能的第一个分支就会开始。这与基于字符串的覆盖相反,在基于字符串的覆盖中,仅将函数主体视为包含可执行字符串,而将函数声明本身视为不可执行。



寻找分支



PHP 解释器认为是逻辑上分离的代码分支与开发人员的思维模型之间的这种差异可能会使指标难以理解。例如,如果您问我computePercent ()中有多少个分支,我将回答2(0的特殊情况和一般情况)。但是,查看上面php-code-coverage报告,此单行函数实际上包含... 4个分支?



要了解PHP解释器的含义,上游有一个附加的覆盖率报告。它显示了每个分支的显示的扩展版本,有助于更有效地识别源代码中隐藏的内容。看起来像这样:





标题写着:“以下是代表Xdebug找到的每个代码分支的源代码行请注意,分支不必与字符串相同:字符串可以包含多个分支,因此可以多次出现。还请记住,某些分支可以是隐式的,例如,即使您未编写if语句在逻辑流中始终具有else


所有这些还不是很明显,但是您已经可以理解calculatePercent()实际上是什么分支



  • 分支1从函数入口开始,包括$ this->分母检查
  • 然后根据是否处理特殊情况将执行分为分支2和3。
  • 分支4是分支2和3合并的地方,由返回和退出函数组成。


将分支与源代码的各个部分进行心理匹配是一项需要实践的新技能。但是,使用易于理解和理解的代码来完成它绝对容易。如果像在我们的示例中那样,代码中充满了巧妙的一线结合了几条逻辑,那么与所有内容都以多行结构化和编写(完全对应于分支)的代码相比,您会期望更高的复杂性。用这种样式编写的相同逻辑如下所示:







三叶草



如果您Clover格式导出php-code-coverage报告以将其传输到另一个系统,则在启用基于分支的coverage的情况下数据将被写入条件Coveredconditionals键以前(或者如果未启用分支覆盖),导出的值始终为零。



覆盖路径



路径是分支的可能组合。如上所示,calculatePercent()示例具有两个可能的路径:



  • 分支1,然后分支2,然后分支4;
  • 分支1,然后分支3,然后分支4。






但是,例如,在包含许多条件和循环的代码中,路径的数量通常大于分支的数量。以下示例取自php-code-coverage,具有23个分支,但实际上有65条不同的路径可供函数执行:



final class File extends AbstractNode
{
    public function numberOfTestedMethods(): int
    {
        if ($this->numTestedMethods === null) {
            $this->numTestedMethods = 0;

            foreach ($this->classes as $class) {
                foreach ($class['methods'] as $method) {
                    if ($method['executableLines'] > 0 &&
                        $method['coverage'] === 100) {
                        $this->numTestedMethods++;
                    }
                }
            }

            foreach ($this->traits as $trait) {
                foreach ($trait['methods'] as $method) {
                    if ($method['executableLines'] > 0 &&
                        $method['coverage'] === 100) {
                        $this->numTestedMethods++;
                    }
                }
            }
        }

        return $this->numTestedMethods;
    }
}


如果你不能发现所有23家分公司,记住的foreach可以接受空迭代,并且如果总有一种无形的别的


是的,这意味着需要进行65次测试才能获得100%的覆盖率。类似于分支



php-code-coverage HTML报告包含每个路径的附加视图。它显示哪些被面团覆盖,哪些没有。



抓取



启用路径覆盖范围会进一步影响显示的指标,即CRAP得分crap4j.org上发布的定义使用PHP中历史上不可用的百分比路径覆盖率度量作为计算的输入PHP中,始终使用逐行覆盖。对于具有良好覆盖率的小型功能,CRAP分数可能保持不变甚至降低。但是对于具有许多执行路径和覆盖范围较差的功能,其价值将大大增加。



包括新指标



分支和路径覆盖一起启用或禁用,因为它们只是同一基础代码执行数据的不同表示。



PHPUnit



对于PHPUnit 9.3+,默认情况下禁用其他度量标准,并且可以通过命令行或phpunit.xml配置文件来启用其他度量标准,但仅当在Xdebug下运行时才可以启用尝试在使用PCOVPHPDBG时启用此功能将导致配置不兼容警告,并且不会收集覆盖范围。



  • 在控制台中,使用--path-coverage选项vendor / bin / phpunit - path-coverage
  • 在phpunit.xml中,将coverage元素的pathCoverage属性设置true


<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
    <testsuites>
        <testsuite name="default">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <coverage pathCoverage="true" processUncoveredFiles="true" cacheDirectory="build/phpunit/cache">
        <include>
            <directory suffix=".php">src</directory>
        </include>

        <report>
            <text outputFile="php://stdout"/>
            <html outputDirectory="build/coverage"/>
        </report>

    </coverage>
</phpunit>


在PHPUnit 9.3中,配置文件格式已进行了严重更改,因此上面的结构可能看起来与您习惯的不同。




贝特代码覆盖率



对于behat-c​​ode-cover 5.0+,设置在behat.yml中完成,该属性称为branchAndPathCoverage如果尝试使用Xdebug以外的其他驱动程序启用它,则会发出警告,但仍会生成覆盖率。这样可以更轻松地在不同环境中使用相同的配置文件。如果未显式配置,则在Xdebug下运行时,默认情况下将启用新的coverage



使用哪个指标?



我个人(Doug Wright)会尽可能使用新指标。我在各种代码上对其进行了测试,以了解什么是“正常”的。在我的项目中,很可能会使用一种混合方法,如下所示。对于商业项目,显然应该由整个团队做出切换到新指标的决定,我期待着有机会将他们的发现与我自己的发现进行比较。



我的意见



100%基于路径的覆盖率无疑是圣杯,并且在有意义的位置应用它是即使您没有这样做也是一个努力的良好指标。如果编写测试,您仍然应该考虑边缘情况。基于路径的覆盖范围可帮助您确保一切正常。



但是,如果一个方法包含数十个,数百个甚至数千个路径(对于相当复杂的东西实际上并不罕见),那么我不会浪费时间编写数百个测试。停在十点是明智的。测试本身并不是目的,而是减轻风险的工具和对未来的投资。测试应该得到回报,并且花费那么多时间测试不太可能获得回报。在这种情况下,最好以良好的分支覆盖范围为目标,因为这至少可以确保您考虑每个决策点正在发生的事情。



如果存在大量路径(现在已经用诚实的CRAP很好地定义了路径),我会评估所讨论的代码是否做得太多,是否有合理的方法将其分解为较小的功能(可以对其进行更详细的分析)?有时不是,那没关系-我们不需要消除绝对的所有项目风险。甚至了解他们都是很棒的。同样重要的是要记住,功能边界及其隔离的单元测试是对逻辑的人为分离,而不是整个软件的真正复杂性。因此,我建议不要仅仅因为执行路径数量众多而破坏大型函数。仅在分离可减少认知负担并帮助代码感知的情况下执行此操作。



是否有任何理由不包括新指标?



是的,性能。与普通的PHP性能相比Xdebug代码的运行速度慢得令人难以置信,这已不是秘密。而且,如果您打开分支和路径的覆盖范围,那么他现在需要跟踪的所有其他执行数据的开销都会使所有事情变得更加糟糕。 好消息是,必须解决这些问题已激励开发人员在php-code-coverage范围内进行常规性能改进,这将使使用Xdebug的所有人受益。测试套件的性能差异很大,因此很难判断这将如何影响每个测试套件,但是收集基于字符串的覆盖范围反而会更快。







分支和路径仍然慢3-5倍。必须考虑到这一点。考虑选择性地启用单个测试文件而不是整个测试套件,或者考虑使用“更好的覆盖率”进行每晚构建,而不是每次都运行推送。由于进行了模块化和性能方面的工作,



Xdebug 3的速度将大大快于当前版本,因此这些警告仅应视为Xdebug 2特有的使用版本3,即使考虑收集额外数据的开销,也可以比现在获得逐行覆盖的时间更短的时间基于分支和路径生成覆盖!





Sebastian Bergmann进行的测试,Derick Rethans绘制的图




结果



请测试新功能并写信给我们。他们有帮助吗?替代可视化的想法(可能来自其他语言)特别有趣。



好吧,我一直对您的代码覆盖率的正常水平感兴趣。





11月29日举行的PHP Russia上,我们将讨论有关PHP开发的所有最重要的问题,有关文档中未包含的内容,但是哪些内容将使您的代码更上一层楼。



与我们一起参加会议:不仅在温暖的气氛中聆听报告并向PHP-Universe的最佳讲者提问,而且还进行专业交流(最终离线!)。我们的社区:TelegramFacebookVKontakteYouTube



All Articles