PHP Mutation Testing(变异测试)策略:利用Infection评估单元测试的实际覆盖率

PHP Mutation Testing:用 Infection 评估单元测试的真实覆盖率

各位同学,今天我们来聊聊一个非常重要的软件测试技术:Mutation Testing,中文叫做变异测试。 我们将会重点介绍如何使用 Infection 这个工具,在 PHP 项目中进行变异测试,并以此来评估我们的单元测试的真实覆盖率,发现潜在的测试盲点。

为什么我们需要 Mutation Testing?

仅仅依靠代码覆盖率(例如行覆盖率、分支覆盖率)并不能完全保证测试的充分性。 想象一下,你写了一个测试,它覆盖了某段代码的每一行,并且满足了所有的分支条件。但是,如果这个测试只是简单地断言某个变量的值不为 null,即使代码逻辑完全错误,测试仍然可以通过。这就是代码覆盖率的局限性。

Mutation Testing 则提供了一种更强大的方法来评估测试质量。它的核心思想是:

  1. 创建变异体 (Mutants): 对原始代码进行微小的修改,例如修改运算符、改变变量值、删除语句等。每一个修改后的版本被称为一个变异体。
  2. 运行测试: 对每一个变异体运行现有的单元测试。
  3. 判断变异体是否被杀死 (Killed): 如果某个变异体导致至少一个测试失败,那么我们说这个变异体被“杀死”了。
  4. 计算 Mutation Score: Mutation Score = (被杀死的变异体数量 / 总变异体数量) * 100%。 Mutation Score 越高,说明测试套件的质量越高。

如果一个变异体没有被杀死,这意味着现有的测试没有能够检测到这个微小的代码改变。 这就暴露了测试中的一个潜在盲点,我们需要改进测试来覆盖这个盲点。

Infection:PHP 变异测试的利器

Infection 是一个流行的 PHP 变异测试工具。它易于使用,并且与 PHPUnit 等流行的测试框架很好地集成。

安装 Infection

使用 Composer 安装 Infection 非常简单:

composer require infection/infection --dev

配置 Infection

Infection 的配置文件通常位于项目根目录下的 infection.phpinfection.json。 这里是一个简单的 infection.php 示例:

<?php

declare(strict_types=1);

use InfectionConfigurationConfiguration;
use InfectionConfigurationFileLocator;
use InfectionConfigurationSchemaSchemaConfiguration;
use SymfonyComponentFinderFinder;

$configDir = __DIR__;

return Configuration::stub(
    new FileLocator([$configDir]),
    SchemaConfiguration::fromArray([
        'timeout' => 10,
        'source' => [
            'directories' => [
                'src'
            ],
        ],
        'logs' => [
            'summary' => 'infection.log',
            'text' => 'infection.txt',
            'badge' => 'infection.svg',
        ],
        'mutators' => [
            'Arithmetic/Plus',
            'Boolean/TrueValue',
            'ConditionalBoundary',
            'Decrement',
            'Increment',
            'Integer/One',
            'MethodCallRemoval',
            'NewObject',
            'ReturnVoid',
            'VoidReturn',
            'NegateConditionals',
        ],
        'paths' => [
            'src'
        ],
        'testFramework' => 'phpunit',
        'testFrameworkOptions' => '--configuration phpunit.xml', // optional
    ])
);

这个配置文件的关键部分:

  • source.directories: 指定要进行变异测试的源代码目录。
  • logs: 配置日志文件的位置。
  • mutators: 指定要使用的变异算子。变异算子定义了代码如何被修改。 例如,Arithmetic/Plus 算子会将加法运算符 + 替换为减法运算符 -
  • testFramework: 指定使用的测试框架,这里是 PHPUnit。
  • testFrameworkOptions: 指定传递给测试框架的选项,例如 PHPUnit 的配置文件。

运行 Infection

在项目根目录下运行以下命令来启动 Infection:

./vendor/bin/infection

Infection 会分析你的代码,生成变异体,并运行你的单元测试。 运行完成后,它会生成一份详细的报告,告诉你哪些变异体被杀死了,哪些变异体存活了下来。

理解 Infection 的报告

