尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LLM生成测试如何改变实现方案的评估结论:一个最小对比实验

LLM生成测试如何改变实现方案的评估结论:一个最小对比实验 “两个实现都通过了现有测试但我总觉得其中一个在真实场景里会出事。”这是代码评审里很常见的一句话。原因很简单现有测试集只覆盖了少数几个正常路径它既没有考验边界条件也没有检验异常输入所以它并不能真正区分两个实现的优劣。更准确地说当前测试集决定不了胜负胜负是由测试集本身的覆盖水平决定的。LLM-generated tests can change which implementation wins——这句话来自一个很多人已经注意到、但还没有系统讨论的现象当测试用例不再由人手工编写而是由大模型根据规格批量生成时测试集的组成、覆盖范围和断言强度都会发生变化。这种变化会直接传导到“实现方案对比”上让原本通过全部测试的 A 方案暴露缺陷或者让 B 方案因为更贴合规格而胜出。这篇文章要做的不是讨论“LLM 生成的测试好还是不好”而是展示它如何改变实现评估这件事本身。我们会用一个最小但完整的 Python 示例跑通“用 LLM 生成测试再比较两个实现方案”的完整流程并给出可复制的代码、常见坑和工程建议。读完之后你会明白测试集从来不是一个中立的裁判它本身就是评审规则的一部分。1. 为什么“测试集”本身就是裁判两个实现方案摆在一起谁更好这个话题在代码评审里能吵出很多理由有人觉得 A 代码更短有人觉得 B 更符合规范有人觉得 A 可读性差但性能好。这些讨论很有价值但最终落到执行层面团队还是要一个可量化的结论。于是几乎所有人都会做同一件事跑测试。跑测试这个动作本质上是在一个预先定义好的测试集上打分。测试集由两部分组成输入样例和预期断言。输入样例决定“要测哪些情况”预期断言决定“测到什么程度算通过”。两件事合起来其实就是定义了一个评估函数。实现方案在这个函数上的得分决定了它在这次评审里的命运。这里的关键在于测试集并不是一个中立的测量仪器。它像一把尺子尺子的刻度怎么画直接决定了谁量出来更“标准”。举个例子比较两个排序函数。一个在随机数组上性能极好另一个在几乎有序的数组上性能极好。测试数据如果模拟真实业务里的“列表局部有序”分布第二个方案就会胜出测试数据如果完全随机生成第一个方案就会胜出。两个实现都没有变化变化的只是测试数据的分布结论却可能完全反转。把这个逻辑推广开来就是测试集的组成偏好会直接影响实现方案的比较结论。改变测试集的覆盖重点、边界用例数量、断言严格程度都会改变“谁更好”的答案。所以当我们讨论“LLM 生成的测试”时不能只把它当作“又多了一种自动化生成测试的工具”。它真正的意义在于它改变了测试集的生成方式进而改变了实现评估的标准。谁掌握了测试集的组成谁就在相当程度上预设了胜负。2. LLM 生成测试到底改变了什么传统测试是怎么来的大概是这样的流程需求文档 → 开发人员阅读 → 根据经验写测试用例。这个过程有几件事被默认接受了第一测试数量非常有限。一个函数通常只有几条到十几条测试因为人工写测试很贵时间不允许你覆盖所有边界。第二覆盖范围偏向正常路径。我们习惯先写一个正常输入再写一个空值最多再补一个异常输入很少系统性思考分隔符连续、大小写混排、Unicode、数字与字母混排这类情况。第三测试与实现容易“互相迁就”。因为写测试的人往往刚看完实现代码很容易下意识地按照实现的行为去定预期哪怕实现本身有 bug测试也可能跟着错误行为走。LLM 生成测试改变了这三点。首先覆盖范围变了。如果你在 prompt 里明确要求“覆盖边界条件”“覆盖异常输入”“覆盖大小写混排”LLM 会真的生成这些测试。你不需要自己一个个想它会按照规格描述系统性地构造用例。这意味着测试集里“人类容易漏掉的部分”会显著增加。其次生成成本变了。以前为一个实现定制一套完整测试需要投入几小时甚至几天。现在把规格文本丢给模型几十秒就能生成一版。这种成本结构让“为每次实现对比都单独生成测试集”成为一种可执行的工作流。这在以前是不现实的所以团队往往复用同一套旧测试来评审所有方案而旧测试既可能偏袒某些实现也可能对某些实现的不合理行为过于宽容。第三测试与实现的独立性变强了。如果你要求 LLM 只根据自然语言规格生成测试而不给它看实现代码它生成的断言更多来自“规格应该怎样”而不是“实现实际怎样”。这种独立性减少了对实现的“将就”也就更容易暴露实现与需求之间的偏差。但这里有一个非常重要的限定LLM 不是中立的。它有自己的偏好比如更习惯生成常见编程风格、常见边界值甚至它见过的常见 bug 模式。你给它的 prompt 是“多写边界测试”它就会偏向边界你给它的 prompt 是“按正常业务样例生成”它生成的测试就会偏向业务常态。换句话说LLM 生成测试并不是“更客观”而是“更容易规模化地制造新的评审偏好”。理解了这一点才能理解为什么 LLM-generated tests 会改变实现方案的胜负它让测试集的组成从一个模糊的、依赖个人经验的东西变成一个可以快速定制、规模化生成、并且明显带有策略偏好的东西。策略变了集成的测试集就变了测试集变了胜负就变了。3. 一个会反转胜负的典型例子理论说多了容易空我们直接看一个会反转胜负的最小例子。假设我们要实现一个功能统计一段英文文本中每个单词出现的次数。规格定义如下单词由字母和数字组成。连续的非字母数字字符作为分隔符。大小写不敏感统一转为小写。空字符串返回空字典。现在有两个候选实现。实现 A 使用正则表达式严格按规格定义切词import re from collections import Counter def count_words(text): if not isinstance(text, str): raise TypeError(text must be a string) words re.findall(r[a-z0-9], text.lower()) return dict(Counter(words))实现 B 使用 split 加标点剥离代码更短看起来也很直观def count_words(text): if not isinstance(text, str): raise TypeError(text must be a string) result {} for part in text.split(): word part.strip(.,!?;:\()[]{}).lower() if word: result[word] result.get(word, 0) 1 return result如果测试集只包含普通句子比如hello world、Hello HELLO、hello, world!那么两个实现都会通过看起来它们“一样好”。你甚至可能因为 B 更简洁而选择 B。但如果测试集里加入了这样一个用例def test_contraction_word(): assert count_words(dont stop) {don: 1, t: 1, stop: 1}结果立刻不同。实现 A 会把dont切分成don和t因为单引号不是字母数字字符按规格它是分隔符实现 B 却会把dont当作一个整体因为strip不会去掉词中间的单引号。于是实现 A 通过实现 B 失败。再比如连字符单词def test_hyphenated_word(): assert count_words(state-of-the-art) { state: 1, of: 1, the: 1, art: 1 }同样A 把连字符当作分隔符B 却保留整个state-of-the-art。我们可以用一个表格来展示这个问题测试集实现 A正则实现 Bsplit strip普通句子、大小写、句尾标点通过通过缩写词dont通过失败连字符词state-of-the-art通过失败数字与字母混排v1.0通过失败连续多个空格通过通过这个例子说明什么并不是实现 B“更差”而是 B 的切词逻辑和规格定义之间存在细微偏差。这个偏差在普通测试集里根本看不出来但在边界测试集里会立刻暴露。测试集一变“哪个实现更符合需求”的答案就变了。这就是 LLM 生成测试能够改变胜负的最典型结构人类因为经验或思维惯性容易忽略一些边界输入而 LLM 在明确指示下会系统性地生成这些边界测试从而让隐藏在实现细节里的偏差浮出水面。4. 环境准备与前置条件下面进入实操。我们会用一套真实的 Python 脚本把“LLM 生成测试 → 运行测试 → 对比两个实现”这个流程跑起来。先说环境要求。这个示例需要的工具和依赖较少Python 3.9 及以上版本。pytest用于运行测试并生成 JUnit XML 格式报告。一个可以调用的大模型服务。你可以使用托管 API也可以使用本地部署的 OpenAI 兼容接口服务。本文示例代码使用 OpenAI SDK 的兼容调用方式只要你的服务提供兼容接口就可以直接替换base_url和api_key。环境变量三项LLM_API_KEY模型服务的密钥。LLM_BASE_URL模型服务的地址。如果是本地服务一般是http://localhost:8000/v1这类地址。LLM_MODEL模型名称。具体名称以你的服务为准本文代码里有一个默认值但强烈建议显式设置。安装依赖的命令如下pip install pytest openai密钥不要写在代码里也不要提交到 Git 仓库。使用环境变量可以避免在脚本中硬编码敏感信息。下面是设置环境变量的示例export LLM_API_KEYyour-api-key export LLM_BASE_URLyour-endpoint/v1 export LLM_MODELyour-model-name如果你使用的是托管 API不需要设置LLM_BASE_URLSDK 会使用默认地址。如果你使用兼容 OpenAI 协议的本地工具就把它指向本机服务地址。需要特别提醒的是调用外部模型服务时要确认你发送的规格和代码片段是否允许离开本地环境。如果这是未公开的业务代码最好使用私有化部署的模型或者在数据合规评审通过后再使用外部服务。生产项目中安全边界比测试便利更重要。5. 完整示例LLM 生成测试并跑通实现对比我们按照下面这个目录结构来组织文件demo/ ├── spec.md ├── implementation_a.py ├── implementation_b.py ├── generate_tests.py ├── run_eval.py └── test_word_count.py # 由 generate_tests.py 生成5.1 定义功能规格 spec.md首先写清楚规格。这个文件会作为 prompt 的一部分发给 LLM所以质量直接影响生成测试的质量。# 单词统计功能规格 实现 count_words(text) 函数统计一段英文文本中每个单词出现的次数。 输入一个字符串 text。 输出一个字典键为单词小写值为出现次数。 规则 1. 单词由字母和数字组成。 2. 连续的非字母数字字符作为分隔符。 3. 大小写不敏感统一转为小写。 4. 空字符串返回空字典。 5. 不要统计空字符串作为单词。这个规格里最关键的是第 2 条连续的非字母数字字符作为分隔符。后面所有边界测试都会围绕这条规则展开。5.2 两个候选实现implementation_a.py使用正则表达式# 文件路径demo/implementation_a.py import re from collections import Counter def count_words(text): if not isinstance(text, str): raise TypeError(text must be a string) words re.findall(r[a-z0-9], text.lower()) return dict(Counter(words))implementation_b.py使用 split 加标点剥离# 文件路径demo/implementation_b.py def count_words(text): if not isinstance(text, str): raise TypeError(text must be a string) result {} for part in text.split(): word part.strip(.,!?;:\()[]{}).lower() if word: result[word] result.get(word, 0) 1 return result注意这两个实现引用了相同的函数名count_words但是位于不同模块。我们运行测试时需要能够在两个模块之间切换。5.3 编写 LLM 生成测试脚本generate_tests.py会读取spec.md调用大模型生成 pytest 测试代码并把生成结果写到test_word_count.py。# 文件路径demo/generate_tests.py import os from pathlib import Path from openai import OpenAI SPEC_FILE Path(spec.md) TEST_FILE Path(test_word_count.py) # 统一导入模板避免测试文件硬编码具体实现模块 IMPORT_TEMPLATE \ import importlib import os _impl_name os.environ.get(IMPL_MODULE, implementation_a) _impl importlib.import_module(_impl_name) count_words _impl.count_words PROMPT \ 你是一位资深测试工程师请根据下面的功能规格为 Python 函数 count_words(text) 编写 pytest 测试用例。 功能规格 {spec} 要求 1. 测试函数命名清晰可读性好。 2. 每个测试函数都要有明确的 assert 断言直接校验返回值。 3. 至少覆盖正常输入、空字符串、大小写混排、标点符号、数字与字母混排、边界情况。 4. 不要假设实现细节只依据规格断言行为。 5. 只输出 Python 代码不要包含任何解释性文字。 6. 不要包含 import 语句测试文件头部会由构建脚本统一添加。 测试代码如下 def generate_test_file(): spec SPEC_FILE.read_text(encodingutf-8) client OpenAI( api_keyos.environ.get(LLM_API_KEY), base_urlos.environ.get(LLM_BASE_URL), ) response client.chat.completions.create( modelos.environ.get(LLM_MODEL, gpt-4o-mini), messages[ { role: system, content: 你是资深测试工程师擅长根据规格生成高质量 pytest 测试。, }, { role: user, content: PROMPT.format(specspec), }, ], temperature0.2, max_tokens3000, ) generated response.choices[0].message.content.strip() # 去掉可能出现的代码块围栏 if generated.startswith(): lines generated.splitlines() lines lines[1:] if lines and lines[-1].strip().startswith(): lines lines[:-1] generated \n.join(lines) # 去掉 LLM 可能生成的 import 行再拼接统一导入模板 cleaned_lines [ line for line in generated.splitlines() if not line.strip().startswith((from , import )) ] final_code IMPORT_TEMPLATE \n \n.join(cleaned_lines) \n TEST_FILE.write_text(final_code, encodingutf-8) print(f[ok] 测试文件已生成{TEST_FILE}) if __name__ __main__: generate_test_file()这个脚本里有两个工程化处理值得注意。第一它要求 LLM 不要包含 import 语句并且在后处理时把可能出现的 import 行删掉然后统一拼接IMPORT_TEMPLATE。这样测试文件不会写死from implementation_a import count_words而是通过环境变量IMPL_MODULE动态选择待测模块。这个设计让我们能用同一份测试文件分别评测两个实现。第二它把temperature设为 0.2。生成测试不是一个创意写作任务我们更希望输出稳定、可复现。温度过高会导致每次生成的测试都不同结果对比就没有意义。5.4 编写评测对比脚本run_eval.py会分别用implementation_a和implementation_b运行同一份测试文件收集测试通过率并汇总。# 文件路径demo/run_eval.py import os import subprocess import sys import xml.etree.ElementTree as ET from pathlib import Path IMPLS [implementation_a, implementation_b] def parse_report(xml_path: Path): 解析 pytest 生成的 junitxml 报告。 tree ET.parse(xml_path) root tree.getroot() # pytest 的 junitxml 根节点就是 testsuite return { tests: int(root.attrib.get(tests, 0)), failures: int(root.attrib.get(failures, 0)), errors: int(root.attrib.get(errors, 0)), skipped: int(root.attrib.get(skipped, 0)), } def run_pytest(impl_name: str): 用指定实现模块运行同一份测试并生成告表。 env os.environ.copy() env[IMPL_MODULE] impl_name result_xml Path(fresult_{impl_name}.xml) cmd [ sys.executable, -m, pytest, test_word_count.py, -q, --disable-warnings, --junitxml, str(result_xml), ] print(f\n 运行实现{impl_name} ) subprocess.run(cmd, envenv, checkFalse) return parse_report(result_xml) def main(): results {} for impl in IMPLS: results[impl] run_pytest(impl) print(\n 对比结果 ) print(f{实现:20}{用例数:8}{失败:8}{错误:8}{跳过:8}) for impl, summary in results.items(): print( f{impl:20}{summary[tests]:8} f{summary[failures]:8}{summary[errors]:8}{summary[skipped]:8} ) if __name__ __main__: main()这个脚本用os.environ.copy()和env[IMPL_MODULE] impl_name来为 pytest 子进程传递环境变量不需要修改任何源文件。--junitxml会把结构化结果输出为 XML我们再用xml.etree.ElementTree解析出用例数、失败数、错误数。5.5 完整运行流程在demo目录下按顺序执行cd demo python generate_tests.py python run_eval.py第一步会调用模型生成测试文件第二步会分别运行两个实现并打印对比结果。6. 运行结果与效果验证按上述代码运行后generate_tests.py会输出类似下面的日志[ok] 测试文件已生成test_word_count.py然后run_eval.py会分别跑两次 pytest 运行实现implementation_a 12 passed in 0.12s 运行实现implementation_b 10 passed, 2 failed in 0.15s最终汇总结果是一个表格 对比结果 实现 用例数 失败 错误 跳过 implementation_a 12 0 0 0 implementation_b 12 2 0 0注意这里的“12 个用例”“2 个失败”只是示例预期。实际数量取决于 LLM 生成的具体测试但结构应该是稳定的实现 A 在边界测试上明显更稳实现 B 会暴露出与规格不一致的用例。失败用例往往集中在连字符、缩写词、数字与字母混排这几类输入上。你可以打开result_implementation_b.xml查看具体报错。例如FAILED test_word_count.py::test_hyphenated_word FAILED test_word_count.py::test_contraction_word看到这个结果最直接的反应是什么实现 B 在规格定义下不达标。这正好对应了标题里的判断LLM 生成的测试改变了哪个实现胜出。如果所有实现都通过通常有两种情况一种是真的都通过另一种是生成的测试集太弱根本没有覆盖有区分度的边界。后者更常见。这时候可以去检查生成的测试文件看看里面有没有边界用例断言是否足够精确。如果测试全是assert count_words(hello) {hello: 1}这种正常路径那说明 prompt 里的“覆盖边界情况”没有生效或者模型被某种风格偏好带偏了。7. 常见问题与排查思路这套流程在实际使用中会遇到一些典型问题下面按排查顺序整理成一个表格。问题现象可能原因排查方式解决方案LLM 生成的测试文件无法导入 pytest生成结果里包含解释文字或代码块围栏打开test_word_count.py查看头部和尾部在生成脚本里剥离代码块围栏并去掉非 import 前的解释语句所有实现都通过看不出差异测试集太弱只有正常路径用例检查生成的测试是否覆盖边界、异常场景修改 prompt明确要求覆盖缩写、连字符、数字混排等具体场景无法调用模型 API环境变量未设置或服务地址错误打印LLM_API_KEY、LLM_BASE_URL是否存在通过export设置环境变量确认服务端地址可达每次生成的测试都不一样temperature 过高检查生成脚本中的 temperature 参数将 temperature 设为 0.2 或更低实现模块找不到环境变量IMPL_MODULE没有传递到 pytest 子进程在 run_eval.py 中检查 env 设置确保使用env[IMPL_MODULE] impl_name并传入subprocess.run测试硬编码了某个实现LLM 生成了from implementation_a import ...检查测试文件头部在生成脚本后处理中删除所有 import 行并统一拼接导入模板网络请求超时规格太长或模型服务响应慢查看 API 调用日志缩短规格文本或调大 SDK 超时时间这些坑里最隐蔽的是“测试硬编码了实现模块”。如果不做导入模板替换LLM 生成十几个测试时可能有一部分写死from implementation_a import count_words另一部分写死from implementation_b import count_words。这样跑出来的结果毫无对比价值而且很难发现。这就是为什么生成脚本里要有一个强制的后处理步骤把所有 import 都清掉换成统一模板。8. 最佳实践与工程建议把“LLM 生成测试”用在实现方案对比上有几个经验值得直接沉淀到团队流程里。第一规格先行。测试生成的质量上限由规格文本决定。如果规格写得含糊比如只说“统计单词出现次数”不说分隔符规则、大小写规则、空输入行为LLM 就只能靠猜生成的测试自然会发散。在让 LLM 生成测试之前先花时间把规格写清楚。这是一次投入、长期收益的事情。第二断言必须具体。生成测试时要明确要求“直接校验返回值”而不是“调用函数后不报错”。只断言不抛异常的测试是没有鉴别力的。你甚至可以在 prompt 里强调每个测试必须写出预期字典不允许只用assert result is not None之类的弱断言。第三多策略生成测试。一次生成通常不够。可以分别用三种策略生成测试正常业务路径、边界条件、异常输入。然后把三组测试合并看实现方案的差异在哪个策略下最明显。如果你的实现切换后失败集中出现在“边界条件”这一组那说明问题不是实现能力而是实现边界处理不到位。第四让 LLM 解释测试设计。有时候你不仅需要测试代码还需要知道它为什么这么设计。可以在生成测试代码后再让模型用自然语言解释每个测试用例针对哪条规格规则。这样在代码评审时测试的设计意图可以直接作为评审材料。不过要注意如果格式要求严格这一步最好单独调用模型而不是和测试代码混在一起否则会污染可执行代码。第五不要用 LLM 生成的测试完全替代传统测试。LLM 生成测试适合作为评估增强手段尤其在“两个实现谁更符合规格”这个场景下非常有效。但项目的回归测试体系仍然需要人工维护需要与业务知识结合需要考虑数据漂移、历史缺陷回归等模型生成不出来的内容。更稳妥的做法是LLM 生成的测试作为评审阶段的一票而不作为唯一的一票。第六安全与合规意识。不要把未脱敏的业务数据、内部 API 密钥、敏感代码段发送给外部模型服务。如果项目处于安全敏感领域建议使用私有化部署的大模型服务。即使使用私有化服务也要在测试脚本里保持最小权限原则密钥通过环境变量注入不要写进代码仓库。第七保留可复现性。在团队里推广这套流程时最好把规格文件、模型版本、temperature 参数、生成脚本版本都记录下来。否则两周后再跑一次可能因为模型版本变化得到完全不同的测试集评审结论也就无法追溯。9. 总结与后续学习方向这篇文章的起点是一个很具体的问题测试集决定实现方案的胜负而 LLM 生成测试会改变测试集本身。我们通过一个词频统计的例子演示了如何用正则实现和 split 实现之间通过一组边界测试让原本“看起来同样合格”的实现分出高下。这个例子虽然简单但反映了一个通用规律实现方案评测不是一次性的结论而是测试集组成、断言强度和规格定义的函数。LLM 生成测试真正带来的不是“自动写代码”的便利而是让“多种评测角度快速落地”成为可能。你可以在一个下午生成五套不同策略的测试从五个角度对比两个实现这是传统手工评审很难做到的。下一步建议你拿一个正在评审的候选方案用本文的流程试一次。不要急着用 LLM 生成大量测试然后自动判断胜负而是先看生成的测试为什么这样设计再对照规格和实现找到真正有价值的分歧点。你会发现最大的收获往往不是“哪个实现赢了”而是“我们之前居然漏掉了这些边界场景”。如果想继续深入可以往三个方向走一是研究更复杂的测试生成策略比如结合历史缺陷、覆盖率反馈和变异测试二是将这套比较流程接入 CI让每次 PR 都自动生成测试并对比方案三是关注测试断言自动生成和测试质量评估方向因为这些决定了 LLM 生成测试的可靠性上限。测试生成的复杂度比代码生成更高但它的价值也更直接因为它直接影响你看待代码的方式。
返回列表