Randoop、EvoSuite和LLM:单元测试用例自动生成的主流实用技术

单元测试是最重要的开发阶段质量保障手段之一。高质高效地做好单元测试,也是众多开发工程师长期追求的目标。实际上,单元测试一直都是业界和学界的热门话题,已经有很多优秀的实践经验、技术方法、学术成果可以供我们参考。比如,我们可以使用单元测试用例自动生成技术[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.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值