PHPUnit + Infection 实现 PHP 中的变异测试(Mutation Testing)

Mutation Testing

介绍

你如何确定当前的测试套件是否增加了足够的价值?测试是否涵盖所有边缘情况?如果你只依赖代码覆盖率,你可能会错失良机。

最近在 Laracon EU Online 会议上的一次演讲中,我的同事 Jeroen Groenendijk 强调了突变测试 在应用程序的 测试过程 中达成 更大的信心 的重要性。

在本文中,我想重点介绍 突变测试 的概念,解释如何开始使用 Infection PHP,作者是 Maks Rafalko,展示了一些实际示例,最后解释了如何在 CI 设置中使用突变测试。

什么是突变测试

突变测试工具将操纵 (mutate) 段源代码,并针对这段 mutant 源代码运行测试套件。突变的代码应该触发失败的测试,或者突变的逃逸。逃逸的突变体是弱测试代码的标志。

作为说明,请看以下示例。

Mutation Testing Diagram

add() 方法以三种不同的方式进行变异:

  • 该方法返回 null
  • + 操作符更改为 - 操作符
  • 方法可见性从 public 更改为 protected

在图中,如果返回 null 并且将加法替换为减法,则测试失败。但是,该方法的可见性可能过于广泛。因此,将其变异protected 并没有产生失败的测试。因此,应将其更改为 protected 甚至 private,坚持将此类的公共 API 保持在最低限度的最佳实践。

Infection PHP 入门

如果你想尝试一下,请查看 Infection Playground,你可以在其中编写代码、测试并直接在浏览器中运行 Infection。继续阅读以了解有关在你的项目中设置 Infection PHP 的更多信息。

设置 Infection PHP

Infection PHP 需要 PHP 版本 7.2(或更高版本)和启用的调试器选择:Xdebug、[phpdbg](infection.github.io/guide/usage.ht... Running-with-phpdbg) 或 pcov提示:当你在面向 Laravel 的开发环境中设置 Xdebug 时,请务必查看 LearnXDebug.com

在下面的步骤中,我假设安装了 xDebug。

虽然你有 多种选择 来安装 Infection PHP,但我建议你在项目中使用 composer 将其安装为 dev 依赖项。

步骤 1. 安装 Infection PHP

composer require --dev infection/infection

步骤 2. 运行 Infection

vendor/bin/infection

在第一次运行时,Infection 会要求输入:

  • 要包含的目录: - 对于 Laravel 项目,这意味着您的 app 目录。 - 对于 PHP 包,这意味着 src 目录。
  • 从源目录中排除目录:
    • 将此留空。除非你的源目录中有 PHP 代码,否则 Infection 不应该发生变异。
  • 存储文本日志文件的位置:
    • 我建议将突变保存到 (e.g.) infection.log。所有逃脱的突变体都保存在这里供以后审查。
    • 或者,你可以使用 --show-mutations 选项将突变记录到终端输出。

你现在会在项目的根目录中找到映射你输入内容的文件 infection.json.dist

{
    "source": {
        "directories": [
            "src"
        ]
    },
    "logs": {
        "text": "infection.log"
    },
    "mutators": {
        "@default": true
    }
}

你可以在「mutators」键下指定是否要启用(或禁用)特定的 mutator。请务必查看可用突变体的完整列表

提示:通过提供 --threads 选项并将其设置为大于 1,允许突变测试并行运行。例如,使用 4 个线程,你将运行 vendor/bin/infection --threads=4。使用并行测试将大大加快突变测试的运行速度。你可以在 infection.github.io/guide/command-... 找到所有命令行选项的概述。

代码示例

示例 1:计算运费

为了在实践中演示突变测试,我将借用 Jeroen 演讲中的示例。

想象一下,有一个 ShippingCalculator 服务类来确定传入的 Product 是否有资格免费送货。为简单起见,假设 Product 类通过其构造函数接受 $price 整数,并提供对 $shipsForFree 属性的公共访问。

