☰
提示工程+LoRA微调:让大模型生成可直接进CI的Java单元测试
2026/10/3 2:56:09 网站建设 项目流程

1. 项目概述

1.1 为什么想做这个项目

说实话,让大语言模型帮你写单元测试用例这件事,听起来很爽,但真正做起来全是细节。我在维护一个中大型Java服务时,每天最烦的就是写那些重复的测试代码:构造函数塞参数、Mock依赖、断言返回结果。一个逻辑稍微复杂一点的方法,测试代码量比业务代码还多,写起来又无聊又容易漏分支。到了迭代后期,测试覆盖率看起来不低,但很多断言其实形同虚设。

后来我开始尝试用提示工程让大语言模型生成单元测试用例,跑了几个版本之后发现,通用模型直接生成的测试代码有两个致命问题:一是大量生成“毕设风格”的测试,只顾着让代码跑通,根本没有验证业务逻辑;二是断言写得像挠痒痒,mock了所有依赖之后测了个寂寞。于是就有了这个项目的完整思路:先通过精心的提示设计拿到可用的基线结果,再用这些结果构造训练数据,通过LoRA微调让模型真正理解这个项目的测试规范,最终达到能直接进入CI的测试生成效果。

这个项目适合谁?如果你的团队正在推单元测试,但写测试消耗的工时已经让你肉疼;如果你试过拿ChatGPT、Copilot生成测试但不得不花大量时间修修改改;或者你正在做LLM应用开发,想搞清楚如何把提示工程和微调这两套手段结合起来,那这篇东西能省掉你不少自己摸索的时间。

1.2 最终实现了什么效果

先给结论,整个方案落地后,模型生成的测试用例在我们的Spring Boot项目上达到了83%的Line Coverage、71%的Branch Coverage,比直接用通用模型提示生成提升了大约20个百分点。更关键的是,生成的测试代码里直接能用的比例(不需要人工修改就能通过编译、通过断言)从原来的26%提升到了74%。这里面的核心手段就是:先做提示工程拿到高质量训练样本,再做LoRA微调把“这个项目的测试规范”烧进模型参数里。

本篇文章会从思路拆解、提示设计、微调实现、问题排查四个部分讲清楚整个链路的做法,中间会穿插大量我已经踩过的坑,那些训练框架报错、梯度爆炸、过拟合的问题,就不需要你再踩一遍了。

2. 提示设计与数据构造:高质量基线从哪来

2.1 提示词模板的迭代历程

很多人拿到这个项目的第一个动作就是写一段提示词,让模型生成测试用例。但真做起来你会发现,通用模型的提示词里藏着很多隐含问题。第一次我给的提示词大概长这样:

请为下面的方法生成单元测试: public Order createOrder(Long userId, List<Long> skuIds) { ... }

结果模型返回的测试基本是三层结构:把方法调一次、断言不为null、结束。这种测试拿到了CI里跑一遍全绿,但你把业务逻辑里的空指针、库存校验、金额计算全部删掉,测试照样绿。所以提示设计的第一原则是:让模型知道“什么是有价值的测试”,而不只是“怎么调这个方法”。

我迭代到第三版之后,提示词的结构调整成了几个固定的部分:角色设定、任务描述、输入信息、约束要求、输出格式。下面是我最终稳定使用的模板结构(Java项目版):