Infection 的报告会告诉你每个变异体的状态:

  • Killed: 变异体被测试杀死了。这表示你的测试能够检测到这个代码改变。
  • Escaped: 变异体存活了下来。这表示你的测试没有能够检测到这个代码改变。 这需要你改进测试。
  • Timed Out: 变异体运行超时。这可能意味着变异体导致了无限循环或其他性能问题。
  • Ignored: 变异体被忽略了。这可能是因为你在配置文件中显式地忽略了某些变异体,或者 Infection 认为某些变异体不值得测试。
  • Msi: Mutation Score Indicator, 显示整体的 Mutation Score。

改进测试

如果 Infection 报告中有存活的变异体 (Escaped),你需要分析这些变异体,找出为什么测试没有能够检测到它们。 然后,你需要编写新的测试,或者修改现有的测试,以便能够杀死这些变异体。

示例:使用 Infection 改进测试

我们通过一个简单的例子来演示如何使用 Infection 来改进测试。

假设我们有一个简单的计算器类:

<?php

namespace App;

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

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

我们有如下的单元测试:

<?php

namespace TestsApp;

use AppCalculator;
use PHPUnitFrameworkTestCase;

class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        $calculator = new Calculator();
        $this->assertEquals(5, $calculator->add(2, 3));
    }

    public function testSubtract(): void
    {
        $calculator = new Calculator();
        $this->assertEquals(1, $calculator->subtract(3, 2));
    }
}

现在,我们运行 Infection。 假设 infection.php 文件配置如下:

<?php

declare(strict_types=1);

use InfectionConfigurationConfiguration;
use InfectionConfigurationFileLocator;
use InfectionConfigurationSchemaSchemaConfiguration;
use SymfonyComponentFinderFinder;

$configDir = __DIR__;

return Configuration::stub(
    new FileLocator([$configDir]),
    SchemaConfiguration::fromArray([
        'timeout' => 10,
        'source' => [
            'directories' => [
                'src'
            ],
        ],
        'logs' => [
            'summary' => 'infection.log',
            'text' => 'infection.txt',
            'badge' => 'infection.svg',
        ],
        'mutators' => [
            'Arithmetic/Plus',
            'Arithmetic/Minus',
        ],
        'paths' => [
            'src'
        ],
        'testFramework' => 'phpunit',
        'testFrameworkOptions' => '--configuration phpunit.xml', // optional
    ])
);

Infection 运行后,可能会报告一个存活的变异体: Arithmetic/Plus 算子将 Calculator::add() 方法中的 + 替换为 -。这意味着我们的 testAdd() 测试没有能够检测到这个错误。

 ------ Summary ------

   Files total: 1
        Lines: 10

      Mutators: 2
     Generated: 2
        Killed: 1
      Escaped: 1
     Timed Out: 0
      Ignored: 0
       Skipped: 0
      Errors: 0

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

为了改进测试,我们可以添加一个断言,检查 add() 方法在输入负数时的行为。

<?php

namespace TestsApp;

use AppCalculator;
use PHPUnitFrameworkTestCase;

class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        $calculator = new Calculator();
        $this->assertEquals(5, $calculator->add(2, 3));
        $this->assertEquals(-1, $calculator->add(-2, 1)); // Added assertion
    }

    public function testSubtract(): void
    {
        $calculator = new Calculator();
        $this->assertEquals(1, $calculator->subtract(3, 2));
    }
}

现在,再次运行 Infection,所有变异体应该都被杀死了。Mutation Score 将会是 100%。

变异算子 (Mutators)

Infection 提供了很多内置的变异算子。常用的变异算子包括:

