☰
用LLM自动生成单元测试:从提示词工程到LoRA微调实战
2026/10/8 2:44:18 网站建设 项目流程

1. 这件事的起点:为什么我非要用LLM来写单元测试

先说一个每天都在发生的真实场景。你接手的那个老服务,核心方法是别人三年前写的,一没有注释,二没有单测,三还是业界罕见的超长方法。你不敢改,怕改坏线上;又不想补测,因为补起来的工作量比你重构还大。这时候如果能有一个工具,把方法喂进去,自动给你生成一套覆盖主分支、异常分支、边界条件的单元测试用例,你会不会用?反正我是会用的,而且我已经这么干了很久。

这整件事的核心链路其实非常简单,一共三站:第一站是提示词设计,目的是让大语言模型在没有额外训练的情况下,仅凭几条精心设计的指令就输出合格的单测;第二站是套上测试执行器和覆盖率工具,验证模型生成的东西到底能不能跑、覆盖多少;第三站是当提示词做到极限也喂不动了,再上LoRA做轻量微调,让模型真正理解你们项目的测试规范。这个路线是我实测走通过的,不是停留在PPT阶段。

技术选型上,我的建议是:除非你是纯粹想体验一下API的省事,否则早期阶段就锁定本地部署的大语言模型,原因后面专门说。模型我用的Qwen系列,一方面它在中英文代码理解上确实能打,另一方面它的底座开源,可以顺路做LoRA微调,不会被厂商绑定卡脖子。整套实现下来,你需要的核心材料就四样:一个大模型推理服务(或API)、一个提示词模板层、一个测试执行与统计工具链、一份用于微调的测试用例数据集。

这篇文章就是把我从提示词设计一路走到LoRA微调的全过程拆开讲。适合谁看?适合那种已经会写代码、用过ChatGPT或本地模型,但还不清楚"怎么把LLM的能力稳定地压到单测生成流水线里"的工程同学。你不需要懂太深的机器学习原理,但需要一点Python基础,以及愿意折腾环境的耐心。

2. 用提示工程逼出模型的代码能力:先别急着微调,把提示词玩明白再说

很多人一上来就想微调,觉得自己数据集也攒了、显卡也租了,结果是拿大炮打蚊子。微调是很重的手段,成本高不说,搞不好还会把模型原本的代码能力练歪。所以我的第一个建议是:先把提示词做到模型能产出的上限,再决定要不要动权重。

2.1 提示词的分层结构:角色、任务、约束、范例一个都不能少

我自己写过非常多版的单测提示词,最后沉淀出来的结构是四层:

  • 角色层:告诉模型"你是一个精通X语言和X单元测试框架的资深测试工程师"。这一层表面看是心理暗示,实际上非常关键。模型在角色约束下输出的表达风格、覆盖意识、边界判断都会有实质变化,尤其对7B级别的小模型来说,角色提示等于是帮它把输出分布往专家方向压缩了一大截。
  • 任务层:必须用祈使句精确描述你要什么:"根据以下函数签名和函数体,生成单元测试用例。要求覆盖正常分支、异常分支、边界条件。"
  • 约束层:列硬性规则,比如"只输出JUnit测试代码,不要解释""不要修改被测函数的签名""被测函数依赖的外部服务全部用Mockito mock掉""生成的用例必须通过编译"。约束要尽量用否定句+肯定句混合,模型对禁令的服从度通常比对"尽量"这种软要求高一截。
  • 范例层:给一到两个few-shot示例,Include被测代码的输入输出、通常的测试风格、断言的写法。这是整个提示词里信息密度最高的部分,一张好的范例顶过十句抽象描述。

这里有个值得注意的细节:范例不是为了展示"标准答案",而是为了统一模型的输出格式和风格。同一个项目里,模型如果两次给出风格完全不同的测试(一次用junit4的@RunWith,一次用junit5的@Nested),你说这是人写的都没有说服力。所以把你们的既有测试风格写进范例里,这一点微调都替代不了。

2.2 被测代码上下文注入:函数体太长时怎么切

实践里遇到最多的一个问题是:被测函数动不动几百行,全部塞进上下文里,模型的注意力会涣散,输出质量断崖式下跌。但截掉函数体只给签名,模型又只会写Happy Path,覆盖不到内部分支。

我试过几种切法,最后稳定下来的是"分而治之"。先把函数体里的if/else、switch、异常抛出点全部抽出来,得到一张分支清单,然后把函数签名、分支清单、以及分支对应的关键代码片段一起塞给模型。等于说,我不让模型看整棵代码树,而是给它一张标注好的地图。这样上下文塞得进去,模型的覆盖效果也接近满分。

