MuJava 是初学者(研究向)常常会去使用的一个工具,也是 Java 软件测试的一个老牌工具。用于为 Java 代码生成变异体和运行单元测试。但是此工具已经有十年没有更新了,这款软件可以说现在已经不能够支持对主流软件框架运行测试。但是使用它进行简单代码的测试还是可以的。
下面我将就我在研究中遇到的一些坑点和各位探讨一下。本人也是软件测试方面的一个小萌新,文章多有疏漏,尽请谅解。
一、MuJava 的配置
MuJava 需要运行在 JDK 1.8 (Java 8)环境下,仅实验性支持 JDK 9。而 MuJava 仅支持语言级别在 5 以下的代码运行测试。可以说是考古了,本人在学习时一开始也不知道它并不支持很多新的特性。
关于配置方面,我相信 CSDN 上也有其他教程,比如这篇:https://blog.csdn.net/wkw1125/article/details/51967630,我就不再啰嗦了。显然,MuJava 依赖 OpenJDK(解析代码 AST) 和 JUnit4(运行单元测试),你需要把它们的包添加到类路径或者使用系统环境变量以便于在 JDK 8 下 mujava 能够识别到依赖。
当你明白无论什么代码结构都可以使用 AST 去解释并生成中间代码时,你只需要略微知道各类突变是怎么实施的,你就可以编写一套属于自己的插桩工具。对 Java 代码进行解析的主流软件包我在以前的文章也介绍过(JavaParser)。
一般地,我们拥有 4 个目录,class 里面存放编译后的字节码(必须是 JDK8 或者更低版本编译的),result 存放生成的变异体和日志文件,src 存放源代码(.java 文件),testset 存放单元测试用例。
并且,我们已经编写好了用于运行的 bat 脚本:
比如 GenMutants.bat:
@echo off
java -Dfile.encoding=utf-8 mujava.gui.GenMutantsMain
pause
一定要注意配置好 mujava.config 配置文件(填写根路径的绝对地址),否则无法正常运行。
二、常见变异算子表
变异算子就是突变(变异)测试主要的特点,它通过人为向源代码引入缺陷,来辅助提升生成测试数据的覆盖效果和挖掘深层次的代码缺陷。下面结合 PiTest 和 MuJava 整理出常用的变异算子:
算术运算符替换(Arithmetic Operator Replacement, AOR)
- 将算术运算符(+, -, *, /, %)替换为其他算术运算符。
- 例如:将 a + b 变异为 a - b。
· 关系运算符替换(Relational Operator Replacement, ROR)
- 将关系运算符(<, <=, >, >=, ==, !=)替换为其他关系运算符。
- 例如:将 a < b 变异为 a <= b。
· 条件边界变异(Conditional Boundary Mutation, CBO)
- 修改条件语句的边界值。
- 例如:将 if (a < b) 变异为 if (a <= b)。
· 逻辑运算符替换(Logical Operator Replacement, LOR)
- 将逻辑运算符(&&, ||, !)替换为其他逻辑运算符。
- 例如:将 a && b 变异为 a || b。
· 返回值突变(Return Values Mutation, RVM)
- 修改方法的返回值。
- 例如:将 return x; 变异为 return x + 1;。
· 常量替换(Constant Replacement, CR)
- 将常量替换为其他值。
- 例如:将 const int x = 5 变异为 const int x = 0。
· 自增自减变异(Unary Operator Insertion, UOI)
- 添加或删除自增(++)或自减(--)运算符。
- 例如:将 a++ 变异为 a--。
· 语句删除(Statement Deletion, SDL)
- 删除代码中的某些语句。
- 例如:删除 if 条件中的一个分支。
· 空返回值替换(Void Method Call, VMC)
- 替换void方法调用。
- 例如:将 voidMethod() 变异为不调用该方法。
· 构造函数调用变异(Constructor Call, CC)
- 修改或替换构造函数调用。
- 例如:将 new ObjectA() 变异为 new ObjectB()。
· 方法调用变异(Method Call, MC)
- 修改或替换方法调用。
- 例如:将 object.methodA() 变异为 object.methodB()。
· 取反条件变异(Negate Conditionals, NC)
- 取反条件表达式。
- 例如:将 if (a < b) 变异为 if (!(a < b))。
· 二元逻辑运算符替换(Binary Logical Operator Replacement, BLOR)
- 将二元逻辑运算符替换为其他二元逻辑运算符。
- 例如:将 a & b 变异为 a | b。
· 移位运算符替换(Shift Operator Replacement, SOR)
- 将移位运算符替换为其他移位运算符。
- 例如:将 a << b 变异为 a >> b。
· 赋值运算符替换(Assignment Operator Replacement, AOR)
- 将赋值运算符替换为其他赋值运算符。
- 例如:将 a += b 变异为 a -= b。
在生成符合实际的突变并最大限度地减少生成等价变异体方面, PiTest 这个活跃的项目做得比 MuJava 好得多。PiTset 目前正采用自定义的过滤器过滤等价变异体以避免生成变异体过程中产生大量的冗余突变。而 MuJava 只按照不同变异算子的排列组合无序生成所有的突变,并且使用者不能够选择生成的质量。PiTset 针对在软件开发中真正容易出现错误的位置生成合理的突变,而不是盲目地生成根本不可能存在或者极其难以存在的突变。
注意:1)MuJava 会检查代码是否可以编译,如果没法编译,则在遍历 AST 时就会失败;
2)MuJava 容易产生冗余突变和等价突变体,容易产生死亡突变体(无法编译的);
3)对于新增加的突变体,MuJava 不支持;
4)对于一些代码,MuJava 在生成变异体时会出现失败,但日志不给出失败的具体原因。似乎在生成 AORB 等时,容易出现路径解析错误,这应该是一个 bug,可以观察到 null 出现在地址中;
三、MuJava 存在的兼容性问题
MuJava 官网给出的迷惑性解释容易造成误解,MuJava 虽然支持运行在 JDK 8 环境,但实际上对于高于语言级别 4 的代码特性都是不支持的。
1)生成 AORB 和 SDL 等少数几个变异时,有几率出现路径解析失败,导致无法生成最终的突变,MuJava 作了跳过处理,但是编号竟然不是修正的编号?;
2)对于 java 中带有 final 修饰的方法或者变量,如需纳入测试,需要手动删除该修饰符,因为此修饰符会阻止我们修改变量的值。
3)MuJava 会对循环体本身进行突变,在不需要此类变异体的情况下,需要检查变异日志(mutation_log)并予以删除;
4)代码中包含各种内部类、外部类的,均会导致 MuJava 编译失败,提示 [OJException] mujava.OpenJavaException: can't generate parse tree。(提示:所有显示该错误的,就是代表语言级别不支持,AST 解析失败)
唯一的解决方法,就是扁平化你需要测试的代码,可以尝试合并不同类中的方法,如果用到了实体类的创建,则修改创建过程,直接使用其中的静态方法。如果不能合并,则舍弃。对于使用自定义异常处理类的,修改为 Exception 使用默认异常处理。(但我个人觉得这样处理是不科学且耗时的)
5)Mujava 只支持基本的异常处理,对于 try-with-resource 类型的异常处理(try 带有需要结束时自动释放资源的初始化语句)不支持。下面代码使用了 try-with-resource 可以修改为一般的 try...catch:
JDK5 失败:
public class Demo {
public static void main(String[] args) {
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("E:\\in.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("E:\\out.txt")))) { //
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
或者 JDK9 也失败:
public class Demo {
public static void main(String[] args) throws FileNotFoundException {
BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("E:\\in.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("E:\\out.txt")));
try (bin;bout) {
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
必须修改为 JDK4 的:
public class Demo {
public static void main(String[] args) {
BufferedInputStream bin = null;
BufferedOutputStream bout = null;
try {
bin = new BufferedInputStream(new FileInputStream(new File("E:\\in.txt")));
bout = new BufferedOutputStream(new FileOutputStream(new File("E:\\out.txt")));
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}
finally {
if (bin != null) {
try {
bin.close();
}
catch (IOException e) {
e.printStackTrace();
}
finally {
if (bout != null) {
try {
bout.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
}
也就是说,try 后面不能有语句块,只能是 { 符号。
6)代码中使用增强 for 循环的也会导致 MuJava 解析失败;需要将增强 for 循环修改为传统的 for 循环,例如:
for(Type tp : tps) {...} // 增强 for 循环
for(int i = 0; i < tps 的大小; i++) { Type tp = tps[i]; ...} // 一般 for 循环
7)代码中使用缺省 for 循环,或者循环的自增变量放在循环体外面的均会导致插桩出错,需要严格按照标准书写习惯;
我们都知道 for 循环的条件由三个语句组成:
for(初始语句; 迭代条件; 自增步长) {
这三个语句都可以实施突变,但是由于编码习惯的不同,MuJava 设计者可能未考虑到代码可能是这样:
a.
int i;
for (i = 0; i < 10; i++) {
b.
int i = 0;
for (; i < 10; i++) {
c.
int i = 0;
for (;; i += 2) {
if (条件) {
break;
}
}
类似这样缺省的都会导致 MuJava 出错或者没法解析 AST。
8) MuJava 误以为 double 类型的数据或者变量前面也可以使用 "~",这会导致生成错误突变体;
9)MuJava 不支持 ArrayList 等 List,可能 Set 也不支持(没试过其他几个)?
10)SDL 语句删除突变会容易造成日志记录里面的行号错误。解析日志时需要格外注意。
...
由于 MuJava 存在的问题或者缺陷过多,这里就不一一列举了,反正各位只需要知道语言级别 5 及更高级别的代码它都不能正常处理就可以了。
四、浅谈如何选择待测试的类和方法
在编写变异测试(Mutation Testing)相关理论研究时,选择合适的测试类和方法对测试的全面性和有效性至关重要。以下是一些指导原则,可以帮助选择合适的测试类和待测试的方法。
1. 选择核心功能类
(1)核心功能:首先选择项目中实现核心功能的类。这些类通常包含最关键的逻辑,测试这些类能够最大限度地覆盖项目的主要功能。
(2)高复杂度类:选择那些复杂度较高的类(例如包含大量业务逻辑和条件分支的类),因为这些类更容易包含潜在的缺陷。
2. 考虑类的依赖关系
(1)依赖关系分析:使用依赖关系分析工具(例如JDepend、SonarQube)来识别依赖关系复杂的类。对这些类进行变异测试,可以有效检查它们与其他类的交互是否存在问题。(可以使用 Python 进行依赖关系分析,或者使用专业的工具,如 JDepend)
(2)高耦合度类:重点测试那些与其他类耦合度高的类,因为这些类的变动可能会影响到其他多个类。(类依赖图中出度较多的类或者包,需要注意忽略对内部包的依赖)
3. 多选择重要方法:
(1)识别边界条件处理方法
通过代码审查和分析测试用例,识别处理边界条件和异常(MuJava 在处理包含异常处理的代码时会报错)的代码部分。选择涉及输入验证、数据解析、异常处理的关键方法进行测试。
(2)定位常用公共方法
分析项目的依赖关系图和调用图,识别使用频率较高的公共方法。选择这些常用公共方法进行变异测试,确保其稳定性。(意思就是私有方法一般不选,如果要选,则需要修改为公共方法或者用反射特性擦除权限控制符)
4. 覆盖率分析
(1)代码覆盖率工具:使用代码覆盖率工具(例如JaCoCo、Cobertura)来分析哪些类和方法的测试覆盖率较低。选择这些覆盖率较低的类进行变异测试,可以提高测试覆盖率。
(2)分析覆盖率:使用变异分支测试程序进行随机化测试并统计覆盖率,重点选择难覆盖的变异分支。下表列出插桩数量和分布的需求:
指标 | 低级别 | 中级别 | 高级别 |
代码复杂度 | 复杂度 < 10: 每个条件分支都应插桩 | 复杂度 10-20: 选择性插桩主要分支和关键分支 | 复杂度 > 20: 重点插桩关键业务逻辑和高风险分支 |
代码行数 | 代码行数 < 100: 全面插桩,覆盖所有分支和条件 | 代码行数 100-500: 选择性插桩,重点放在关键逻辑和条件分支上 | 代码行数 > 500: 重点插桩关键业务逻辑、复杂条件分支和异常处理部分 |
业务逻辑重要性 | 辅助业务逻辑: 选择性插桩,重点覆盖关键分支和条件 | 核心业务逻辑: 全面插桩,确保每个分支和条件都得到测试 | 核心业务逻辑: 全面插桩,确保每个分支和条件都得到测试 |
风险评估 | 低风险代码: 最小插桩,只覆盖主要逻辑路径 | 中等风险代码: 选择性插桩,重点关注关键分支和条件 | 高风险代码: 全面插桩,确保所有可能的分支和条件都被测试到 |
从代码行数看,不同代码行数区间推荐的插桩行数如下:
筛选出的等价变异体数量:
可以看出,插桩行数在源代码行数的 1.5 ~ 2.5 倍左右。
5. 业务逻辑分析
根据项目文档和业务逻辑,识别实现核心业务逻辑的类。(这里推荐使用 IDEA的翻译插件快捷查看每个方法的文档注释,里面有方法的功能介绍和测试样例).
- 需求文档:查看项目的需求文档,找出实现核心需求的类。
- 用户故事:根据用户故事和用例,识别实现这些功能的类。
6. 变异算子选择
(1)常见变异算子:使用常见的变异算子(例如条件边界变化、算术操作符替换、逻辑操作符替换等)生成变异体。确保这些变异体能够模拟实际可能出现的代码错误。
(2)定制变异算子:根据项目特点和特定的业务逻辑,设计定制的变异算子。这些变异算子应该能够针对项目的特定场景进行变异,从而发现更多潜在缺陷。定制变异算子是根据具体项目或应用场景的需求,设计和实现的特定变异算子。这些算子可以模拟实际开发过程中可能出现的特定错误,超越通用变异算子的范畴,提供更有针对性的测试。以下是一些定制变异算子的示例及其应用场景:
1). 特定业务逻辑变异
在一个处理财务计算的系统中,可以设计定制的变异算子来处理常见的财务错误:
- 舍入错误算子:在涉及金钱计算的代码中,将舍入方式从四舍五入改为向下舍入。
- 税率变异算子:将预定义的税率值更改为不同的值(例如从5%变为4.5%)。
2). 特定输入处理变异
对于处理特定格式输入的系统,可以设计定制的变异算子来测试输入处理的健壮性:
- 日期格式变异算子:将日期输入格式从YYYY-MM-DD变为DD/MM/YYYY。
- 编码格式变异算子:将字符串输入的编码从UTF-8变为ISO-8859-1。
示例:银行交易系统
假设我们有一个银行交易系统,以下是可能的定制变异算子示例:
1. 舍入错误算子
// 原始代码
double amount = Math.round(transactionAmount * 100.0) / 100.0;
// 变异代码
double amount = Math.floor(transactionAmount * 100.0) / 100.0;
2. 税率变异算子
// 原始代码
double tax = amount * 0.05;
// 变异代码
double tax = amount * 0.045;
3. 日期格式变异算子
// 原始代码
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate date = LocalDate.parse(inputDate, formatter);
// 变异代码
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date = LocalDate.parse(inputDate, formatter);
通过定制变异算子,你可以更有针对性地测试项目中的特定逻辑,发现普通变异算子可能遗漏的问题,从而提高测试的有效性和覆盖率。
五、关于实际开发环境的建议
我个人觉得使用 PiTest、JavaParser、Faker、Mock、Soot 等工具,可能更加适应现今的实际软件审计和软件测试环节。
MuJava 工具可以作为学习研究使用,但也只能够用于一些简单代码的测试,而不能够很好地用于真正的软件测试。
Major 工具在一些方面可能做得比 MuJava 更好,但是它目前没有开源,下一篇我将介绍 Major 的使用细节。
本文出处链接:https://blog.csdn.net/qq_59075481/article/details/140999234。
本文发布于:2024.08.07,更新于:2024.08.07.