在以下情况下,ShippingCalculator 类可以确定 Product 接受免费送货:

  • 价格等于或大于阈值(设置为任意值)
  • 产品的 $shipsForFree 属性为 true(或 truthy)
class ShippingCalculator
{
    const FREE_SHIPPING_THRESHOLD = 20;

    public static function hasFreeShipping(Product $product): bool
    {        
        if ($product->price >= self::FREE_SHIPPING_THRESHOLD) {
            return true;
        }

        if ($product->shipsForFree) {
            return true;
        }

        return false;
    }
}

使用 PHPUnit 测试代码

为了确保 ShippingCalculator::hasFreeShipping() 方法正常工作,我们可以考虑添加以下单元测试以确保正常运行:

  • 当产品的$price超过阈值时,它应该免费发货
  • 当产品的$price 未超过阈值时,不应免费发货
  • 当产品的 $shipsforFree 属性设置为 true 时,它应该免费发货
class ShippingCalculatorTest extends TestCase
{
    /** @test */
    function product_ships_for_free_when_price_is_above_treshold()
    {
        $product = new Product($price = ShippingCalculator::FREE_SHIPPING_THRESHOLD + 1);

        $this->assertTrue(ShippingCalculator::hasFreeShipping($product));
    }

    /** @test */
    function product_does_not_ship_for_free_when_price_is_below_treshold()
    {
        $product = new Product($price = ShippingCalculator::FREE_SHIPPING_THRESHOLD - 1);

        $this->assertFalse(ShippingCalculator::hasFreeShipping($product));
    }

    /** @test */
    function product_ships_for_free_when_ships_for_free_property_is_true()
    {
        $product = new Product(ShippingCalculator::FREE_SHIPPING_THRESHOLD - 1);
        $product->shipsForFree = true;

        $this->assertTrue(ShippingCalculator::hasFreeShipping($product));
    }
}

使用这三个测试,来自 PHPUnit 的代码覆盖率报告(您可以使用 vendor/bin/phpunit --coverage-text 生成)显示两个类的代码覆盖率均为 100% 。

 Summary:
  Classes: 100.00% (2/2)
  Methods: 100.00% (2/2)
  Lines:   100.00% (7/7)

运行 Infection PHP

现在,我们将使用 vendor/bin/infection 运行 Infection PHP 并看是否有任何变体逃脱。

.M....                                               (6 / 6)

       6 tupian were generated:
       5 mutants were killed
       0 mutants were not covered by tests
       1 covered mutants were not detected
       ...

Metrics:
         Mutation Score Indicator (MSI): 83%
         Mutation Code Coverage: 100%
         Covered Code MSI: 83%

哦,不, 一个变体已逃脱

此外,我们的 MSI83%,而生成的突变覆盖了 100% 的代码。这意味着 6 个变种中有 5 个被杀死。

当我们检查日志文件时,我们看到 [M] GreaterThanOrEqualTo 逃逸的变体:

逃逸的变体:
================
1) ../src/ShippingCalculator.php:15    [M] GreaterThanOrEqualTo