等到这套提示词产出稳定了,再把它做成一套模板管线。我的管线长这样:

  1. 输入被测函数源码
  2. 静态分析出函数签名、依赖列表、分支点列表
  3. 按模板组装提示词
  4. 调本地模型推理
  5. 解析模型输出,提取代码块
  6. 把测试代码落盘到测试目录
  7. 跑mvn test + jacoco覆盖率,返回结果

这一套其实就是后续微调时要用的推理侧完整链路。你提前把它做扎实,微调完成后只需要换模型权重文件,其他一概不动。

2.3 模型倾向性调节:温度、top_p和采样参数对用例多样性有多敏感

你可能会觉得生成单测这种偏严谨的任务,模型输出越稳定越好,那温度直接拉到0不就行了。但我跑下来发现,温度固定在0时,模型倾向于输出语料中出现频率最高的那几种测试形态,比如只覆盖正常路径、只测单个断言、大量重复等待超时。这会导致一个很隐蔽的问题:覆盖率看起来还行,但同一批用例里冗余度高得吓人。

我的做法是:温度设0.2~0.4之间做一点随机性注入,然后把模型生成三轮,用覆盖率报告和变异测试结果做一次择优合并。不要试图把温度调太高,否则模型会开始编造根本不存在的API,比如生成一个毫无根据的Mockito.when(foo.doSomething()).thenReturn(...),可被测代码里根本没有这方法。0.2到0.4这个区间是我反复试下来性价比最高的区间。

2.4 提示词版本管理的必要性

既然提示词的改动对模型输出影响这么大,那它就有资格跟代码一样进入版本管理。我维护了一个prompts.yaml,每个提示模板都有版本号、改动说明、实测覆盖率对比。改模板之前,必须先在十条黄金测试样本上跑一遍旧版本和新版本,都过了才能换。

这块建议直接做成脚本挂CI里。每次prompts文件变动,自动触发一次benchmark,输出覆盖率变化。别问我为什么这么较真,你只要经历过一次"改了一个字让模型输出全部跑偏"的事故,就知道提示词管理的价值在哪里了。

3. 本地部署模型作为底座:为什么是Qwen,以及推理服务怎么搭

提示词好归好,但如果你全程调用云端API,你会发现两个问题:第一是代码会出网,大部分公司这关就过不了;第二是后续微调时,云端API只能黑盒调用,微调完的权重落不到自己手里。所以从第一天起,我就把底座锁定在本地部署的开源模型上。

3.1 选Qwen的理由:代码能力、中文语境、微调生态

这个领域能选的模型其实不少。Llama系列的代码能力不差,但中文指令理解有时候会有点水土不服;CodeLlama专门为代码场景优化过,但聊天能力和通用性弱一些,提示词稍微复杂一点就崩。Qwen系列对我来说是综合平衡最好的:基座模型在代码语料上的训练量足够,对中文注释和中文需求的理解又天然占优,而且它的指令微调版本(Qwen2.5-Coder系列等)在代码生成任务上跟同级别的Llama系比也不会吃亏。

实操上我选的是Qwen2.5-Coder-7B-Instruct,量化成4bit塞进一张24G显卡完全跑得动。如果你只有16G显存,那就用Qwen2.5-Coder-7B的GPTQ int4版本;如果显存宽裕,14B的Instruct版效果更好,尤其是对长函数体、多分支场景的把握明显强一个量级。

3.2 本地推理服务的两种搭法:llama.cpp和vLLM

我在不同的阶段用过两条路线,各有各的适用场景。

  • llama.cpp:占内存极小,CPU也能凑合跑,适合单机调试和快速验证。它的API是一个OpenAI兼容的server,默认端口8080,curl一发就能拿到响应。缺点是并发能力弱,生成速度吃不满GPU。我在业务代码研究阶段、或者说只生成少量用例的时候,就一直开着它。
  • vLLM:吞吐量确实牛,高并发场景下能把GPU利用率顶上去,但显存要求也高,而且跟Windows环境基本绝缘,需要Linux服务器。当我要批量生成上千个函数的单测,跑一轮全量评估时,就得上vLLM。

两条路线的共同点,是它们都提供OpenAI兼容的/v1/chat/completions接口。这意味着你的提示词层、微调之后的部署都不需要换接口,只换base_url和model_name就行。这个设计带来的省心事,后面你会真切体会到。

3.3 部署过程中最容易卡住的三个点

