初探变异测试
发布时间:
最后更新:
变异测试,跟单元测试、集成测试等等东西一样,都属于软件测试技术。它的核心思想就是随机修改源码,然后运行测试,如果通过则说明你的测试不完善,或者存在多余代码。
最近偶然发现了这玩意,用了下感觉挺不错的,一下子在我的项目里发现了好几个问题。
比如这个(本文里的代码都是 TypeScript):
// 对应的测试代码,测试框架 Jest。
it.each([
"\ta;\tb",
"a; b;",
"a; b\t",
"a\t; b",
])("should split and trim the value %s", value => {
expect(split(value)).toStrictEqual(["a", "b"]);
});
这段代码的逻辑是把字符串按;
分割,去除前后空白并过滤掉空串。在变异测试中,将正则里的一个\s
改成了大写的\S
后测试仍然通过,这表明它的代码或测试有问题。
仔细一看就会发现,测试中漏掉了分号旁没有空白的情形,如果给测试数据再加一个"a;b"
则变异测试不再报错。
当然还有一种做法是改为value.split(";")
,因为后面已经用trim
去空白了。
传统的单元测试,对这种错误是无能为力的,它的覆盖率一直是 100%。 对此就需要另一种技术,能够对现有的测试进行测试,找出遗漏的用例和多余的代码,这就是变异测试。
基本理论 #
变异测试首次在 1971 年提出,最初是为了定位测试单元的弱点。这个理论是:如果一个变异被引入,同时出现的行为(通常是输出或测试结果)不受影响的话,就说明:变异代码从没有被执行过(产生了无效的代码)或者测试无法定位错误。
这些变异不是瞎改,而是基于良好定义的操作(变异算子),比如:
- 把
a + b
改为a - b
。 - 把
a > b
改为a < b
。 - 把某条
throw new Error()
语句删除。 - 把
if
语句的条件改为true
或false
。 - 把某个函数里的代码直接清空。
它不会做一些把关键字const
改成cosnt
之类的修改,因为语法检查就能够发现它。
有了变异之后,就开始运行现有的测试,这也意味着变异测试只能用于已经拥有测试的项目。在这里每一个变异之后的代码成为一个突变(Mutant),如果它不改变测试的结果就称为存活(Survived);反之称为杀死(Killed)。
那么被杀死的变体越多,就证明代码写得越好。通过配合覆盖率分析,可以计算出突变分数,作为评判测试质量的指标。
变异测试通常运行得很慢,因为一段代码中能替换的地方是非常多的,另外还要通过分析识别出等价的组合。而且对于每个突变,都要找到覆盖到它的测试并运行。
我的小项目有 74 个测试用例,单元测试用时 3.15s,而变异测试用时 3m 45s。
更多示例 #
除了最开始的之外,在我项目里还测试出了更多的问题:
这个数组的初始值与构造函数里的重复了,属于多余代码,变异测试通过更改数组中的元素发现了这个问题。
这个是测试写得不完善,忘了断言最终结果的行数,导致#
开头的行有没有被排除都一样。
无效的情况 #
由于程序本身是复杂的,同时突变也不会深入到第三方代码,所以突变测试即使报告了存活,也并不意味着有错误,必须具体分析。我的项目中也有一些例子:
这是个读取 JSON 文件的函数,因为 JSON.parse
对非字符串类型会调用toString
,所以去掉"uft8"
传递 Buffer 也正确。
但提前指定编码更好,这样能在读取到缓冲区后就解码,避免创建完整的 Buffer,所以这段代码是正确的,该变异可以忽略。
还有一类误报的情况就是防御性编程,例如下面的:
这段代码的作用是解析爬虫下载的文件,按行分割并作一些处理。在分割时,使用\n
遇到连续的空行会生成空白元素,而\n+
则不会。虽然目前抓取的文件没有这种情况,导致变异存活,但保不准未来会遇到,所以这个变异也不用管。
测试框架 #
变异测试虽然小众,但也有很久的年头了,主流语言基本都有工具可用:
- 本文用得是 Stryker,它同时有 JavaScript、C#、Scala 三种语言的实现。
- C++ 也有 Mull。
- JAVA 语言的话可以用 Pitest。
- Golang 的比较新 go-mutesting。
这里介绍下本文 stryker-js + Jest 的用法,相当简单只需三步。
首先装依赖:
npm i @stryker-mutator/core @stryker-mutator/jest-runner
然后创建配置文件stryker.conf.js
,内容如下
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
// 我的项目是 ESM,如果是 CJS 则用 module.exports =
export default {
// 包管理器,看你项目用的是啥。
packageManager: "pnpm",
plugins: ["@stryker-mutator/jest-runner"],
testRunner: "jest",
coverageAnalysis: "perTest",
// 生成 HTML 报告,控制台显示进度。
reporters: ["html", "progress"],
// 修改后的临时代码存放路径,测试完自动删除。
// 注意使用 Jest 的话该路径不能以点开头。
tempDirName: "stryker-tmp",
// Jest 如果使用 ESM 的话需要加这个参数。
testRunnerNodeArgs: ["--experimental-vm-modules"],
};
最后运行命令即可开测
stryker run
成功后的报告保存在 reports/mutation/mutation.html
。