单元测试是最重要的开发阶段质量保障手段之一。高质高效地做好单元测试,也是众多开发工程师长期追求的目标。实际上,单元测试一直都是业界和学界的热门话题,已经有很多优秀的实践经验、技术方法、学术成果可以供我们参考。比如,我们可以使用单元测试用例自动生成技术[1],辅助我们完成单元测试设计,在某些情况下可以有效提高单元测试效率,降低单元测试成本。
1 Randoop
Randoop[2]是非常著名的单元测试自动生成工具,由Carlos Pacheco等人于ICSE '07提出。在SBST等国际测试工具竞赛中,Randoop经常作为其他工具的对比基准。
1.1 技术原理
Randoop使用的是反馈指向的随机测试生成技术[3],适用于JAVA代码。所谓“反馈指向”,即使用所生成用例的执行信息来调整用例生成的策略,本质上体现的是八类测试设计思想中“控制的思想”,大意如下图所示:
具体来说,Randoop为某一个待测类生成测试用例的技术要点大致如下:
① 认为测试用例就是对方法的调用序列;
② 首先随机选择待测类中的一个公共方法m,从已有的方法调用成功语句序列集合中随机选择一个序列s(初始为空)。为m随机选择输入参数(如果是原始类型,则从输入范围中随机选择;如果是对象类型,则从已有序列已生成的对象中随机选择);
③ 将m添加到s的底部,形成新的序列s';
④ 执行s',判断执行结果(根据contract或specification),如果违反预先定义的contract或specification,将s'加入调用失败语句序列集合;否则,将s'加入调用成功语句序列集合,使用s'作为测试步骤,使用被测方法返回值作为断言,生成回归测试用例。
1.2 使用方法
Randoop的使用方法也很简单。从官网下载安装包后解压,设置环境变量:
set RANDOOP_JAR=D:\cmd_app\randoop-4.3.3\randoop-all-4.3.3.jar
将要测试的类提前编译好,同时整理一个待测类的列表,写入一个txt文本文件中,比如:
myclasses.txt
hxzhou.ClassUnderTest |
将待测类的class文件和myclasses.txt都放在classpath下,之后就可以通过命令行启动单元测试的自动生成:
java -classpath .;%RANDOOP_JAR% randoop.main.Main gentests --classlist=myclasses.txt --time-limit=60
命令行执行之后,在当前目录下将会生成两个标准的JUnit测试集,其一是缺陷检出用例集,命名为ErrorTest*.java;其二是回归测试用例集,命名为RegressionTest*.java。缺陷检出用例集中的用例,执行结果违反了预先定义的contract或specification,说明有可能发现了待测类的缺陷,需要开发人员进一步调试确认;回归测试用例集中的用例则满足contract或specification定义的约束或目标,可作为后续版本的回归测试用例使用。
例如,针对如下待测类:
ClassUnderTest.java
package hxzhou;
public class ClassUnderTest {
public int Math(int a, int b) {
if (a == 73) {
System.out.println("运行1");
return 419;
} else if (b == 0) {
System.out.println("运行2");
return b;
} else {
System.out.println("运行3");
return a / b;
}
}
public int Test(int a, int b) {
int aa = this.Math(a, b);
System.out.println("结果" + aa);
return aa;
}
}
Randoop生成的用例如下:
@Test
public void test500() throws Throwable {
if (debug)
System.out.format("%n%s%n", "RegressionTest0.test500");
hxzhou.ClassUnderTest classUnderTest0 = new hxzhou.ClassUnderTest();
int int3 = classUnderTest0.Math((int) '#', (int) (short) 1);
int int6 = classUnderTest0.Math((int) (short) -1, (int) (short) 1);
int int9 = classUnderTest0.Test(10, 1);
int int12 = classUnderTest0.Math((int) (byte) 0, (-3));
int int15 = classUnderTest0.Test(26, (int) (byte) 1);
int int18 = classUnderTest0.Test((int) 'a', 2);
int int21 = classUnderTest0.Math(50, (int) (short) 0);
int int24 = classUnderTest0.Test(1, 1);
java.lang.Class<?> wildcardClass25 = classUnderTest0.getClass();
org.junit.Assert.assertTrue("'" + int3 + "' != '" + 35 + "'", int3 == 35);
org.junit.Assert.assertTrue("'" + int6 + "' != '" + (-1) + "'", int6 == (-1));
org.junit.Assert.assertTrue("'" + int9 + "' != '" + 10 + "'", int9 == 10);
org.junit.Assert.assertTrue("'" + int12 + "' != '" + 0 + "'", int12 == 0);
org.junit.Assert.assertTrue("'" + int15 + "' != '" + 26 + "'", int15 == 26);
org.junit.Assert.assertTrue("'" + int18 + "' != '" + 48 + "'", int18 == 48);
org.junit.Assert.assertTrue("'" + int21 + "' != '" + 0 + "'", int21 == 0);
org.junit.Assert.assertTrue("'" + int24 + "' != '" + 1 + "'", int24 == 1);
org.junit.Assert.assertNotNull(wildcardClass25);
}
2 EvoSuite
相比Randoop,EvoSuite[4]要年轻很多。自从在fse '11上横空出世以来,EvoSuite多次获得国际单元测试工具竞赛的最高分。
2.1 技术原理
EvoSuite的基本思想是,以测试集整体覆盖率为目标,藉由遗传算法开展搜索,并通过变异分析来控制断言规模[5]。EvoSuite生成测试用例的技术要点大致如下:
① 要搜索的最优解是一个用例集,用例集中的用例是被测对象的方法调用序列;
② 使用focused local search和动态符号执行方法,帮助生成方法调用的输入数据;
③ 用例集的交叉方式是随机交换用例。用例集的变异方式是添加新用例,或者对已有用例的语句、参数进行增删改;
④ 进化的目标适应度定义为与覆盖准则的差距。比如对于分支覆盖,目标适应度定义为所有分支距离之和(如果分支条件是x=42,那么当x=10时,分支距离就是32。String类型则用Levenshtein距离);
另外,EvoSuite在断言的生成上也非常讲究,很注重断言的质量及规模控制。其主要策略是:
(1)在原始程序和所有变异体分别上执行测试步骤,记录执行过程中所有可观测的变量值、对象属性值、对象(称其为观测量);
(2)分析原始程序和变异体观测量的差异,为每一个差异生成一个断言,形成一个断言集;
(3)优选该断言集的一个能够杀死最多变异体的最小子集。
2.2 使用方法
EvoSuite提供了命令行、Eclipse插件、Intellij IDEA插件、maven插件等使用方式。本文仅介绍maven插件方式。
将Randoop实验使用过的待测类ClassUnderTest包装为maven项目,进行EvoSuite的实验。在pom文件中配置EvoSuite依赖。添加如下dependency:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<dependency>
<groupId>org.evosuite</groupId>
<artifactId>evosuite-standalone-runtime</artifactId>
<version>1.0.6</version>
<scope>complie</scope>
/dependency>
添加如下plugin:
<plugin>
<groupId>org.evosuite.plugins</groupId>
<artifactId>evosuite-maven-plugin</artifactId>
<version>1.2.0</version>
</plugin>
命令行方式执行:
mvn compile evosuite:generate -Dcuts=hxzhou.ClassUnderTest evosuite:export -DtargetFolder=src/test/java
针对hxzhou.ClassUnderTest生成测试用例将保存在src/test/java下,命名为<包名>/<类名>_ESTest.java。生成的用例如下:
package hxzhou;
import org.junit.Test;
import static org.junit.Assert.*;
import hxzhou.ClassUnderTest;
import org.evosuite.runtime.EvoRunner;
import org.evosuite.runtime.EvoRunnerParameters;
import org.junit.runner.RunWith;
@RunWith(EvoRunner.class)
@EvoRunnerParameters(mockJVMNonDeterminism = true, useVFS = true, useVNET = true, resetStaticState = true, separateClassLoader = true, useJEE = true)
public class ClassUnderTest_ESTest extends ClassUnderTest_ESTest_scaffolding {
@Test(timeout = 4000)
public void test0() throws Throwable {
ClassUnderTest classUnderTest0 = new ClassUnderTest();
int int0 = classUnderTest0.Test(1415, 473);
assertEquals(2, int0);
}
@Test(timeout = 4000)
public void test1() throws Throwable {
ClassUnderTest classUnderTest0 = new ClassUnderTest();
int int0 = classUnderTest0.Math(1043, (-592));
assertEquals((-1), int0);
}
@Test(timeout = 4000)
public void test2() throws Throwable {
ClassUnderTest classUnderTest0 = new ClassUnderTest();
int int0 = classUnderTest0.Math((-536), 0);
assertEquals(0, int0);
}
@Test(timeout = 4000)
public void test3() throws Throwable {
ClassUnderTest classUnderTest0 = new ClassUnderTest();
int int0 = classUnderTest0.Test(473, 0);
assertEquals(0, int0);
}
@Test(timeout = 4000)
public void test4() throws Throwable {
ClassUnderTest classUnderTest0 = new ClassUnderTest();
int int0 = classUnderTest0.Math(0, 0);
assertEquals(419, int0);
}
@Test(timeout = 4000)
public void test5() throws Throwable {
ClassUnderTest classUnderTest0 = new ClassUnderTest();
int int0 = classUnderTest0.Test((-1918), 1);
assertEquals((-1918), int0);
}
}
3 LLM
在2024年这个时间节点,讨论单元测试用例自主生成,自然无法对基于LLM的方法避而不谈。
3.1 技术原理
无论采用哪一种LLM来生成单元测试用例,背后的原理都是一样的。简而言之,就是利用经过系统辨识得到的神经网络模型,将待测代码映射为单元测试代码。为了优化这一映射的效果,主要有两种辅助策略:
① 对模型进行垂直强化,用更丰富、更优质的训练数据,赋予模型更完整、更合理的映射规则。这种策略的主要问题是模型训练阶段的成本过高;
② 补充更多的输入信息,也就是采用所谓“提示词工程”,提高映射的准确性。典型的工作机制[9]如下图所示:
这种策略的主要问题是需要与LLM进行多次交互,效率会受到一定影响。
3.2 使用方法
最方便的办法就是利用LLM的对话窗口,以聊天的方式向LLM委托任务。比方说,针对hxzhou.ClassUnderTest类,利用智谱清言生成单元测试用例的对话过程如下:
你是一个测试专家。请给如下代码生成单元测试用例: package hxzhou; public class ClassUnderTest { public int Math(int a, int b) { if(a == 73) { System.out.println("运行1"); return 419; } else if(b == 0) { System.out.println("运行2"); return b; } else{ System.out.println("运行3"); return a / b; } } public int Test(int a, int b) { int aa = this.Math(a, b); System.out.println("结果"+ aa); return aa; } } |
基于您提供的ClassUnderTest类,以下是一些JUnit单元测试用例。这些测试用例旨在覆盖Math和Test方法的不同分支和边界条件。 package hxzhou; import static org.junit.Assert.assertEquals; import org.junit.Before; import org.junit.Test; public class ClassUnderTestTest { private ClassUnderTest classUnderTest; @Before public void setUp() { classUnderTest = new ClassUnderTest(); } // 测试 Math 方法,当 a 等于 73 时 @Test public void testMath_aEquals73() { assertEquals("当 a 等于 73 时,应该返回 419", 419, classUnderTest.Math(73, 100)); } // 测试 Math 方法,当 b 等于 0 时 @Test public void testMath_bEquals0() { assertEquals("当 b 等于 0 时,应该返回 0", 0, classUnderTest.Math(100, 0)); } // 测试 Math 方法,当 a 和 b 都是非零正数时 @Test public void testMath_positiveNumbers() { assertEquals("当 a 和 b 都是非零正数时,应该返回正确的除法结果", 2, classUnderTest.Math(10, 5)); } // 测试 Math 方法,当 a 和 b 都是负数时 @Test public void testMath_negativeNumbers() { assertEquals("当 a 和 b 都是负数时,应该返回正确的除法结果", 2, classUnderTest.Math(-10, -5)); } // 测试 Math 方法,当 a 是正数,b 是负数时 @Test public void testMath_positiveAndNegativeNumbers() { assertEquals("当 a 是正数,b 是负数时,应该返回正确的除法结果", -2, classUnderTest.Math(10, -5)); } // 测试 Math 方法,当 a 是负数,b 是正数时 @Test public void testMath_negativeAndPositiveNumbers() { assertEquals("当 a 是负数,b 是正数时,应该返回正确的除法结果", -2, classUnderTest.Math(-10, 5)); } // 测试 Test 方法,确保它正确地调用了 Math 方法并返回了正确的值 @Test public void testTest_correctMathCall() { assertEquals("Test 方法应该返回 Math 方法的正确结果", 419, classUnderTest.Test(73, 100)); } // 测试 Test 方法,当 Math 方法返回 0 时 @Test public void testTest_MathReturns0() { assertEquals("当 Math 方法返回 0 时,Test 方法也应该返回 0", 0, classUnderTest.Test(100, 0)); } // 测试 Test 方法,当 Math 方法执行正常除法时 @Test public void testTest_MathNormalDivision() { assertEquals("当 Math 方法执行正常除法时,Test 方法应该返回正确的除法结果", 2, classUnderTest.Test(10, 5)); } } 请注意,以上测试用例没有处理System.out.println的输出,因为通常在单元测试中,我们不关心标准输出。如果需要测试这些输出,您可能需要使用更高级的技术,比如使用System.out的替代输出流。 此外,测试用例没有覆盖所有可能的输入组合,尤其是对于Math方法中的除法操作,可能还需要考虑其他边界条件和异常情况。例如,您可能想要测试当a或b为Integer.MAX_VALUE或Integer.MIN_VALUE时的行为。 |
另一种常用方法是借助IDE插件等专用辅助工具。例如,使用通义灵码的VS Code插件,在开发过程中通过简单的界面交互,就可以为hxzhou.ClassUnderTest类生成单元测试用例,结果如下:
package hxzhou;
import java.util.*;
import java.math.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
public class ClassUnderTestTest {
private MockedStatic<System> mockedSystem;
@BeforeEach
public void setUp() {
mockedSystem = mockStatic(System.class);
}
@AfterEach
public void tearDown() {
mockedSystem.close();
}
@Test
public void Math_WhenAIs73_ShouldReturn419() {
ClassUnderTest classUnderTest = new ClassUnderTest();
int result = classUnderTest.Math(73, 1);
assertEquals(419, result);
}
@Test
public void Math_WhenBIs0_ShouldReturn0() {
ClassUnderTest classUnderTest = new ClassUnderTest();
int result = classUnderTest.Math(1, 0);
assertEquals(0, result);
}
@Test
public void Math_GeneralCase_ShouldReturnADividedByB() {
ClassUnderTest classUnderTest = new ClassUnderTest();
int result = classUnderTest.Math(10, 2);
assertEquals(5, result);
}
@Test
public void Test_WhenAIs73_ShouldReturn419AndPrintResult() {
ClassUnderTest classUnderTest = new ClassUnderTest();
mockedSystem.when(() -> System.out.println("结果419")).thenAnswer(invocation -> null);
int result = classUnderTest.Test(73, 1);
assertEquals(419, result);
verify(System.out, times(1)).println("结果419");
}
@Test
public void Test_WhenBIs0_ShouldReturn0AndPrintResult() {
ClassUnderTest classUnderTest = new ClassUnderTest();
mockedSystem.when(() -> System.out.println("结果0")).thenAnswer(invocation -> null);
int result = classUnderTest.Test(1, 0);
assertEquals(0, result);
verify(System.out, times(1)).println("结果0");
}
@Test
public void Test_GeneralCase_ShouldReturnADividedByBAndPrintResult() {
ClassUnderTest classUnderTest = new ClassUnderTest();
mockedSystem.when(() -> System.out.println("结果5")).thenAnswer(invocation -> null);
int result = classUnderTest.Test(10, 2);
assertEquals(5, result);
verify(System.out, times(1)).println("结果5");
}
}
3 几种技术的对比
学界已经有一些Randoop和EvoSuite的对比研究。比如四川大学的团队通过实验发现,Randoop用例在缺陷检出能力上高于EvoSuite用例,而EvoSuite用例的分支覆盖率则优于Randoop[6]。当然实验研究总会有很多局限性,孰优孰劣需要考虑各种前提条件。
其实从上文所述Randoop和EvoSuite的原理就可以看出,EvoSuite在生成用例的过程中有明确的优化策略,对覆盖率和用例集规模有比较充分的考虑,使用的技术相对先进,实施方法也更加便捷。但是Randoop提供了一个非常有用的特性,即支持自定义contract或specification。换言之,Randoop可以为用例的生成引入需求信息。比如,我们可以为待测类hxzhou.ClassUnderTest的Test方法定义如下的需求规格:
[
{
"operation":{
"classname":"hxzhou.ClassUnderTest",
"name":"Test",
"parameterTypes":["int","int"]
},
"identifiers":{
"parameters":["a","b"],
"receiverName":"receiver",
"returnName":"result"
},
"throws":[],
"post":[
{
"property":{
"condition":"result > 0",
"description":"received value is non-negative"
},
"description":"returns non-negative received value",
"guard":{
"condition":"true",
"description":""
}
}
],
"pre":[]
}
]
生成用例的命令行中需要添加--specifications选项:
java -classpath .;%RANDOOP_JAR% randoop.main.Main gentests --classlist=myclasses.txt --time-limit=60 --specifications=specification.json
在此基础上,Randoop将有条件使用真正的oracle作为断言,由此可以生成大量的缺陷检出用例。引入需求信息,是解决oracle问题的一个重要途径(当然这也同时意味着用例生成的成本增加了)。
如果说Randoop和EvoSuite是擅长逻辑和算法的理科生,那么LLM就是擅长感受和表达的文科生。从前文的例子可以看出,相比Randoop和EvoSuite,LLM生成的单元测试用例可读性很强,这是这种方法的主要优势。但是这种方法在生成用例的准确性方面存在很大的问题。有实证研究结果显示,ChatGPT 3.5根据被测代码生成的单元测试用例,能够编译通过的比例是42.1%,能够执行成功的比例只有24.8%[8]。即便我们辅以前文提及的两种优化策略,情况仍然难以得到根本的改观。这是由LLM自身的局限性导致的,因为LLM本质上是一种基于概率的token序列生成器,所以它无法掌握关于编码规则的深入知识。它并不知道,只有公有方法和属性,才能从类的外部进行访问;它也不知道,抽象类不能进行实例化。
4 结论与展望
如果采用MC/DC覆盖[7]等相对严格的测试充分准则,单元测试所需的成本可能会占到整个开发阶段的50%以上。在项目中适当引入单元测试用例自动生成技术,作为开发阶段质量保障的辅助手段,研发团队的负担将得到相当的缓解。当然,现阶段这项技术仍然存在很多局限,比如用例生成更多依靠随机手段、建立断言需要人工介入、生成用例准确性不高等等,这些都是学界和业界未来需要继续研究和改进的方向。
附:参考文献
[1]Anand S , Burke E K , Chen T Y , et al. An orchestrated survey of methodologies for automated software test case generation[J]. Journal of Systems & Software, 2013, 86(8):1978-2001.
[2]https://randoop.github.io/randoop/
[3]Pacheco C, Lahiri S K, Ernst M D, et al. Feedback-directed random test generation[C]//29th International Conference on Software Engineering (ICSE'07). IEEE, 2007: 75-84.
[4]https://www.evosuite.org/
[5]Fraser G, Arcuri A. Evosuite: automatic test suite generation for object-oriented software[C]//Proceedings of the 19th ACM SIGSOFT symposium and the 13th European conference on Foundations of software engineering. 2011: 416-419.
[6]郭丹. Randoop和Evosuite生成测试用例的变异检测能力分析[J]. 现代计算机, 2020(9):6.
[7]Hayhurst K J. A practical tutorial on modified condition/decision coverage[M]. DIANE Publishing, 2001.
[8]Yuan Z, Lou Y, Liu M, et al. No more manual tests? evaluating and improving chatgpt for unit test generation[J]. arXiv preprint arXiv:2305.04207, 2023.
[9]Chen Y, Hu Z, Zhi C, et al. Chatunitest: A framework for llm-based test generation[C]//Companion Proceedings of the 32nd ACM International Conference on the Foundations of Software Engineering. 2024: 572-576.