变异算子 描述 示例
Arithmetic/Plus 将加法运算符 + 替换为减法运算符 - $a + $b -> $a - $b
Arithmetic/Minus 将减法运算符 - 替换为加法运算符 + $a - $b -> $a + $b
Boolean/TrueValue true 替换为 false return true; -> return false;
Boolean/FalseValue false 替换为 true return false; -> return true;
ConditionalBoundary 修改条件语句的边界值,例如将 > 替换为 >=< 替换为 <= if ($a > 10) -> if ($a >= 10)
Decrement 将递增运算符 -- 替换为 ++ $i-- -> $i++
Increment 将递减运算符 ++ 替换为 -- $i++ -> $i--
Integer/One 将整数替换为 1 $a = 5; -> $a = 1;
MethodCallRemoval 移除方法调用 $object->method(); -> “
NewObject new 关键字替换为 null $object = new MyClass(); -> $object = null;
ReturnVoid 将返回值替换为 void (仅适用于有返回值的方法) return $value; -> return;
VoidReturn 在没有返回值的方法中添加 return; 语句 public function myMethod() { ... } -> public function myMethod() { return; ... }
NegateConditionals 将条件表达式取反 (例如 == 替换为 !=> 替换为 <=) if ($a == $b) -> if ($a != $b)
ArrayItemRemoval 移除数组中的元素 $arr = [1, 2, 3]; -> $arr = [1, 2];
ArrayOneItem 将数组替换为只包含一个元素的数组 $arr = [1, 2, 3]; -> $arr = [1];
RemoveVoidMethodCall 移除对 void 方法的调用 $obj->voidMethod(); -> “

你可以根据项目的需要,选择合适的变异算子。

最佳实践

  • 从小处着手: 首先从核心的业务逻辑开始进行变异测试。
  • 逐步增加变异算子: 不要一次性使用所有的变异算子。 逐步增加变异算子,可以更容易地发现和修复问题。
  • 持续集成: 将 Mutation Testing 集成到持续集成流程中,可以确保代码质量的持续提高。
  • 关注存活的变异体: 优先关注存活的变异体 (Escaped),这些变异体暴露了测试中的潜在盲点。
  • 不要追求 100% 的 Mutation Score: 有些变异体可能很难被杀死,或者杀死它们的成本太高。不要盲目追求 100% 的 Mutation Score。 更重要的是,要确保核心业务逻辑的测试是充分的。
  • 忽略无法修复的变异体: 在某些情况下,某些变异体可能永远无法修复,或者修复的代价过高。可以使用 @infection-ignore 注释来忽略这些变异体。

    <?php
    
    namespace App;
    
    class MyClass
    {
        public function doSomething(): void
        {
            // @infection-ignore-line RemoveVoidMethodCall
            $this->someVoidMethod();
        }
    
        private function someVoidMethod(): void
        {
            // ...
        }
    }

    你也可以在 infection.php 文件中忽略某些目录或文件:

    <?php
    
    declare(strict_types=1);
    
    use InfectionConfigurationConfiguration;
    use InfectionConfigurationFileLocator;
    use InfectionConfigurationSchemaSchemaConfiguration;
    use SymfonyComponentFinderFinder;
    
    $configDir = __DIR__;
    
    return Configuration::stub(
        new FileLocator([$configDir]),
        SchemaConfiguration::fromArray([
            'timeout' => 10,
            'source' => [
                'directories' => [
                    'src'
                ],
                'excludes' => [
                    'src/SomeDirectoryToExclude'
                ],
            ],
            'logs' => [
                'summary' => 'infection.log',
                'text' => 'infection.txt',
                'badge' => 'infection.svg',
            ],
            'mutators' => [
                'Arithmetic/Plus',
                'Boolean/TrueValue',
                'ConditionalBoundary',
                'Decrement',
                'Increment',
                'Integer/One',
                'MethodCallRemoval',
                'NewObject',
                'ReturnVoid',
                'VoidReturn',
                'NegateConditionals',
            ],
            'paths' => [
                'src'
            ],
            'testFramework' => 'phpunit',
            'testFrameworkOptions' => '--configuration phpunit.xml', // optional
            'ignore' => [
                'src/SomeFileToIgnore.php',
            ],
        ])
    );

总结一下

Mutation Testing 是一种强大的测试技术,可以帮助我们评估单元测试的真实覆盖率,发现潜在的测试盲点。 Infection 是一个易于使用的 PHP 变异测试工具,可以与 PHPUnit 等流行的测试框架很好地集成。 通过使用 Infection,我们可以编写更高质量的测试,并最终构建更健壮的软件。 记住,关注存活的变异体,并逐步改进测试,是提高代码质量的关键。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注