突变测试

突变测试是评估单元测试质量的一种方法。 对于突变测试, Stryker.NET 工具会自动在代码中执行突变,运行测试,并生成包含结果的详细报告。

示例测试方案

考虑一个示例 PriceCalculator.cs 类,其中包含一个 Calculate 计算价格的方法,并考虑折扣。

public class PriceCalculator
{
    public decimal CalculatePrice(decimal price, decimal discountPercent)
    {
        if (price <= 0)
        {
            throw new ArgumentException("Price must be greater than zero.");
        }

        if (discountPercent < 0 || discountPercent > 100)
        {
            throw new ArgumentException("Discount percent must be between 0 and 100.");
        }

        var discount = price * (discountPercent / 100);
        var discountedPrice = price - discount;

        return Math.Round(discountedPrice, 2);
    }
}

上述方法由以下单元测试涵盖:

[Fact]
public void ApplyDiscountCorrectly()
{
    decimal price = 100;
    decimal discountPercent = 10;

    var calculator = new PriceCalculator();

    var result = calculator.CalculatePrice(price, discountPercent);

    Assert.Equal(90.00m, result);
}

[Fact]
public void InvalidDiscountPercent_ShouldThrowException()
{
    var calculator = new PriceCalculator();

    Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, -1));
    Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, 101));
}

[Fact]
public void InvalidPrice_ShouldThrowException()
{
    var calculator = new PriceCalculator();

    Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(-10, 10));
}

前面的代码突出显示了两个项目,一个项目用于充当服务 PriceCalculator ,另一个项目是测试项目。

安装全局工具

首先,安装 Stryker.NET。 为此,需要执行以下命令:

dotnet tool install -g dotnet-stryker

若要运行 stryker,请从单元测试项目所在的目录中的命令行调用它:

dotnet stryker

测试运行后,控制台中会显示一个报表。

Stryker 控制台报告

Stryker.NET 将详细的 HTML 报告保存在 StrykerOutput 目录中。

Stryker 第一个报表

现在,考虑什么是变异体,什么是“幸存”和“被杀”的含义。 变异体是 Stryker 有意在你的代码中进行的小更改。 这个想法很简单:如果你的测试做得很好,它们就应该能捕捉到变化并使其失败。 如果它们仍然通过,则说明你的测试可能不够严格。

在我们的示例中,变异体是指将表达式 price <= 0 替换为 price < 0,例如,然后运行单元测试。

Stryker 支持多种类型的突变:

类型 DESCRIPTION
等效 等效运算符用于将运算符替换为其等效运算符。 例如,x < y 将变为 x <= y
算术 算术运算符用于将一个算术运算符替换为相应的等效运算符。 例如,x + y 将变为 x - y
字符串 字符串运算符用于将字符串替换为其等效项。 例如,"text" 将变为 ""
逻辑 逻辑运算符用于将逻辑运算符替换为其等效运算符。 例如,x && y 将变为 x \|\| y

有关其他突变类型,请参阅 Stryker.NET:突变 文档。

解释突变测试结果

运行 Stryker.NET 后,你将收到一份报告,该报告将变种分类为“被杀死”“幸存”“超时”。 下面介绍如何解释和处理这些结果:

  • 已终止:这些更改是测试成功捕获的更改。 大量被“杀死”的变异体表示您的测试套件能够有效地检测逻辑错误。
  • 幸存下来:测试未捕获这些更改。 查看它们以识别测试覆盖率的不足或断言过于薄弱的地方。 专注于添加目标单元测试,如果变异体是真实的,则会失败。
  • 超时:这些突变导致代码挂起或超过允许的时间。 这可能发生在无限循环或未优化的路径中。 调查代码逻辑,或根据需要增加超时阈值。

注释

不要追求达到 100% 的突变分数。 而是专注于高风险或关键业务领域,其中未检测到的错误可能导致最高成本。

将突变测试添加到 CI/CD 工作流

可以将突变测试无缝集成到持续集成和交付工作流中。 例如,可以将 Stryker.NET 配置为在 Azure Pipelines 或 GitHub Actions 设置中运行,从而允许在自动化测试过程中强制实施质量阈值。

自定义

除了为管道设置阈值外,Stryker.NET 还提供针对每个项目需求的不同配置的可能性。 可以使用 stryker-config.json 文件自定义行为。

{
  "stryker-config": {
    "ignore-mutations": [
      "string",
      "logical"
    ],
    "ignore-methods": [
      "*Logs"
    ],
    "mutate": [
      "!**/Migrations/*",
      "!**/*.Designer.cs"
    ]
  }
}
  • ignore-mutations:要从测试中排除的突变类型,因为它们与应用程序逻辑无关或干扰。 它们将显示在你的报告中。Ignored
  • ignore-methods:可以使用此功能来根据其签名跳过整个方法。 这些内容也会在报表中显示为 Ignored。 在前面的示例中,将忽略以“Logs”结尾的所有方法。
  • 变异:如果没有此选项,Stryker 将尝试变异项目中的所有文件。 因此,可以忽略文件或整个文件夹。 在前面的示例中,所有 Migrations 文件夹内的内容和所有 .Designer.cs 文件(这些文件通常是自动生成的)都被忽略。

有关详细信息,请参阅 Stryker:配置

增量改进

如果在更改代码后,单元测试成功通过,那么这些测试就不够严谨,并且变异体幸存下来。 突变测试后,5种变异体幸存下来。

让我们为边界值添加测试数据,并再次运行突变测试。

[Fact]
public void InvalidPrice_ShouldThrowException()
{
    var calculator = new PriceCalculator();

    // changed price from -10 to 0
    Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(0, 10));
}

[Fact] // Added test for 0 and 100 discount
public void NoExceptionForZeroAnd100Discount()
{
    var calculator = new PriceCalculator();

    var exceptionWhen0 = Record.Exception(() => calculator.CalculatePrice(100, 0));
    var exceptionWhen100 = Record.Exception(() => calculator.CalculatePrice(100, 100));

    Assert.Null(exceptionWhen0);
    Assert.Null(exceptionWhen100);
}

Stryker 第二份报告

如你所见,更正等价变异体后,我们只剩下字符串变异,通过检查异常消息的文本,我们可以轻松地“消除”它们。

[Fact]
public void InvalidDiscountPercent_ShouldThrowExceptionWithCorrectMessage()
{
    var calculator = new PriceCalculator();

    var ex1 = Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, -1));
    Assert.Equal("Discount percent must be between 0 and 100.", ex1.Message);

    var ex2 = Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(100, 101));
    Assert.Equal("Discount percent must be between 0 and 100.", ex2.Message);
}

[Fact]
public void InvalidPrice_ShouldThrowExceptionWithCorrectMessage()
{
    var calculator = new PriceCalculator();

    var ex = Assert.Throws<ArgumentException>(() => calculator.CalculatePrice(0, 10));
    Assert.Equal("Price must be greater than zero.", ex.Message);
}

Stryker 最终报告

突变测试有助于找到改进使测试更可靠的机会。 它强制你不仅检查“理想路径”,还要检查复杂的边界情况,从而降低在生产环境中出现 bug 的可能性。