--- Original
+++ New
@@ @@
-        if ($product->price >= self::FREE_SHIPPING_THRESHOLD) {
+        if ($product->price > self::FREE_SHIPPING_THRESHOLD) {

缺失的测试

很明显,我们 遗漏一个关键测试:我们没有断言当 $price 等于 免费送货阈值时会发生什么。

当将条件从 great-than-or-equals 变为 greater-than 比较时,Infection PHP 预计至少会有一个测试失败。因为我们的我们的测试套件没有失败,这个突变没有引起注意。

让我们通过添加 “forgotten” 边界测试来解决这个问题:

/** @test */
function product_ships_for_free_when_price_equals_threshold()
{
    $product = new Product(ShippingCalculator::FREE_SHIPPING_THRESHOLD);

    $this->assertTrue(ShippingCalculator::hasFreeShipping($product));
}

当我们再次运行 Infection,我们得到 100% MSI。这次没有逃脱的 突变体!

示例 2: 重定向

在我的 Laravel 项目中使用了突变测试后,我了解到突变测试会淘汰我本来不会编写的测试

请看以下示例,其中用户被重定向到特定页面以及来自 store() 控制器操作的「date」参数。

class AppointmentController
{
    public function store()
    {
        // 控制器逻辑

        return redirect(route('appointments.index', ['date' => $date]));
    }
}

在我对该控制器操作的测试中,我最初没有检查 store() 方法是否将用户重定向到提交日期的相应页面。因此,在运行突变测试时,以下突变已逃脱

3) .../app/Http/Controllers/AppointmentController.php:163    [M] ArrayItemRemoval

--- Original
+++ New
@@ @@
-        return redirect(route('appointments.index', ['date' => $date]));
+        return redirect(route('appointments.index', []));
     }
 }

虽然重定向中的这个 date 参数对于应用程序的用户登陆适当的页面来说是关键,但我没有针对这种行为进行测试。到目前为止,没有对特定重定向进行测试可能会被忽视。多亏了突变测试,我现在发现了这个差距,它迫使我添加一个涵盖这种情况的测试。

在 CI 中使用 Infection

可以针对新添加的代码或在构建之前在持续集成(CI)中运行突变测试。

您可以设置一个 --min-msi 评分选项,强制杀死一定比例的突变体 并逐渐增加该数量,以改进项目的测试套件。如果 MSI 分数低于要求,构建将失败

小型项目

对于小型项目,最简单的选择是运行 PHPUnit 测试,同时将测试覆盖率文件保存在 构建 目录中,然后将生成的报告文件直接提供给PHP

vendor/bin/phpunit --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml
vendor/bin/infection --threads=2 --coverage=build --min-msi=70

GitHub Actions

虽然 infection 很容易与任何 CI 设置集成,但我想强调一下新添加的与GitHub Actions的集成(在0.20版中)。现在可以在 PR 中直接在提交的代码中记录逃逸的突变体

Infection GitHub Annotations

要使用此功能,你可以将以下操作添加到您的 GitHub 操作工作流程中:

- name: Run Infection for added (A) and modified (M) files
  run: |
    git fetch --depth=1 origin $GITHUB_BASE_REF
    php vendor/bin/infection --threads=2 --git-diff-base=origin/$GITHUB_BASE_REF --git-diff-filter=AM --logger-github

你可以查看发布文档更多细节。

在大型项目中应用

如果你想为大型项目在 CI 中使用突变测试,请务必查看 Alejandro Celaya 写的 这篇文章

在他的博客文章中,他建议使用 phpdbg 来运行 Infection,从而显着提高性能。

结语

我希望这篇博文为开始使用突变测试提供一些见解和方向。总之,突变测试从你的源代码创建突变体,这些突变体针对你现有的测试套件运行。如果这些测试都没有失败,则表明这段源代码(突变体)经过了弱测试。这种方法有助于识别你当前测试套件中的差距以及无效或不必要的代码(或例如不必要的广泛方法可见性)。

尽管这篇博文仅涵盖了一些示例,但在变异测试中还有更多需要探索的内容:请务必查看 所有可用的变异器操作指南

缺点

虽然突变测试有很多优点,但请考虑以下可能的缺点和需要注意的事项。

  • 运行突变测试相对较慢。你可以通过允许并行测试来加快进程(设置 --threads > 1)。
  • 并非所有的变种人都必须被杀死。试图杀死所有突变体可能会导致代码和测试之间的紧密耦合。或者导致代码太严格并且不再灵活

更多资源

如果你想了解有关突变测试的更多信息,请务必查看有关突变测试(使用 Infection)的这些重要资源:

相关文章:

相关资料:

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://matthewdaly.co.uk/blog/2018/09/1...

译文地址:https://learnku.com/laravel/t/67927

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!