第一,模型的上下文长度配额。Qwen2.5系列默认支持很长上下文,但如果你用llama.cpp,需要在启动时显式设置--ctx-size,否则只给你默认的512或者2048,塞一份长函数代码进去就超出限制了。第二,量化格式和推理框架的匹配问题。同一个GGUF文件,在老版本llama.cpp上可能直接报错不识别。建议装新一点的版本,或者干脆用官方文档里的命令拉最新的release。第三是输出长度限制,生成测试用例时往往输出几百行代码,默认的max_tokens根本不够,我一般直接给到2048以上,必要时开到4096。

如果你用vLLM,还需要额外注意一个参数:--max-model-len。这个值设太小,长上下文请求会被直接拒掉;设太大,显存占用量又会翻番。我建议按实际提示词的平均长度加两到三倍的余量来设。

3.4 一个实用的小动作:给本地服务套一层统一代理

一旦本地模型服务和后续的LoRA推理要接入多个流程,你会发现各流程里service_url散落各处,到处都要改。我的做法是在本地加一个极薄的反向代理层,统一暴露/v1/chat/completions,后端真实模型然后走转发。后续做微调版本切换,只改代理的配置,业务代码一行不动。

这一层代理配合上面说的OpenAI兼容接口,基本等于给整个项目做了一个"模型无关"的缓冲。无论后面模型从7B换成14B,还是从base模型切到LoRA融合版本,提示词层和测试执行层都无感知。

4. 从提示生成到评估闭环:覆盖率、编译成功率、无效用例率

光会生成代码不叫落地,能证明"生成的代码真实可用"才算数。我踩了一个大坑之后才知道,初版模型生成了500个用例,编译通过率高达95%,但mvn test一跑,红色瀑布哗啦一片。为什么?因为大量编译通过的用例,运行时直接在setup阶段就抛异常,全是一堆无效测试。从那一刻起,我就把评估指标改成了比"代码生成质量"更靠近工程现实的三大指标:编译通过率、测试执行通过率、代码覆盖率,外加一个统计无效用例率的辅助指标。

4.1 编译通过率和执行通过率:你能骗过编译器,但骗不过junit

编译通过率是入门指标,它衡量的是模型输出能不能过编译器这一关。执行通过率更进一步,要求用例在测试运行时不能出现初始化失败、mock失败、超时这类"假失败"。所谓假失败,就是你的测试代码本身有问题,而不是被测试逻辑有问题。这两种情况要分开统计,否则你没法判断究竟是模型生成的测试不好,还是被测代码真的有bug。

我的统计方式是:执行通过率 = (通过用例数 / 总用例数),但把异常分门别类,比如NullPointerException、MockitoException、超时、AssertionError等一一打标。第三步做"用例去重 + 缺陷聚类"时,这些标签能帮你快速定位模型在哪些类型上系统性犯错。

4.2 覆盖率的正确打开姿势:别只看行覆盖

Jacoco的报告里有行覆盖、分支覆盖、方法覆盖、指令覆盖,很多人只盯着行覆盖,被那个虚高的百分比自我感动。单测生成任务里,分支覆盖才是更难的指标。行覆盖高但分支覆盖低,说明模型一直在重复测同一条路径,根本没碰到else或者异常捕获逻辑。

我有一次跑出来的数据就是行覆盖82%,分支覆盖只有46%。排查之后发现,模型生成的用例全部集中在正常返回路径上,几乎所有包含if (xxx == null) throw ...的方法,异常分支的用例一个都没有。我的评估脚本后来就改成行覆盖和分支覆盖分开输出,生成任务的目标函数改成"两覆盖都要高于基线,否则本轮生成视为失败"。

4.3 无效用例率:被大多数人忽略的隐形杀手

无效用例是什么?是能编译、能执行、但它什么也没验证的用例。典型的例子包括:

  • 测试方法体只有mock调用,没有任何断言。跑是能跑,但等于没测。
  • 断言写得模棱两可,比如只断言返回值不为null。这种用例在覆盖率报告里还占名额,但你要是指着它抓回归bug,基本指望不上。
  • 打着"测试"旗号的空跑,根本没有调用被测方法。

统计无效用例率的口径,我用了静态特征+动态特征结合的方式:静态检查是否有assert语句、是否调用了被测方法;动态检查如果删除该用例后覆盖率不变,则视为疑似无效用例。把无效用例率压到5%以下,你的生成链路才有资格谈稳定。

4.4 让评估闭环成为一条可回归的流水线

最后我在本地CI上搭了一条流水线:每条提示词改动、每次模型权重的替换,自动触发一轮全量评估。数据集是预先选好的50个覆盖不同复杂度等级的函数,从纯工具函数到带外部依赖的服务方法都有。评估结果写成一个JSON,历史版本存档。这样谁改了什么,覆盖率升了还是降了,一查便知。

