当测试通过,但代码仍然“损坏”时
\n\n想象一下这种情况:您为 C# 项目编写了单元测试,dotnet test 显示绿灯,代码覆盖率达到 85%。团队很满意,CI/CD 完美运行。但一个月后,生产环境中出现了一个本应由单元测试捕获的 bug。熟悉吗?
问题在于,代码覆盖率是一个具有欺骗性的指标。它显示了哪些代码行被触及,但并未检查测试本身的质量。您可能拥有 100% 的覆盖率,却没有一个有意义的断言。正是在这里,C# 中的变异测试登场了——这是一种通过“破坏”您的代码来检验您的测试捕获错误能力的方法。
\n\n在本文中,我们将探讨什么是变异测试,如何使用 Stryker.NET 将其集成到 .NET 项目中,以及为什么它对于任何严肃的 C# 开发者来说都是必备技能。
\n\n什么是变异测试?
\n\n变异测试是一种评估测试质量的方法,它基于对程序源代码进行微小、有意的更改(变异)。每个这样的修改后的代码版本被称为变异体。
\n\n逻辑很简单:
\n- \n
- 获取您的生产代码。 \n
- 自动生成变异体——例如,将运算符
+替换为-,将条件if (x > 5)改为if (x >= 5),将true改为false。 \n - 针对每个变异体运行您的单元测试。 \n
- 如果测试失败——变异体被“杀死”。这是一个好的测试。 \n
- 如果测试通过——变异体“存活”。这是一个不好的迹象:您的测试没有注意到逻辑上的变化。 \n
目标是实现高比例的变异体被杀死(通常 80% 以上)。这是您的测试在检验行为而不仅仅是“遍历”代码的真实保证。
\n\n为什么代码覆盖率不够?
\n\n考虑一个简单的 C# 示例。假设我们有一个检查数字是否为偶数的方法:
\n\npublic class MathUtils\n{\n public bool IsEven(int number)\n {\n return number % 2 == 0;\n }\n}\n\n以及一个具有 100% 覆盖率的“完美”测试:
\n\n[Test]\npublic void IsEven_ShouldReturnTrueForEvenNumber()\n{\n var utils = new MathUtils();\n var result = utils.IsEven(4);\n Assert.IsTrue(result);\n}\n\n测试通过,覆盖率 100%。但是,如果有人意外(或恶意)将方法改为 return number % 2 == 1; 会怎样?我们唯一的测试会失败吗?不会!它会检查数字 4(4 % 2 == 0,但现在方法返回 false),断言会失败——变异体将被杀死。一切顺利?等等。
如果代码改为 return number % 2 != 1; 呢?逻辑上是一样的,但变异测试会创建一个变异体:return number % 2 != 0;。我们使用数字 4 的测试仍然会通过(4 % 2 != 0 为 false,方法返回 false,但断言期望 true——测试会失败)。这里没问题。但想象一下,我们这样编写测试:
[Test]\npublic void IsEven_ShouldNotThrow()\n{\n var utils = new MathUtils();\n Assert.DoesNotThrow(() => utils.IsEven(4));\n}\n\n覆盖率仍然是 100%,但断言没有检查结果!现在任何变异体都会存活。变异测试会立即显示您的测试是空壳。
\n\nC# 中的变异测试工具
\n\n对于 .NET 来说,主要且实际上是标准的工具是 Stryker.NET。这是一个开源框架,可以轻松集成到任何 C# 项目中。它支持 xUnit、NUnit 和 MSTest。
\n\n其他工具(不太流行):
\n- \n
- VisualMutator — 一个过时的 Visual Studio 插件。 \n
- Nester