你是一名资深的Java单元测试工程师,擅长JUnit 5和Mockito。 请根据以下源代码、被测方法和测试规范,为该类的指定方法生成完整的单元测试类。 【被测类】 {classSourceCode} 【被测方法】 {methodSignatureAndBody} 【依赖关系】 {relevantDependencies} 【项目测试规范】 - 必须使用JUnit 5,断言使用AssertJ - Mock外部依赖(Redis、数据库、远程接口),不要启动Spring上下文 - 测试命名格式:方法名_场景_预期结果 - 每个测试必须包含核心行为断言,不允许只调用方法后断言非空 - 必须覆盖以下分支:正常分支、异常分支、边界值、依赖异常 【输出格式】 只输出完整的Java测试类代码,使用```java 代码块包裹,不要输出多余解释。

你可能注意到我把“项目测试规范”单独拉了一块,这非常关键。通用模型不懂你的项目用什么断言库、要不要起Spring上下文、命名规则是什么。如果你不说,它就会按自己训练数据里最主流的风格来,大概率是JUnit 4加Spring Boot Test,启动一个完整的MockMvc,甚至调Database。这种测试生成出来,你光修编译错误就得花半天。

我建议提示词里把“依赖关系”也放进去。并不是所有被测方法都能从代码上下文里看出来依赖是什么,比如某个Service注入了RedisTemplate、RestTemplate、ObjectMapper,你得把这些依赖的类型和Mock策略写清楚,否则模型会胡猜,生成一堆不存在的Bean。

2.2 提示结果筛选与样本质量

基线的提示词设计出来之后,下一个动作是跑一批结果,然后人工筛选,把“可用的测试”挑出来。我一开始直接把模型生成的结果全部塞进训练集,结果微调之后发现在过拟合的同时还学到了坏习惯。后来想明白了:微调阶段,模型学的是你给它的数据分布,如果你把那些低质量、风格混乱的测试也喂进去,它只会生成更多低质量测试。

筛选样本的时候,我总结了几个硬性标准:

  • 测试代码能直接编译通过,或者只需要极少量修改。
  • 至少有一个断言验证了方法的核心行为,而不是只验证“不为null”或“不抛异常”。
  • 能够体现出项目特有的测试风格,比如使用了项目自定义的测试工具类或Mock策略。
  • 覆盖到了输入边界或异常分支,而不是只有一条happy path。

筛选之后,我保留了大概800条高质量测试用例,然后做了格式归一化:把断言从JUnit自带的Assert换成AssertJ的链式断言、把命名统一成“方法名_场景_预期结果”的格式、把Mockito的when/then改成BDD风格的given/willReturn。这一步看起来机械,但对微调效果的影响是决定性的。模型在训练时看到的一致性越高,生成时的稳定性就越好。

数据格式我采用JSONL,每行一条样本,基本结构是:

{"instruction": "为以下方法生成单元测试...", "input": "方法签名与源代码...", "output": "完整测试类代码..."}

这种Alpaca风格的数据格式在LoRA微调中非常通用,不过你需要注意,你的base model用什么格式训练过,最好匹配它的chat模板,否则指令遵循效果会打折扣。我用的Qwen基座模型,train的时候就把指令拼接成它的chat格式,这一点后面讲微调参数时还会细说。

2.3 为什么先做提示工程再做微调

这块我觉得值得单独拎出来说,因为很多做微调的团队犯的最大错误就是跳过提示工程直接微调。

微调的本质是“在你指定的分布上继续训练”,它解决的是模型不知道你的偏好、不知道你的规范的问题。但如果连你自己都说不清楚“想要什么样的测试”,那你怎么构造训练集?你靠什么标准来筛选sft数据?提示工程的作用本质上是一个“逼你把需求说清楚”的过程。我通过反复调整提示词,把自己对测试代码的偏好梳理成了可以写下来的规则,这些规则最后变成了微调数据的挑选标准和输出格式参考。

另外,基座模型在提示工程下能拿到什么水平的结果,直接决定了微调的下限。如果你的提示词给力但模型生成还是乱七八糟,那你微调是对的;如果你提示词本身太弱,你喂进去的数据质量大概率也一般,微调出来的模型只会稳定地输出烂代码。

从成本和收益角度核算一下:提示工程几乎零成本,你只需要花时间调prompt,这部分投入是完全可复用的,之后换了模型、换了代码库,提示词微调一下就能接着用。LoRA微调则需要GPU时间、数据标注成本、评估成本。所以正确姿势永远是:先用提示工程把基线打到一个“可以手工筛选出足量干净数据”的程度,再做微调。

3. LoRA微调:把测试风格烧进参数里

3.1 基座模型选择与硬件考量

LoRA微调的基座模型选择,我经历了一个试探过程。一开始想省钱用的6B模型,后来发现虽然LoRA训练跑得动,但生成的测试代码复杂度一高就开始语法混乱,多写几个lambda表达式就漏右括号。后来换了7B和14B的Qwen系列,效果提升非常明显。如果你在本地部署大语言模型做微调,并且算力有限,7B基本是甜点大小:质量够用、显存还能撑得住。

我最终用的是Qwen2.5-7B-Instruct,说实话在代码生成领域它不是最强的(Codellama和DeepSeek-Coder更偏向代码补全),但它的指令遵循能力在同尺寸里非常出色,而且中文和英文都支持得很好。另外还需要考虑一点,LoRA微调之后,这个模型还要继续承担日常的代码生成任务,Qwen在通用场景下的退化比专门的代码模型小。

硬件这块,我使用的是单张RTX 4090 24GB,用QLoRA(4-bit量化)训练7B模型,训练时显存占用大概在16GB左右,batch size开到4,梯度累积步数设到8,等效batch size就是32。如果你的卡是24GB以下,我建议要么换更小的基座模型,要么把max_seq_len压到2048。训练过程中的一些硬参数我列个表供参考:

参数设定值说明
base_modelQwen2.5-7B-Instruct指令遵循能力强、中文支持好
lora_rank16较低维度,防止过拟合
lora_alpha32缩放系数,rank的2倍
lora_dropout0.05防止过拟合
target_modulesq_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj全量线性层注入LoRA
learning_rate2e-4比全参微调高,LoRA推荐范围
batch_size4配合梯度累积达到32
max_seq_len4096覆盖长测试类,避免截断
epochs3数据量800条,3轮足够
fp16True降低显存占用

3.2 训练数据清洗与格式转换

训练数据的准备工作比很多人想象中要麻烦。从代码仓库里抽取方法、生成测试、清洗,每一步都有坑。我这里给出一个我最终跑通的pipeline供参考。

第一步是抽取被测方法。我用的是JavaParser,把项目里的源代码解析成AST,然后按照规则提取每个public方法:方法签名、方法体、类名、依赖字段。这里有个细节,提取的时候要把“和这个方法相关的上下文”一起提取出来,比如方法依赖的类字段、私有辅助方法。否则模型看到的被测方法不完整,生成的测试容易用一些不存在的依赖。

第二步是生成测试。用稳定的提示词模板,调用Qwen的API批量跑。这里特别需要控制temperature,我设定的是0.7,出来的结果多样性比较好,又不至于太飘。如果设为0,同一个方法多次生成的结果几乎一样,不利于后续人工筛选多样性。

第三步是人机协同清洗。让模型先筛一遍候选测试,再人工检查。我的标注团队(其实就是我自己加一个实习生)会把明显不过关的样本删掉,剩下的做小修改。这个环节很枯燥,但它是整个微调效果的上限来源。

最终我构造了800条训练样本和100条验证样本,测试类长度的中位数大概在50行左右。清洗完成后,把数据转换成模型chat模板需要的格式。Qwen的chat模板格式是:

<|im_start|>system 你是一名资深的Java单元测试工程师...<|im_end|> <|im_start|>user 为以下方法生成单元测试... {method_code}<|im_end|> <|im_start|>assistant ```java ... 测试代码 ... ```<|im_end|>

如果你用其他模型,一定要去查它的chat template,HuggingFace的tokenizer_config.json里有。这个细节如果搞错了,模型训练的时候不会报错,但推理时指令遵循会很差,效果降一大截。

3.3 微调训练过程中的坑

LoRA微调听起来轻松,实际上调试过程也是有不少陷阱。第一个坑就是梯度爆炸。我在第一次训练时,loss在前几百步就冲到了NaN,后来发现是learning_rate太高了。LoRA虽然是对低秩矩阵做微调,学习率一般可以比全参微调大一些,但2e-4这个值在7B模型上运行是稳妥的,超过5e-4我就开始遇到loss不稳定。

第二个坑是过拟合。800条样本看着不多,但如果你用QLoRA跑5个epoch以上,验证loss一般会在第3个epoch之后开始反弹。我在训练时专门观察验证loss,发现第3轮之后验证loss不降反升,果断在epoch=3的位置提前停了。

第三个坑是我没想到的:训练数据里混入了“模型自己生成的代码”与“真实验证过的测试代码”的分布偏移。因为我生成测试时用的是Qwen的API,而代码仓库里的真实风格和Qwen的生成风格不完全一致,导致训练出来的模型在输出格式上过于“Qwen化”,一些琐碎的注释、无用的空行,看起来很明显。这个问题的解法是:清洗数据时手动删掉那些过于模板化的多余表达,越干净越好。

训练命令我用的HuggingFace的transformers加peft库,整体脚本如下:

from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments from peft import LoraConfig, get_peft_model from datasets import load_dataset import torch model_name = "Qwen/Qwen2.5-7B-Instruct" tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) tokenizer.pad_token = tokenizer.eos_token model = AutoModelForCausalLM.from_pretrained( model_name, load_in_4bit=True, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True ) lora_config = LoraConfig( r=16, lora_alpha=32, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"] ) model = get_peft_model(model, lora_config) dataset = load_dataset("json", data_files="train.jsonl") def format_sample(example): system_prompt = "你是一名资深的Java单元测试工程师,擅长JUnit 5和Mockito。" user_prompt = f"为以下方法生成单元测试:\n{example['input']}" assistant_prompt = example["output"] text = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{user_prompt}<|im_end|>\n<|im_start|>assistant\n{assistant_prompt}<|im_end|>" return {"text": text} dataset = dataset.map(format_sample) def tokenize_function(examples): return tokenizer(examples["text"], truncation=True, max_length=4096, padding=False) tokenized_dataset = dataset.map(tokenize_function, remove_columns=["text"]) training_args = TrainingArguments( output_dir="./lora_testgen", per_device_train_batch_size=4, gradient_accumulation_steps=8, learning_rate=2e-4, num_train_epochs=3, logging_steps=10, save_steps=200, eval_steps=200, evaluation_strategy="steps", fp16=True, report_to="tensorboard", save_total_limit=2, remove_unused_columns=False, ) import transformers trainer = transformers.Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["validation"], tokenizer=tokenizer, ) trainer.train()

训练完合并LoRA权重:

from peft import PeftModel base_model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float16, device_map="auto") lora_model = PeftModel.from_pretrained(base_model, "./lora_testgen/checkpoint-600") merged_model = lora_model.merge_and_unload() merged_model.save_pretrained("./lora_testgen_merged")

4. 效果评估与回归分析

4.1 评估指标:不要只看Code Coverage

很多人评估“AI生成的测试好不好”只看代码覆盖率,这是个巨大的陷阱。代码覆盖率这个指标有个致命的问题:它衡量的是“哪些代码被执行了”,而不是“哪些行为被验证了”。一段测试如果只是把所有分支都锤了一遍然后什么都不断言,覆盖率一样好看,但bug照样漏出去。

我的评估体系由三个维度组成:

  • 可直接使用率:模型生成的测试代码能直接通过编译、通过断言、不需要人工修改的比例。
  • 有效断言率:每个测试方法里“有实际验证价值的断言”占总断言的比例。无价值断言指那些只验证非null、非空、调用次数之类的“凑数”断言。
  • Bug检测率:往被测代码里人为注入若干个真实缺陷(比如把金额校验去掉、把库存扣减条件翻转),看测试能抓住几个。

我拿这三个维度分别对比了“纯提示工程基线”和“提示工程+LoRA微调”的模型,结果如下:

评估维度基线(纯提示工程)LoRA微调后
可直接使用率26%74%
有效断言率38%67%
Bug检测率35%61%
Line Coverage61%83%
Branch Coverage52%71%

看完数据你会发现,LoRA微调的提升是全方位的,但最香的其实是可直接使用率。这个指标直接决定了你省了多少人工工时。原来生成10个测试,你要改7个;现在生成10个,只用改3个甚至不需要改。这才是这个项目真正值的价值。

4.2 人工审计:哪些类型的测试生成得好,哪些不行

评估不能只看数字,还得拆到具体类型看看模型的强项和短板。我把项目里的方法按类型分了四类:纯计算型方法、CRUD型方法、外部依赖型方法、复杂业务编排型方法,分别测了一遍生成效果。

纯计算型方法(比如金额计算、日期格式化)生成效果是最好的,LoRA微调后的模型几乎完美:输入输出明确、断言精确、边界值覆盖到位。这种方法的本质是“输入-输出映射”,LLM在这个场景下非常擅长,只要提示词里把计算规则描述清楚就行。

CRUD型方法生成效果次之。这类方法需要Mock Mapper或Repository,生成的测试虽然能跑,但经常会漏掉“数据不存在”这种分支。不过经过微调之后,我发现在训练数据里专门加了几个“空结果”的用例,模型就学会这个套路了。

外部依赖型方法(比如调用第三方API)生成效果一般。主要问题在于模型不知道第三方服务的异常行为,它倾向于只模拟正常返回和超时两种情况,对于“返回错误码但HTTP 200”这种情况抓瞎。这里我还没找到特别好的解法,目前的做法是在提示词里把依赖异常场景写得更具体一些。

复杂业务编排型方法生成效果最差,但这也是预期内的,因为这类方法往往涉及事务、多表操作、状态流转,甚至连人肉写测试都很容易漏。我目前的建议是:这类方法不要完全依靠LLM生成,把它当成“人机协作”的重点对象,让模型生成骨架,人工补足关键分支。

4.3 微调模型的本地部署与使用

微调完的模型不能只躺在训练日志里,要真正跑起来才有价值。我用vLLM部署了一个本地推理服务,加载合并后的LoRA权重。部署配置比较常规,设定max-model-len为8192,gpu-memory-utilization开到0.9。实测下来单张4090上并发8个请求,每个测试生成的延迟在8到15秒之间,对于离线批量生成完全够用。

部署命令大概长这样:

vllm serve ./lora_testgen_merged \ --served-model-name testgen-qwen \ --tensor-parallel-size 1 \ --max-model-len 8192 \ --gpu-memory-utilization 0.9 \ --dtype float16

部署好之后,我把生成测试的流程接到了CI里:每次代码合并触发,对变更文件抽取方法,请求本地推理服务,生成测试代码,然后自动跑一遍编译检查。编译器通过的直接提交到MR里供人工review,编译不过的自动丢弃并生成提示日志。这个流程跑下来,人工review的负担显著下降了。当然,这里有一个合规和信任的考量:AI生成的代码不能直接进生产分支,必须经过真人review后才能合入CI流水线,这条规则我们执行得很严格。

5. 常见问题与排查思路

5.1 训练效果差的排查清单

经常有人跑完LoRA微调之后发现效果和基线差不多,甚至更差,于是怀疑LoRA没救了。我排查下来,90%的问题出在数据上,而不是训练参数上。你可以按这个顺序自查:

  1. 训练数据格式是否和chat模板匹配?不匹配的话模型完全学不进去指令格式。
  2. 数据里有没有坏样本?尤其是那些编译不过的“半成品”,模型会学坏。
  3. 样本多样性够不够?如果800条样本全是CRUD类型的测试,模型对其他类型的方法就束手无策。
  4. 验证集和训练集是否同分布?我见过有人把同一条数据既放训练集又放验证集,验证loss永远是0.01,看起来效果炸裂,上线立刻翻车。
  5. 是不是训练数据太少?LoRA微调虽然数据需求比全参微调少,但少于300条高质量样本基本没效果。

如果上面都没问题,再回头检查训练超参数。我遇到过learning_rate设太大导致loss震荡、max_seq_len设太小导致代码被截断,这两种情况都很典型。特别是max_seq_len,如果你发现生成的测试类老是被截断,后半部分全是残缺的,那大概率就是这里的问题。

5.2 生成结果常见的三类问题

第一类问题是“测试全糊弄”:生成了10个测试,没有一个真正断言核心逻辑。这些测试跑起来全绿,但属于典型的无效测试。解决办法是在提示词里强行规定“每个测试必须包含至少一个核心行为断言,不允许仅验证返回非空”,并且在筛选数据时严格把关。

第二类问题是“过度Mock”:模型把被测方法里的所有依赖全部Mock掉,导致被测逻辑本身也没被真正执行到。典型的例子是Mock了Calculator对象,然后调calculator.add(a, b)之后断言add方法被调用了——这测了个寂寞。这类问题的根源在于模型不理解哪些依赖应该Mock、哪些应该真实调用,需要在数据里加入明确的“该真实调用的依赖列表”。

第三类问题是“幻觉对象”:模型生成了项目里不存在的类或方法。比如代码里明明用的是RedisTemplate,模型却给你写了一个StringRedisTemplate。这类问题在微调之后大幅减少,因为模型见过项目的真实类型了,但如果项目里引入了新的依赖而训练数据没覆盖到,仍会出现。遇到这种情况,最快的处理方式是把新依赖的信息补充到提示词的“依赖关系”板块里。

5.3 关于评估标准,多说两句

我在实战中发现,测试生成领域最缺的不是生成算法,而是一个统一可靠的评估标准。覆盖率这种指标太粗糙,根本反映不出测试质量;“代码能编译通过”这种指标又太初级,编译通过但断言无效的测试遍地都是。推荐的做法是维护一个“缺陷注入测试集”:每个被测方法都人工注入几个真实缺陷,然后看生成测试能不能测出这些缺陷。这个测试集很贵,但一旦建立起来,就是评估模型效果最可靠的标尺,比任何覆盖率数字都有公信力。

6. 一些想对后来者说的话

做这个项目的过程中,我最深的体会是:提示工程和微调不是二选一的关系,而是一条流水线上的两道工序。提示工程负责把需求变成“可计算的形式”,让你知道什么叫作高质量测试;微调负责把“形式”固化成“肌肉记忆”,让模型不假思索地按这个标准输出。两者缺一不可。

另外,别指望模型第一次就能生成完美的测试代码。这就像带新人,最开始要手把手教规矩、给反馈、做示范,等新人把项目的套路都摸透了,你才能放心让他独立干活。LoRA微调本质上就是一次加速版的“带新人”。

还有一个小技巧想分享一下:我最后在生成测试之前,都会让模型先“分析一遍被测方法的逻辑分支”,输出一个分支列表,然后再生成测试。这一步看起来多余,但实测能把Branch Coverage再提高5到8个百分点,因为模型先梳理了逻辑,后面写测试的时候就不容易漏分支。这个技巧成本极低,但效果显著,你调完提示词之后值得一试。

如果后续要继续扩展,我准备把这一套流程推广到Service层的集成测试生成上,并且把数据清洗部分做成一个半自动化的工具链,减少人工标注的成本。到那时候再写一篇完整的实战记录,和大家分享。

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

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

立即咨询