没有这条流水线之前,我好几次"凭感觉优化"提示词,改完反而把分支覆盖拉低了,却因为只看单条生成结果而完全没察觉。有了自动评估之后,这类劣化会被立刻发现。

5. 提示词喂不动了,才是LoRA微调出场的时候

你可能会问:提示词已经做到这样了,为什么还要微调?因为我在实践中遇到了一个提示词怎么调都跨不过去的瓶颈:模型对项目私有风格的适配能力有限。你的项目有自己的一套返回体结构Result<T>,有自己的异常类型BizException,有自己的mock规范"禁止mock静态方法"。提示词里写得清清楚楚,模型偶尔能照着做,但大多数时候还是回到它训练语料里的"主流写法"。这个问题的本质是:通用模型知道"怎么写单元测试",但不认识"你们项目的开发规范"。

LoRA微调干的活,就是在不更新全部权重的前提下,用一批你们项目自己的数据,给模型"补一门短课",让它学会你们项目内部的代码风格和测试习惯。

5.1 训练数据:一份高质量单测数据集要满足什么条件

微调效果的上限由数据决定,这句话我说得一点不夸张。我第一批数据是从开源项目里抓来的通用单测,微调完模型写出来的用例依然偏通用风格,项目特色没有任何显著体现。真正让模型产生质变的,是用你们自己项目的真实代码和真实测试对。

一份合格的数据集长这样:

  • 样本数:不需要多,800到2000条之间足够。LoRA本身是轻量微调,数据量太大容易过拟合,太小又学不到模式。
  • 配对结构:instruction + input_code + output_test。instruction是固定的提示模板,input_code是被测方法源码,output_test是项目里实际爱用的那种测试写法。
  • 多样性:同样的业务逻辑,最好用不同的代码形式出现,比如同一个接口方法,有同步实现、有异步实现、有带重试的实现,这样子模型才能学到抽象规律而不是死记模板。
  • 项目规范自然融入:数据里大量出现你们项目的Result、自定义异常、mock风格、断言风格。样例本身就会教给模型。

数据清洗的时候特别注意三件事:把测试里引用到的不存在的依赖去掉;把超长、超复杂的测试样本排除掉;把含有敏感业务信息的测试做个脱敏。别小看第三点,本地微调数据一般都要进训练脚本,如果里面有线上账号密码的硬编码,鬼知道会被模型学成什么。

5.2 数据格式:我不造轮子,直接套LLaMA-Factory的格式

微调工具链我用的LLaMA-Factory,因为它的数据格式通用、支持Qwen全系、训练脚本写得很顺手,而且对LoRA这类PEFT方法支持得很成熟。它要求的数据结构是conversations式的,或者alpine格式。具体我用的是alpine格式,每个样本有instruction、input、output三段。

这段稍微强调一下格式细节:input字段放被测源码,output字段放期望生成的测试代码。为了保持指令和推理阶段一致,训练时的instruction文本必须跟你线上推理时用的prompt完全一致。如果不一致,模型学到的"输入输出映射"在线上会全部错位。这一点看似废话,但在实际的微调项目里翻车率极高。

5.3 训练工程参数:LoRA rank、alpha、learning rate怎么定

LoRA的核心思想是,给原始权重矩阵加两个低秩矩阵做增量,训练时只更新这两个小矩阵。所以你要关心的参数就那几个:

  • lora_rank:低秩矩阵的维度。一般取8到64。任务越复杂,rank越大,但太大也会引入更多可训练参数,增加过拟合风险。单测生成这个任务我取16到32之间比较稳。
  • lora_alpha:缩放系数,控制LoRA分支对最终输出的影响强度。常用取值是rank的两倍,比如rank=16,alpha=32。
  • learning_rate:LoRA微调的学习率一般比全参数微调要大,常用1e-4到5e-4。我用2e-4起步,跑few轮后看一眼loss曲线,如果训练loss还在降但验证集上生成质量已经在变差,就降学习率重跑。
  • num_epochs:我建议从2到3个epoch起步。批次数据量小的话,太多轮次会过拟合到数据集里的噪声。

训练日志里重点盯两处:一是训练loss曲线是否平滑下降;二是验证集上的生成样例是否逐步贴近目标风格。验证方式就是拿微调后的模型,在几条没进过训练集的函数上生成一次测试,人工看一眼像不像项目里的人写的。

5.4 把LoRA权重部署回推理服务:不合并、不烧权重

