尧图精选

TDD+LLM协作实践:用测试安全网驯服庞杂代码库

🕒 发布时间:2026/9/9 15:18:46 📁 来源:尧图网络
这是我前阵子重构一个老订单模块的真实状态改了十分钟代码跑了二十分钟测试最后把改动全部回滚了。不是测试不通过而是我根本不确定自己改完这一处会不会把三个业务分支带偏。那一刻我意识到一个系统最可怕的不是“复杂”而是“庞杂”——复杂至少意味着结构可解庞杂则是改了哪里都不知道下一步会发生什么。所以我开始重新审视手上仅有的两件武器TDD以及最近一年深度使用的LLM。你可能会觉得奇怪TDD和LLM一个是老掉牙的开发纪律一个是新潮的AI工具它们能碰撞出什么但当我真正把两者放进同一个工作流之后我发现一个很有意思的结论LLM解决的是“写得快不快”的问题TDD解决的是“改得怕不怕”的问题而一个老码农在2024年之后最缺的恰恰是后者。这篇文章就是我这段探索过程的完整复盘包括思路、实战、坑和最后沉淀下来的日常流程适合所有对TDD有疑虑、又在摸索LLM辅助开发边界的人。1. 先说说“复杂”和“庞杂”在我代码库里意味着什么1.1 从“复杂”到“庞杂”的临界点很多开发者在聊技术债的时候喜欢把复杂和庞杂混为一谈但我的经验里它们是两码事。复杂是系统本身业务逻辑多、状态多、交互多但如果你能用一张状态图、几个关键时序把它讲清楚那它是“结构性的复杂”是健康的。庞杂则完全不同它意味着没有任何一个人能完整说出某个函数被哪些地方调用、某个字段为什么不能删、某段逻辑当初是为了满足哪个客户的需求写出来的。我记得很清楚临界点出现在一次人员流动之后。核心模块的作者走了交接文档是半年前的过时版本新来的同事在原有逻辑上打了三个补丁每个补丁都只覆盖了眼前的需求。从那以后这个模块每一次改动都像在雷区里穿行。测试不是没有但全是“断言不为空”“断言调用成功”这种摆设改完代码发现测试全绿该出的Bug一个没少。这就是“庞杂化”的典型特征测试数量在增长安全感在下降。后来我做过一次回溯发现真正让系统滑向庞杂的不是功能需求有多绕而是我们失去了对变化的快速反馈。没有可靠的测试网兜底改动一件大功能就得人工回归半天时间一紧张大家就绕过测试直接上线然后继续补丁叠补丁。所以要治庞杂不是先上微服务、不是引一堆新框架而是先把安全网补上TDD恰好就是干这个的。1.2 为什么复杂度一上来第一反应是测试先行早些年我对TDD的态度是敬而远之的总觉得测试先行是理想主义者的自我感动。但后来一个老前辈一句话点醒了我他说你想想你写代码最贵的环节是什么不是敲键盘那几分钟是搞清楚“这段代码应该有什么行为”的思考过程。TDD强迫你先把行为定义出来再谈实现等于逼着你在动手前把需求捋清楚。这个体会在做复杂业务模块时尤为明显。比如订单拆分这种需求如果直接上手写实现你的注意力会全部被“怎么拆”吸引很容易漏掉边界情况。但如果你先写测试把“同商家合并”“不同商家拆开”“金额精度处理”“空订单怎么处理”这些行为固化成可执行的断言你实际上是在用一种低成本的方式做需求分析。测试红了是你对期望行为的定义测试绿了是行为和实现达成一致。更关键的是TDD提供了一种“分而治之”的节奏感。复杂问题之所以让人恐惧是因为它看起来是一个巨大的、无法一口吞下的整体。但TDD逼着你拆分每一步产生一个失败的测试然后做最小的实现让它通过。这样每走一步系统的行为就增加一点点每一步都是可以验证、可以回退的。这种“小步前进”的模式恰恰是应对复杂性的核心方法论。2. LLM进场TDD的姿势得重新摆2.1 LLM不是替换TDD而是给红灯绿灯过程提速我刚接触LLM辅助编程时也和大多数人一样兴奋于“让它直接生成整个函数”。但是用了一段时间之后我发现这种方式有个致命问题LLM生成的代码看起来很像样但它不是你需求分析的产物它没有经过行为定义的约束。你让它实现一个订单拆分它给出了一个几十行的函数看着逻辑完整可你根本不知道它考虑没考虑边界条件于是你还得逐行审。后来我换了个思路不让LLM直接写实现而是让它先写测试。这一下子就把TDD的红灯阶段变成了火箭模式。你只需要用自然语言描述清楚期望行为LLM能在几秒钟内生成一版测试用例甚至帮你补足你压根没想到的边界条件。你拿到测试之后先自己审一遍删掉不合理的、补上遗漏的然后跑一遍——红了证明测试真的在约束行为然后再让LLM生成实现跑绿。这个过程最大的变化在哪里呢在于LLM把TDD里最“机械”的部分——从需求描述到测试代码的翻译过程——加速了但保留了最核心的“人判断期望行为”的环节。换句话说LLM负责把规则变成代码你负责定义规则本身。这正是TDD和LLM能够协同而不是互斥的根本原因。2.2 让LLM写测试用例的第一现场我第一次认真尝试这个流程时是在一个日志解析模块上。老模块严重缺少测试我花了十分钟向LLM描述需求输入是格式不定的日志行输出是结构化的日志对象需要处理时间格式化和字段缺失两种情况。LLM给了十几个测试用例覆盖了普通行、异常行、空字段、带时区的时间格式等场景。我第一反应是惊喜但很快冷静下来因为我知道LLM生成的测试有个通病它常常在“自说自话”。什么意思就是它假设了一个并不存在的接口然后为这个假设接口写测试。比如它可能测试一个名叫parse_log_compat的函数而你的正式函数叫parse。这不是大问题但意味着你必须把测试用例中的API名对齐到真实代码这实际上也是个很好的审查过程。我逐条删掉那些假设性太强的测试只保留符合现有接口的然后跑了一遍果然红了。那一刻我第一次觉得TDD原本那个“先写测试再写实现”的心理门槛被LLM大幅降低了。但这里要强调一个红线LLM生成的测试用例必须由人来把关。我见过有人直接把LLM生成的几十个测试全盘收下结果测试覆盖了一个根本不存在的业务场景导致后面实现被测试反向绑架写出了一堆迎合测试的死代码。所以我的原则是LLM负责广撒网人负责收敛边界。3. 一次完整的TDDLLM实战为一个订单拆分功能补测试3.1 红灯阶段让LLM生成失败测试拿我最近重构的订单拆分功能来演示一遍完整流程。业务需求很常见一笔订单里可能有多个商品分属不同商家系统需要按商家把原订单拆成多笔子订单每笔子订单汇总对应商家的商品金额。第一步我打开一个空的测试文件向LLM描述这个需求我给的Prompt大致是这样的订单拆分功能输入一个订单对象包含订单号和商品列表每个商品有商家ID、商品名、单价和数量。规则同一商家的商品合并到同一子订单子订单金额按单价乘数量累加金额保留两位小数没有商品的订单应当返回空列表。请用pytest写出测试用例覆盖正常拆分、同商家合并、跨商家拆分、空商品列表这些场景。LLM很快就给出一版测试代码大致长这样def test_split_order_by_merchant(): order Order( order_idORD-001, items[ Item(merchant_idM1, name苹果, price3.5, quantity2), Item(merchant_idM2, name香蕉, price2.0, quantity3), Item(merchant_idM1, name梨, price4.0, quantity1), ], ) sub_orders split_order_by_merchant(order) assert len(sub_orders) 2 m1_order [o for o in sub_orders if o.merchant_id M1][0] assert m1_order.total_amount 3.5 * 2 4.0 * 1 assert round(m1_order.total_amount, 2) 11.0这个测试很基础但确实覆盖了需求。我保留了同商家合并和跨商家拆分两条把空列表场景加进了参数化测试里。然后运行pytest报错split_order_by_merchant不存在红灯亮起。这符合TDD的预期先有失败后有实现。3.2 绿灯阶段最小实现与LLM注意点红灯亮起之后我让LLM写出“能通过测试的最简单实现”。这里有一个关键措辞——最简单的实现。因为如果你不加这个限制LLM会给你一个带缓存、带并发控制、带一大堆扩展点的“豪华版”实现。这不是绿灯阶段该做的事绿阶段的目标是让测试通过不是让系统完美。LLM返回的实现大致是def split_order_by_merchant(order): from collections import defaultdict merchant_items defaultdict(list) for item in order.items: merchant_items[item.merchant_id].append(item) result [] for merchant_id, items in merchant_items.items(): total round(sum(item.price * item.quantity for item in items), 2) result.append(SubOrder( order_idorder.order_id, merchant_idmerchant_id, itemsitems, total_amounttotal, )) return result我审了一遍发现两个问题一是空商品列表时循环里merchant_items为空返回空列表没问题这个满足需求二是我注意到LLM用了defaultdict但没处理订单本身为None的情况——虽然当前测试没有覆盖但我在红灯阶段没有定义这个行为所以先不管。跑一遍测试全绿。绿灯达成。所以这里我特别想强调一个反直觉的结论让LLM参与绿灯阶段时你反而要更警惕“设计过度”而不是“设计不足”。因为LLM的训练数据里有大量工业级代码它默认会带出很多工程化元素但这些在红灯阶段没有被约束加入了反而会让后续重构更难。你要做的是在让它写实现之前明确告诉它只需要让现有测试通过不需要额外抽象。3.3 重构阶段LLM辅助识别坏味道实现跑绿之后我进入重构阶段。这时LLM的角色变了我不再让它写代码而是让它当代码审查者。我把当前实现和测试代码一起给它让它指出重复逻辑、命名问题和潜在边界风险。LLM反馈了几个点第一个是金额计算逻辑在测试里重复了三次可以提取成辅助函数第二个是merchant_items这个命名不够精确应该用items_by_merchant更能表达字典的含义第三个是如果未来有优惠券按订单级别分摊当前按商家聚合并计算金额的方式可能需要调整建议在测试中补充一个注释说明当前假设。前两个我采纳了第三个我保留了测试注释但没有为此提前做抽象。这就是重构阶段和绿灯阶段的区别绿灯阶段只求功能红灯阶段定义行为重构阶段则在测试保护下优化结构。这一步也是TDD里最容易被忽略的很多人跑绿就结束了结果代码越来越臃肿直到无法维护。我也尝试过让LLM直接给出重构后的完整版本然后对比原实现。效果不错但有个前提重构后必须再跑一次全量测试确保行为没有偏移。LLM在重构时偶尔会把某个变量名改漏导致运行时错误测试就是抓住这个错误的最后一道防线。4. 踩过最深的几个坑LLM生成的测试不是金科玉律4.1 断言形同虚设测试绿了但没测到东西和LLM协作最危险的一件事不是它生成错误代码而是它生成了一堆“看似正确但实际没用”的测试。我印象最深的一次它给某个函数写了这样一个断言def test_parse_log_returns_object(): result parse_log(2024-01-01 10:00:00 ERROR something failed) assert result is not None这个测试跑起来必然绿但它验证了什么呢什么都没有。它既没有检查时间字段解析得对不对也没有检查错误级别是否被正确提取。这种测试的唯一作用是让你在跑测试时产生一种虚幻的安全感。更糟的是它还会占用覆盖率指标让你误以为“行覆盖够了”。后来我的做法是每接受一条LLM生成的测试都问自己一个问题——如果实现代码变得完全错误这个测试能不能发现如果不能它就属于装饰性测试。对于这类测试要么补充强断言要么直接删掉。宁可测试少而精不要多而废。4.2 过度mock把实现细节锁死在测试里另一个高频坑是LLM特别喜欢用mock。你让它测一个函数它常常会把依赖全部mock掉比如把数据库连接、外部HTTP调用、消息队列全部mock成假对象然后只测试一个空壳逻辑。有次它生成的一个测试里把订单服务依赖的库存接口mock后返回固定值然后断言订单状态变成“已支付”。表面看测试通过了但商品的真实扣减逻辑完全没被覆盖。更麻烦的是这个测试还把“库存接口被调用了一次”写进了断言。一旦重构代码把库存检查从服务层挪到领域层这个测试就会破碎而破碎的原因不是行为变了而是调用方式变了。这其实是测试与实现细节的过度耦合。我的建议是让LLM在生成测试时优先使用真实对象和真实依赖只在确实需要时才mock且mock的边界应该控制在“外部系统”而不是“内部逻辑”。如果你发现测试里mock了太多自己项目内部的类那大概率不是测试的问题而是代码结构本身耦合度太高了。4.3 上下文窗口与“幻觉测试”LLM的长处是能理解你贴给它的上下文但问题恰恰出在这里在大型项目里你不可能把全部代码都塞进上下文。于是LLM只看到了你给它的局部信息然后基于局部信息“脑补”出它认为合理的测试数据或函数签名。我遇到过一次特别典型的“幻觉测试”我让它为一个配置解析模块补测试它基于一个不存在的配置键名max_retry_count写了断言。这个键名在真实配置里叫retry_limit但LLM根本没机会看到那部分代码因为它不在上下文里。它只是基于训练数据中常见的命名模式“合理推断”出来的。这类问题防不胜防唯一的办法是人工审查每一条测试里的资源名、函数名和字段名和真实实现逐一核对。还有一个更隐蔽的变体LLM生成了对某个函数的测试这个函数在现有代码里确实存在但LLM对这个函数行为方式的假设是基于类似项目的模式而不是基于你项目实际实现的语义。比如它假设order.total_amount是实时计算的属性而实际上是一个从数据库读取的字段——这在测试外不会暴露但一旦你跑生成测试和真实逻辑的对照就会发现问题。4.4 版本换代导致的隐性失效如果你像我一样工具链里既有LLM辅助也有测试框架你还会遇到一个时代特有的坑LLM生成测试时可能忽略当前项目依赖库的版本差异。比如它给你生成的是pytest 7.x风格的tmp_path_factory用法但项目里的pytest是6.x很多API签名不一致导致测试直接报错。这种问题的根源在于LLM的训练语料包含不同时期的代码它不会自动适配你项目里的依赖版本。我的应对方案是在让LLM写测试的Prompt里明确附上项目依赖的关键版本信息比如pytest版本、Python版本、是否使用Django等。另外跑完LLM生成的测试后我还会专门看一下是否有“ImportError”或“TypeError”这类因为版本不匹配导致的低级错误这些通常一眼就能排查掉但如果疏漏了会浪费不少时间。5. 给同样想尝试的人的几条落地建议5.1 先从测试生成开始而不是让LLM直接写实现这是我最想传达的一条建议。很多人在接触LLM辅助开发后第一反应是“让AI写整个模块”结果代码写得飞快但自己完全没跟上节奏最后陷入“AI生成的代码只有AI能重构”的窘境。我建议的路径完全不同先让LLM做测试生成你来做行为定义。逼着自己先用TDD的思考方式把需求拆解清楚再让LLM把测试翻译成代码。这个过程有几个额外的好处第一你被迫锻炼需求分析能力第二你会在红灯阶段验证测试的约束力第三当LLM生成实现时它是在满足你的测试约束而不是自由发挥。经过一段时间后你会发现你并不需要LLM帮你写所有代码你只需要它在“从规则到代码”这一步上加速而把“从需求到规则”这一步牢牢握在自己手里。5.2 用领域语言约束LLM避免测试变成实现复读机LLM生成测试时有一个让人头疼的倾向它倾向于用和实现相同的术语和方式写断言。比如实现里用了total_amount字段测试里也直接断言total_amount等于多少。这看起来没问题但实际上是在复读实现细节而不是验证业务行为。更好的做法是在描述需求时使用领域语言。比如对于订单拆分我描述需求时会说“同一商家的商品应该汇总到同一物流单元里”而不是说“按照merchant_id字段分组”。这样LLM生成的测试会倾向于验证业务结果而不是验证实现步骤。测试的落脚点应该是“订单被正确拆分了”而不是“某个字段被正确赋值了”。这个微小的措辞差异决定了你的测试是业务契约还是实现快照。业务契约在重构时保持稳定实现快照在重构时第一个粉身碎骨。5.3 把LLM当成结对编程伙伴而不是拐杖最后一个建议比较务虚但我觉得是最重要的心态建设。你看待LLM的方式决定了你使用它的姿势。如果把它当拐杖你会倾向于“它给我什么我就用什么”省力但失控。如果把它当结对编程伙伴你会倾向于“它给我建议我来裁决”多花一些审查时间但始终掌握方向。TDD本身是一套决策流程LLM加速了这个流程里的翻译和检索环节但它没有替代决策本身。我现在的状态是让LLM负责跑腿它生成测试、提出重构建议、解释不认识的代码片段我负责把关决定哪些行为是期望的、哪些边界需要考虑、哪些抽象是值得引入的。这个分工下我的编码速度可能只比纯手动快百分之二三十但系统的可维护性和我的掌控感是纯手动时代也比不了的。6. 我现在的日常工作流长什么样6.1 一个可复制的“测试-实现-重构”循环经过这段探索我沉淀了一套固定的工作流写在这里供你参考。第一步拿到需求后先用自然语言写一个行为清单不一定很长三五条就行。然后把清单发给LLM让它生成测试文件和用例覆盖正常路径和已知边界。第二步人工审查测试重点核对接口名、字段名和断言强度删掉装饰性测试补充你想到的边界情况。第三步运行测试确认红灯这一步的意义是验证测试真的在约束行为。第四步让LLM生成满足测试的最简实现你可以给它看一下现有代码的上下文但要求它只用最少代码让测试通过。第五步跑测试到绿灯然后立刻进入重构模式让LLM审查当前实现和测试指出坏味道和改进建议你筛选后利用测试保护做重构。这个循环跑得多了以后你会有个明显的感觉开发的节奏感回来了。以前一大块需求让人心里发怵现在一个测试一个测试地推每一步都能看到结果焦虑感自然降低。6.2 认知负荷管理庞杂的真正解法说回标题里那个词——“庞杂”。我认为庞杂不只是代码层面的问题更是认知层面的问题。当系统大到没有任何一个人能同时理解所有部分时开发就变成了摸着石头过河。而TDD加LLM这个组合无意中成了一种认知负荷管理工具。一方面TDD把问题拆成了小块让每一次思考只聚焦在一个行为上这在心理上大大降低了“这个模块太大了我搞不定”的畏难情绪。另一方面LLM接手了“生成代码骨架”“查找API”“生成测试用例”这些占用工作记忆的任务让我能腾出更多脑力去处理真正需要判断力的部分。我也开始重建那些老模块的测试但不追求一次搞定。我给自己的规则是每天在维护现有功能的同时挑一个老模块的核心函数用这个工作流补上第一组测试。把红灯点亮就是一个好的开始。几个月下来最痛的那个模块终于有了一组像样的测试网虽然距离“完全健康”还很远但至少我不再害怕改它了。这个过程中我最大的体会是工具再好也替代不了“小步前进、随时验证”的纪律。TDD给了我纪律的框架LLM给了我坚持纪律的底气。两者结合让我这样一个写了很多年代码的老家伙终于找到了一种对抗系统熵增的可持续方式。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →