PHP Mutation Testing:用 Infection 评估单元测试的真实覆盖率
各位同学,今天我们来聊聊一个非常重要的软件测试技术:Mutation Testing,中文叫做变异测试。 我们将会重点介绍如何使用 Infection 这个工具,在 PHP 项目中进行变异测试,并以此来评估我们的单元测试的真实覆盖率,发现潜在的测试盲点。
为什么我们需要 Mutation Testing?
仅仅依靠代码覆盖率(例如行覆盖率、分支覆盖率)并不能完全保证测试的充分性。 想象一下,你写了一个测试,它覆盖了某段代码的每一行,并且满足了所有的分支条件。但是,如果这个测试只是简单地断言某个变量的值不为 null,即使代码逻辑完全错误,测试仍然可以通过。这就是代码覆盖率的局限性。
Mutation Testing 则提供了一种更强大的方法来评估测试质量。它的核心思想是:
- 创建变异体 (Mutants): 对原始代码进行微小的修改,例如修改运算符、改变变量值、删除语句等。每一个修改后的版本被称为一个变异体。
- 运行测试: 对每一个变异体运行现有的单元测试。
- 判断变异体是否被杀死 (Killed): 如果某个变异体导致至少一个测试失败,那么我们说这个变异体被“杀死”了。
- 计算 Mutation Score: Mutation Score = (被杀死的变异体数量 / 总变异体数量) * 100%。 Mutation Score 越高,说明测试套件的质量越高。
如果一个变异体没有被杀死,这意味着现有的测试没有能够检测到这个微小的代码改变。 这就暴露了测试中的一个潜在盲点,我们需要改进测试来覆盖这个盲点。
Infection:PHP 变异测试的利器
Infection 是一个流行的 PHP 变异测试工具。它易于使用,并且与 PHPUnit 等流行的测试框架很好地集成。
安装 Infection
使用 Composer 安装 Infection 非常简单:
composer require infection/infection --dev
配置 Infection
Infection 的配置文件通常位于项目根目录下的 infection.php 或 infection.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,我们可以编写更高质量的测试,并最终构建更健壮的软件。 记住,关注存活的变异体,并逐步改进测试,是提高代码质量的关键。