我强烈建议你别把LoRA权重直接合并进底座模型保存成一个大文件。合并没有错,但每次想调LoRA的强度、或者换一套LoRA权重时都要重新加载整个模型,太笨重。LLaMA-Factory训练完会保存adapter配置和权重,部署时只需要让推理框架加载底座模型,再动态挂上adapter即可。

如果在vLLM里做,可以把adapter配置放到模型目录,启动时用--lora-modules指定;如果用的是llama.cpp,社区最近也支持了LoRA加载,启动时带--lora参数就行。这样你可以同时跑"裸模型"和"LoRA版本"两个服务,一个当baseline,一个当test,让评估脚本比一比哪个覆盖率高。

6. 实际效果与踩坑记录:从数据里挑几个有代表性的例子

光说方法没有例子,总觉得不落地。我从自己的实测记录里挑了三组有代表性的情况,都是提示词阶段和LoRA微调阶段对比很直观的样本。

6.1 典型场景A:带复杂前置校验的业务方法

这是一个用户注册方法,前置校验包括用户名格式、密码强度、邮箱合法性、验证码时效,每一个校验失败都抛不同的BizException(code, msg)。提示词阶段的模型能生成正常注册路径的用例,但对每个校验分支的覆盖就时好时坏,经常漏掉验证码过期这个分支。LoRA微调之后,模型生成的用例把校验分支全部覆盖了,而且异常断言的写法跟项目里老测试完全一致:assertBizExceptionWithCode(result, 1203)。

这一条的改变逻辑其实不难理解:项目里的大量既有测试都是这种业务异常分支的写法,模型在微调样本里反复看到了"校验失败必须断言错误码"这个模式,自然就把这个习惯学会了。

6.2 典型场景B:第三方服务依赖的mocking

这个场景是"外部HTTP调用型方法"的测试。提示词阶段的模型频繁出两种问题:不mock外部HTTP服务,直接new真实客户端;或者mock了,但没配置when(...).thenReturn(...),返回了个null,导致被测方法里NPE。LoRA微调之后,模型学会了先用接口抽象层mock,再对成功、超时、5xx分别配置不同响应。这个改变,直接让这个场景下的执行通过率从71%提到93%。

6.3 典型场景C:异步方法的测试

异步代码的测试对模型来说是天然盲区。prompt阶段的模型经常拿同步思维写异步测试,直接断言还没返回的结果。我试过在提示词里加"注意CompletableFuture异步等待",效果一般。LoRA数据集里塞了几十条不同风格的项目异步测试样本之后,模型学会了CompletableFuture.join()的正确位置以及超时时间的设置,这个进步是提示词怎么压都压不出来的。

6.4 把翻车现场也放出来:数据泄露、过拟合、格式错乱

不能光报喜。我微调翻过几次车,最典型的:第一次数据没做清洗,有一批测试里面调用了私有方法,模型学了一堆getDeclaredMethod + setAccessible(true)的黑魔法,生成出一堆反射测试,编译通过率高得惊人,但覆盖率却低得吓人。后来拿掉这类样本重新训练,才恢复正常。

第二轮翻车是过拟合。epoch跑了6轮,训练loss从1.1降到0.2,我一看降得漂亮,直接把这个版本上线,结果在没见过的函数上生成质量还不如baseline模型。特征很明显:模型只会重复数据集的写法,遇到新代码结构时完全失去泛化能力。最后把所有训练轮次降到3,又在评估流水线上跑通,发现生成质量才真正稳住了。

7. 严格自查后的落地清单和几个长期看到的效果

如果现在让你自己动手复现这条路,我建议按这个顺序走:先不管微调的事,把提示词模板写出来,跑一个最小可行链路,让模型真能生成出可编译、可执行、有覆盖率的测试用例;然后在评估流水线上把baseline跑稳;当你确认"提示词已经榨出80%的能力"之后,再攒数据集,上LoRA;LoRA训练完不要急着并权重,单独开一个服务,用同样是评估流水线跟baseline对比,不能说效果没变好就把微调版本换上去。

这一套流程跑完,我这边长期挂在流水线上的效果是:存量老代码的单测覆盖率平均提升了约30个百分点,新代码的用例生成已经常态化地进到了开发流程里。别指望它替代人,也别在它出错的时候甩锅给它,把它当成一个"永远有精力把边边角角分支都测一遍"的实习生,定位就对了。

最后再分享一个小技巧:微调完的模型并不是终点,你还可以把提示词里成功的范例继续优化,替换成微调后生成的"优质新样本",让底座模型+提示词+LoRA三者互相迭代。我的经验是,这个迭代周期大概一个月做一轮,每一轮都能见到稳定的质量爬坡。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询