Can Large Language Models Write Good Property-Based Tests?
China Vasudev VikramCarnegie Mellon UniversityPittsburgh, PA, USA vasumv@cmu.edu
Caroline LemieuxUniversity of British ColumbiaVancouver, BC, Canada clemieux@cs.ubc.ca
Rohan PadhyeCarnegie Mellon UniversityPittsburgh, PA, USA rohanpadhye@cmu.edu
引用
Vikram V, Lemieux C, Padhye R. Can large language models write good property-based tests?[J]. arXiv preprint arXiv:2307.04346, 2023.
论文:https://arxiv.org/pdf/2307.04346v1
摘要
基于属性的测试(Property-based testing,PBT)虽然在软件测试研究社区中已是成熟的技术,但在实际软件中仍相对较少使用。编写基于属性的测试的困难点包括实现多样的随机输入生成器以及思考有意义的测试属性。然而,开发者更倾向于编写文档;有大量库的API文档可以作为基于属性测试的自然语言规范。由于大型语言模型(LLMs)最近在各种编程任务中展现了潜力,我们探索了使用LLMs来生成基于属性测试的可能性。我们称我们的方法为PBT-GPT,并提出了三种不同的策略来提示LLM生成PBT。我们描述了PBT-GPT的各种失败模式,并详细介绍了一种自动生成基于性质测试的评估方法。
1 引言
基于属性的测试(PBT)是一种强大的测试技术,用于通过随机生成输入来测试程序的属性。与传统的依赖于手动编写的测试用例和示例的测试方法不同,PBT 使用自动生成广泛的输入,可以调用一组不同的程序行为。PBT首先由 Haskell 中的 Quickcheck库推广,并用于在各种现实世界软件中找到大量错误。在PBT之上构建了其他技术,并展示了它们在为软件提供更强的测试方面的潜力。
尽管基于属性的测试(PBT)在研究界已经证明了其成果和影响,但在开源和工业软件开发人员中并没有被广泛采用。使用开源洞察依赖数据集,我们发现,在 18 万个 PyPI 软件包中,只有 222 个软件包将 Python PBT 库 Hypothesis 列为依赖项,尽管这是一个非常受欢迎的项目(GitHub 上有超过 6.7k 星)。Harrison 等人进行了一系列访谈,并详细描述了专业开发者在尝试在其软件中使用 PBT 时所面临的一系列挑战。开发者报告了在
(1)为输入编写随机数据生成器
(2) 阐述和实施能够有意义地测试他们的代码的属性
这两个方面遇到的困难。此外,他们描述了“关键质量问题”,即 PBT 在软件行业仍相对较不为人知和不受欢迎。
最近,使用预训练的大型语言模型(LLMs)进行代码生成变得越来越流行。LLMs 在将自然语言规范和指令翻译成具体代码方面表现得非常有效。此外,LLMs 还显示出改进现有自动单元测试生成技术的潜力,甚至可以从头生成单元测试。在本文中,我们研究了使用 LLMs 通过提供 API 文档来生成基于属性的测试的潜力。我们认为,API方法的文档能够帮助 LLM 生成该方法的随机输入逻辑并导出有意义的结果属性进行检查。
图1 numpy.cumsum API方法的截断Numpy文档
我们可以在图 2 中看到利用 LLMs 进行基于属性测试(PBT)的潜力,图中展示了当提供图 1 中的文档时,LLM 生成的针对 numpy.cumsum 方法的基于属性的测试。首先,在第 10 至 17 行,生成了用于输入参数 a 和 axis 的随机值的逻辑。然后,在第 20 行对这些参数调用了 cumsum 方法。最后,在第 25 至 37 行包含了对输出 cumsum_result 的属性断言。我们特别注意到这些属性断言与图 1 中 API 文档中的自然语言描述相匹配。文档指定了“结果与 a 的大小相同”,这直接转化为第 30 行的断言。同样,规范指定“如果轴不为 None 或者 a 为 1 维数组,则结果与 a 具有相同的形状”,这在第 25 至 26 行被条件性地作为一个断言进行检查。最后,第 35 至 37 行所展示的属性断言检查结果的最后一个元素是否等于 np.sum(a),如果数组不是浮点类型的话。该断言将文档中的注释部分的信息转化为一个有用的属性进行检查。虽然并非完美的基于属性的测试,但这个例子展示了LLMs具有编写生成随机输入逻辑并从API文档中推导出有意义的属性断言的能力。
在本文中,我们提出了一种应用 LLMs 生成基于属性测试(PBT)的方法;我们将其称为 PBT-GPT,因为我们选择使用 GPT-4 作为我们实现中的基础模型。我们概述了 PBT-GPT 的三种不同方法,分别是独立生成、连续生成和共同生成生成器和属性。在初步探索使用 LLMs 生成 PBT 时,我们注意到 PBT 有各种不同的失败模式。我们对这些失败模式进行了分类,并提出了一种评估方法来评估 (1) 生成器的质量和 (2) 属性的质量。我们注意到这种方法可以应用于不同形式的自动 PBT 生成。我们报告了使用我们提出的评估方法在三个 Python 库 API 上的初步结果。
图2 基于GPT-4生成的numpy.cumsum属性测试
2 背景
2.1 基于属性的测试
基于属性的测试旨在通过生成大量随机输入并检查程序的相应输出是否符合一组期望的属性来概率性地测试程序。一个基于属性的测试可以定义如下:给定待测试函数f,一个输入空间X和一个属性P,我们希望验证∀x ∈ X: P(x, f(x))。通常,P包括一系列组件属性,即P = p1 ∧ p2 ∧ ... ∧ pk。在实践中,我们无法枚举X中的所有输入。因此,我们编写一个生成器函数gen,用于在X中生成随机输入,即x = gen()。然后,我们编写一个参数化测试T :: X → {true, false},返回P(x, f(x))。属性在许多随机生成的值x上进行检查,属性违规将导致测试失败。尽管基于属性的测试无法证明属性违规的不存在,但相比单元测试中常见的测试特定硬编码输入和输出,它仍然是一种改进。基于属性的测试因此是一种模糊测试形式。
图3 Python 排序函数假设中基于示例属性的测试来对列表进行排序
接下来,我们描述我们的基于属性测试的正式定义如何转换为 Hypothesis(一个流行的 Python 基于属性测试库)中的基于属性测试代码。假设我们待测试的函数是 Python 的 sorted 函数,它接受一个列表作为输入并返回排序后的版本。我们希望测试排序后的列表的元素是单调递增的属性。首先,我们必须编写我们的生成器 gen,从列表的输入空间中抽取一个输入。图 3 中第 4–8 行的 generate_lists 函数即是这种生成器的示例。Hypothesis 具备一套内建的各种数据结构的采样策略。第 6 行的 lists 和 integers 策略用于随机生成并返回一个大小≥1的 Python 整数列表。
接下来,我们需要编写参数化测试 T,该测试接受一个输入 x 并返回 P(x, f(x)),其中 f 是 sorted 函数,P 是排序后列表元素单调递增的属性。这样的一个参数化测试的示例是图 3 中的 test_sorted_separate 函数。在第 12 行,sorted 函数在输入 lst 上被调用。然后,第 15–16 行通过使用断言语句检查属性 P,即排序后的列表元素是否递增。如果 P(x, f(x)) 为真,即没有断言失败,则 T 将返回 true。通常,如果 P 包含多个组件属性,可以将其表示为 T 中的断言语句列表。
最后,为完成基于属性的测试,我们必须调用我们的生成器来抽取随机输入,并在该输入上调用参数化测试。使用 Hypothesis 中的 @given 装饰器来实现这一步骤,就像第 12 行所示。该装饰器指定了我们参数化测试 test_sorted_separate 的输入 lst 应使用 generate_lists 作为生成器。
另一种编写 Hypothesis 测试的风格是将生成器包含在参数化测试内部,就像图 3 中的 test_sorted_combined 函数所示。在第 20 行,@given(data()) 装饰器提供一个对象,可用于抽取未指定类型的随机输入数据。第 22–24 行充当生成器,使用与 generate_lists 函数相同的逻辑生成最小长度为 1 的随机整数列表。第 25–27 行使用方法调用和断言语句,就像 test_sorted_separate 函数中一样。将生成器包含在参数化测试中的方法在待测试方法具有相互依赖的多个输入参数时有特定优势。在这种情况下,可以使用依赖于先前生成参数的生成器逐个生成每个参数。虽然图 3 中显示的基于属性测试是有效且会正确运行的,但它们不一定是 sorted 函数的最佳基于属性测试。也许用户想要验证 sorted 对空列表的行为,而由于第 8 行和第 24 行中的 min_size=1 约束,这并不是我们的生成器生成的输入。类似地,第 15–16 行和第 26–27 行的断言并未捕获 sorted 函数的所有行为。例如,它没有检查 lst 和 sortedlst 是否共享相同的元素。
2.2 大语言模型
预训练的大型语言模型(LLM)是一类具有大量参数的神经网络,训练于大规模文本语料库上。这些模型通常采用自回归方式进行训练——即预测序列中的下一个标记,这使得它们能够在大量未标注的文本上进行训练。这种广泛的预训练使它们可以作为一次学习或零次学习的模型。也就是说,这些模型可以在仅提供一个任务示例或文本任务说明时执行各种任务。传递给 LLM 的自然语言指令以及任何附加的输入数据被称为提示。创建能够使 LLM 有效解决目标任务的提示的做法称为提示工程。此外,许多 LLM 还经过大量代码的训练。例如,Codex从 GPT-3 开始并额外训练了 160GB 的代码数据;StarCoder是一个具有 155 亿参数的模型,训练于一万亿个代码标记上。这些模型,以及更通用的 LLM,被用于许多软件工程任务,包括程序合成,程序修复,代码解释,以及测试生成。这些技术通过提示工程的方式直接利用 LLM 完成任务。与以往的工作类似,我们采用预训练的语言模型,并仅通过提示工程来调整它们以适应我们的任务。我们在第三节中讨论了构建这些提示的三种不同方法。
3 PBT-GPT方法
为了从LLM中合成基于属性的测试,我们首先设计一个提示,其中包括API文档和编写输入方法的属性测试的说明。
我们将合成基于属性的测试过程分为两个主要部分:(1)合成生成函数,以及(2)合成包含属性断言的参数化测试。
我们首先设计一个由以下部分组成的高级提示模板:
1)系统级说明,指出LLM是一位专业的Python程序员。
2)用户级任务说明,要求审查API文档并使用Hypothesis库生成PBT组件(例如生成器、属性或两者)。
3)从网站直接获取的输入API方法文档。
4)期望的输出格式(例如,根据任务使用Hypothesisst.composite或st.data())。
这个提示模板可以针对三种不同任务进行调整:合成生成函数、合成属性作为断言语句,以及将两者合成为单个参数化测试。
按照这种设计,针对networkx.find_cycle方法的生成器任务的提示示例如图4所示。用户级任务指示审查find_cycle的API文档,并编写一个函数,以生成networkx.Graph对象的随机值。输出格式使用st.composite装饰器(参见图3的参考文献4),并提供了生成器函数的签名。通过更改第二个任务说明,我们可以使用类似结构的提示来合成属性。我们不是指示LLM编写生成器函数,而是指示其使用提供的输入和输出变量名称来编写属性断言。要在单个参数化测试中合成生成器和属性,我们包括写生成器和属性的指令,并指定使用st.data()装饰器的输出格式(参见图3第20行)。
利用这种针对各个PBT组件的提示设计,我们如何为一个API方法生成完整的基于属性的测试?我们提出了三种提示LLM生成基于属性的测试的方法,每种方法的命名均与生成PBT组件的方式相关:独立生成、连续生成和同时生成。我们的三种提示方法在图5中被大致概述。接下来我们将详细描述每种方法。
图 4:合成networkx.Graph的生成器函数的示例prompt
独立生成:我们独立地两次提示语言模型生成器函数和属性断言,如图5左侧所示。生成器提示遵循图4所示的结构,包括指示LLM编写特定输入对象的生成器函数的任务说明。属性提示则使用不同的任务说明来编写所需的属性断言。一旦生成了这两个组件,我们会自动插入用于参数化测试的样板代码,指定在@given装饰器中使用LLM生成的生成器函数,并在输入上调用API方法。我们将LLM生成的属性断言放置在API方法调用之后。这遵循了图3第12-16行中所示的测试结构,分别包含生成器和参数化测试。连续生成:我们连续地提示语言模型生成内容,如图5中间所示。首先,我们提示LLM生成输入对象的生成器函数。然后,我们继续对话,使用后续提示指示LLM编写使用先前合成的生成器并包含任何所需属性断言的参数化测试。这为LLM提供了生成器的上下文,可能有助于生成有意义的属性。生成的基于属性的测试与独立生成提示的方法具有相同的结构。合并生成:最后,我们可以在一个测试函数中使用一个提示同时生成生成器和属性,这个提示来自LLM,如图5右侧所示。该提示包括生成输入数据生成器和所需属性断言的指令,并在一个测试函数中完成。输出格式使用了图3第20行所示的st.data()装饰器,以便测试函数可以动态生成输入数据并在输出上调用属性断言。图5 基于生成器和属性断言的组合,使用LLM生成基于属性的测试的三种不同的方法
4 评估和改善结果
使用我们的PBT-GPT方法来提示LLM合成基于属性的测试,我们如何评估这些生成的测试的质量?
尽管关于单元测试的有效性已经是被研究了几十年的话题,但基于属性的测试并非如此。进行这类PBT评估的困难之一是软件中缺乏现成的基于属性的测试。幸运的是,LLMs可以为我们提供一种自动生成基于属性的测试的方法,我们可以设计一个评估方法。我们提出了一个基于属性的测试评估方法和指标,重点关注于(1)生成器的质量和(2)属性的质量。我们列举了Python中API方法的不准确LLM合成的基于属性的测试的示例,并讨论影响这些质量的问题。我们所有的示例都使用Python中的Hypothesis PBT库。
4.1 生成器质量
图6 GPT-4产生无效日期时间的示例
1 生成器有效性:一种常见的错误行为是在调用生成器函数时遇到简单的有效性问题,即出现运行时错误。在Python datetime模块中由LLM生成的用于timedelta对象的生成器中存在一个示例,如图6所示。该生成器函数生成timedelta对象的值,可能导致datetime.timedelta构造函数在天数的大小超过1,000,000时引发OverflowError。虽然这个特定的生成器仍然可以用于属性基础测试,但自动生成的生成器可能会始终导致运行时错误,因此完全无法使用。因此,我们需要意识到生成器调用可能遇到运行时错误的频率。
为了衡量生成器的有效性,我们多次调用生成器,并记录未导致任何运行时错误的调用百分比。我们发现,针对timedelta对象的十个由GPT-4合成生成器在10,000次执行中实现了平均99%的有效性。
2 生成器多样性:尽管较高的生成器有效性对于功能性属性基础测试至关重要,生成器的另一个重要特性是其产生多样化输入的能力。如果一个生成器只能产生输入空间的一个小子集,那么可能无法对API方法进行适当的测试。图7展示了一个由LLM合成的生成器,用于networkx.find_cycle方法的输入为networkx.Graph对象。我们可以看到这个生成器只能生成无向图,如第22行中的nx.Graph()的调用所示。尽管这个生成器可能产生大量独特的输入,但我们更感兴趣的是关于API方法的输入多样性。networkx.find_cycle方法包含处理有向图的独特逻辑;由于这个生成器只产生无向图,因此这段逻辑将不会被测试。因此,我们通过衡量在生成的输入上调用时对被测试API方法的覆盖率来评估生成器的多样性。我们发现,在十个由GPT-4合成的生成器上,find_cycle方法平均覆盖了87.1%的语句和71.1%的分支。分支覆盖率的主要差距是因为大多数生成器只构建了无向图。
图7 networkx.Graph的示例
4.2 属性质量
1 属性有效性:如果一个属性导致一个与属性断言无关的运行时错误,我们将其定义为无效属性。这可能发生在LLM合成错误代码时,比如调用一个不存在的API方法。在图8的第8行中,LLM合成的断言包含一个调用networkx.is_undirected_acyclic_graph,而这个方法在networkx库中并不存在。我们衡量了在10个不同合成的基于属性的测试中有效属性的百分比,发现对于networkx.find_cycle,GPT-4 实现了98%的属性有效性。
图 8 GPT-4 生成包含无效属性断言的基于属性的测试
2 属性合理性:LLM(Large Language Model)也可能合成一个不合理的属性,即存在一个输入/输出示例违反该属性但在规范下是有效的。图9提供了一个针对numpy.cumsum方法的LLM合成属性测试的示例,在第6行包含一个不合理的属性。numpy.cumsum的文档规定对于给定的输入数组a,输出应该具有“与a相同的形状,如果axis不是None或者a是一个一维数组”。合成的属性不合理,因为它无条件地检查输出和输入的形状是否匹配。一个随机生成的输入array([[0]])在运行此测试时会产生一个断言失败,因为输入形状是(1, 1),而输出形状是(1,)。
如果在测试期间遇到来自属性检查的断言失败,我们如何知道是由于属性不合理还是API实现中的错误造成的?遇到断言失败时,我们假设LLM生成不合理属性的可能性比出现bug的可能性更高。因此,我们可以通过测量多个生成的输入中断言失败的频率来捕获属性的合理性。如果在大部分输入上断言失败,那很可能是一个不合理的属性。
为了报告属性断言的合理性,我们多次运行基于属性的测试,并记录属性导致断言失败的运行百分比。如果在超过10%的运行中断言失败,则标记属性为不合理,然后我们手动检查该属性检查的合理性。我们对numpy.cumsum运行了十次GPT合成的基于属性的测试,每个测试运行了1万次,并发现有68%的属性是合理的。10次合成的基于属性的测试中有6次没有包含任何不合理的属性断言。大部分不合理属性包含了cumsum(a)[-1]和np.sum(a)之间的无条件相等检查,这对于浮点值并非必然成立(参见图1)。
图 9 GPT-4 生成基于属性的测试,其中包含第 6 行 numpy.cumsum 的不健全属性
3 属性强度:属性有效性和正确性是对我们生成的属性的正确性进行评估的指标,但仍需要衡量属性的强度。当我们谈论强度时,我们指的是属性检查程序有趣行为的能力。图10显示了一个针对timedelta.total_seconds API方法的属性基础测试,其中包含第5行的弱属性。这个属性只是检查t.total_seconds()是否为浮点类型。虽然这个属性是有效的并且百分之百正确,但它并没有测试API方法的核心逻辑。
图10 GPT-4 生成基于属性的测试,其中包含时间delta 的弱属性断言
我们建议使用变异分数作为衡量合成属性强度的指标。我们记录下专门由于有效属性的断言失败而被杀死的变异体的百分比。这会忽略任何因调用API方法而引发异常而被轻易杀死的变异体,而专注于合成属性断言检测变异行为的能力。GPT-4合成的属性检查能够在基于属性的测试中平均杀死日期时间timedelta类中约69%的变异体。
4.3 缓解策略
虽然上述描述的LLM代码质量可能存在一些问题,但我们相信合成的属性测试提供了一个很好的起点,可以在此基础上进行迭代。针对之前讨论的每一个问题,我们提出了应对策略,并与LLM进行进一步的对话交流,以改善属性测试的质量。这些技术是为了与人工循环一起使用的。
1)无效的生成器:对于错误的LLM合成代码,一个常见的解决方案是与LLM继续对话,给出包括错误消息的指令。在调用无效生成器导致运行时错误的情况下,这个策略可以提高生成器的有效性。
2)低多样性的生成器:如果LLM合成的生成器对API方法的覆盖较低,那么与LLM继续对话,并在生成器函数中请求更多功能可以提高生成器的多样性。例如,我们可以要求LLM在networkx.Graph对象的生成器中包括有向图(参考图7),这将提高find_cycle的分支覆盖率。
3)无效的属性:与解决无效生成器的策略类似,继续对话并指导LLM修复错误可以修正无效属性。
4)不完整的属性:不完整的属性会导致特定输入/输出示例的断言失败。继续对话并指导LLM修复属性,同时提供导致断言失败的示例可能会提高其完整性。文档中违反属性检查的规定等额外背景信息也可能有所帮助。
5)弱属性:为了提高属性的强度,我们可以使用未被属性断言杀死的变异体作为例子。我们可以在后续提示中包含一个变异体,并指导LLM改进属性,以便测试能够杀死该变异体。
5 结论
在这篇论文中,我们探索了大型语言模型(LLMs)在合成属性基础测试方面的潜力。我们的初步研究表明,在样本Python库API上,使用文档描述中派生出的合成测试包含多种生成器和属性,结果令人鼓舞。我们相信,由LLM合成的属性基础测试可以作为开发人员和LLM迭代的绝佳起点。未来工作的一个方向是将更多的属性基础测试功能纳入我们的提示设计。例如,LLM可以合成使用假设语句的生成器,优先生成具有某些特征的输入。这些语句的使用已经证明可以提高生成器的强度,因为它导致执行更深入的程序行为的输入百分比更高。我们还认为,本文中展示的例子突出了使用LLM如何鼓励并帮助开发人员为其软件编写属性基础测试。LLM可以提供生成器和属性的初始逻辑,这通常是编写这些测试的最大障碍。此外,我们评估属性基础测试的方法可以形成一个有用的工作流程,自动执行由LLM合成的输出并标记任何可能的失败模式。在我们观察到的许多由LLM合成的属性测试中,开发人员需要对错误进行简单的修复,例如无效的生成器和不健全的断言。为此,我们正在为Python开发人员创建一个可使用的平台,使用LLM合成高质量属性基础测试,可在https://proptest.ai找到。虽然我们讨论的许多技术都可以自动化,但我们相信它们在循环中与人类的结合效果最佳。我们的意图是降低属性基础测试的入门门槛,并鼓励更多开发人员将其纳入其测试方法。
转述:杨襄龙