☰
用TDD红绿重构循环让AI写出高质量代码和测试
2026/10/27 17:16:29 网站建设 项目流程

1. 为什么AI写的代码总像“半成品”

1.1 一个普遍到让人麻木的现象

你让AI写一个函数,它三秒钟就吐出来了,逻辑看起来还挺像那么回事。你让它写一个模块,它也能给你搭出个骨架。但你要是说“顺便把测试也写了”,它要么给你生成一堆assert True,要么写几个根本跑不通的用例,要么干脆告诉你“测试可以根据实际需求自行补充”。

这个现象太普遍了,普遍到很多人已经默认“AI写代码就是这样的”。但如果你仔细想想,这其实很不合理——AI能理解业务逻辑、能处理边界条件、能写出复杂的算法,为什么偏偏写不好测试?

我一开始也以为是模型能力问题,后来用多了才发现,根本原因在于训练数据的分布。GitHub上开源项目的代码和测试比例大概是10:1甚至更夸张,很多项目压根没有测试。AI在海量代码上训练,它学到的是“代码长什么样”,而不是“测试该怎么写”。更关键的是,测试代码的“正确性”和业务代码不一样——业务代码跑通了就是跑通了,测试代码写错了可能照样通过,但它在测一个错误的东西。

所以AI写测试的默认策略就是:能跑就行,别报错就行。它不会主动去想“这个边界条件覆盖了吗”“这个异常路径测了吗”“这个mock是不是太宽了”。它就像一个刚入行的新人,你让他写测试,他就写几个happy path交差。

1.2 红-绿-重构到底在约束什么

TDD的“红-绿-重构”循环,很多人以为只是一个开发流程,其实它本质上是一套认知约束机制。这三个步骤分别对应三种心理状态:

  • 红:先写一个失败的测试。这一步强制你想清楚“我要什么”。测试失败意味着你的预期是明确的,你知道什么是对的、什么是错的。
  • 绿:用最少的代码让测试通过。这一步强制你克制过度设计的冲动。不要想“以后可能会用到”,只解决当前这一个失败。
  • 重构:在测试保护下优化代码结构。这一步强制你持续改善,而不是等到代码烂透了再重写。

把这套流程套在AI身上,效果出奇地好。因为AI最擅长的就是“给定明确目标,生成满足条件的输出”。你让它自由发挥写测试,它不知道你要什么;你给它一个失败的测试,它就知道“哦,我要让这个变绿”。

1.3 这套方法适合谁用

如果你满足以下任意一条,这篇文章就是写给你的:

  • 用AI辅助写代码,但每次都要花大量时间手动补测试
  • 团队里推行TDD推不动,想看看AI能不能帮忙降低门槛
  • 已经在用AI写代码,但代码质量不稳定,改一处崩三处
  • 对TDD感兴趣但觉得“太慢”,想找一个更务实的落地方式

我自己的情况是:日常用AI辅助开发,项目不大但迭代频繁,之前经常出现“AI改了一个函数,另一个地方莫名其妙挂了”的情况。后来把红-绿-重构的循环套在AI工作流里,代码稳定性明显上来了,而且总体耗时反而更短——因为省掉了大量调试和回归的时间。

2. 把TDD循环拆解成AI能执行的指令

2.1 为什么不能直接让AI“写个测试”

直接说“给这个函数写测试”,AI的默认行为是:读一遍函数,然后生成几个调用示例,断言返回值等于某个值。它不会去分析这个函数的契约——输入域是什么、输出域是什么、哪些输入是合法的、哪些是非法的、异常情况怎么处理。

更麻烦的是,AI会“顺着”你的代码写测试。如果你的代码有bug,它写的测试很可能把这个bug当成预期行为。比如你写了一个除法函数,没处理除数为零的情况,AI生成的测试可能压根不测除零,或者测了但断言的是“抛出异常”——它觉得这样也合理。

所以关键不是“让AI写测试”,而是让AI先写一个失败的测试。失败意味着测试的预期和当前代码的行为不一致,这个不一致就是你要解决的问题。

2.2 红阶段:用提示词逼AI暴露预期

红阶段的核心任务是:在写任何实现代码之前,先定义一个可执行的预期。这个预期必须满足三个条件:

  1. 可执行:能跑起来,不是伪代码
  2. 会失败:当前代码跑不过,或者压根没有对应实现
  3. 失败原因明确:失败信息能告诉你缺什么

我常用的提示词模板是这样的:

我要实现一个函数,功能是:[一句话描述] 输入:[输入类型和约束] 输出:[输出类型和约束] 异常情况:[列出所有已知的异常场景] 请先不要写实现代码。请写一个测试文件,包含以下测试用例: 1. 正常路径:至少2个典型输入 2. 边界条件:输入域的边界值 3. 异常路径:每个异常场景至少1个用例 测试框架用[pytest/jest/junit等]。 每个测试用例的命名要能说明它在测什么。

这个提示词的关键在于把“写测试”拆成了“定义预期”。AI不需要猜你要什么,你明确告诉它输入输出和异常场景,它只需要把这些翻译成测试代码。

举个例子。我要写一个“计算订单折扣”的函数,提示词是这样的:

我要实现一个函数 calculate_discount(order_amount, user_level, coupon_code) 功能:根据订单金额、用户等级和优惠券计算最终折扣价 输入: - order_amount: 正数,单位元 - user_level: 枚举值,'normal' | 'silver' | 'gold' - coupon_code: 字符串,可能为空 输出:折扣后的金额,保留两位小数 异常情况: - order_amount <= 0 时抛出 ValueError - user_level 不在枚举范围内时抛出 ValueError - coupon_code 无效时忽略优惠券,按用户等级折扣计算 请先写测试,不要写实现。

AI生成的测试里,正常路径会测普通用户无券、金卡用户有券;边界条件会测金额刚好为0、刚好为0.01;异常路径会测负数金额、非法等级、无效券码。这些测试跑起来全是红的——因为实现还没写。

注意:红阶段的测试必须真的跑一遍,确认它们失败。如果某个测试意外通过了,说明要么测试写错了,要么已经有部分实现存在。这两种情况都需要处理。

2.3 绿阶段:让AI用最小改动通过测试

绿阶段最容易踩的坑是:AI会“顺便”把代码写得很漂亮。你让它让测试变绿,它给你生成一个包含设计模式、异常处理、日志记录的完整实现。结果就是——测试是绿了,但代码复杂度上去了,后面重构反而更麻烦。

所以绿阶段的提示词要加一个强约束:

当前测试全部失败。请写最少的代码让所有测试通过。 要求: 1. 不要添加测试未覆盖的功能 2. 不要做性能优化 3. 不要添加日志、注释以外的额外逻辑 4. 如果某个测试无法通过,说明原因,不要强行修改测试

这个约束的本质是把AI的“过度设计倾向”压下去。AI的训练数据里充满了“最佳实践”,它天然倾向于写“完整”的代码。但在TDD循环里,绿阶段的唯一目标是“通过测试”,其他都是重构阶段的事。

我实测下来,加了这条约束之后,AI生成的实现代码平均行数减少了40%左右,而且后续重构的难度明显降低——因为代码简单,改起来不容易出错。

2.4 重构阶段:在测试保护下让AI优化结构

重构阶段是很多人忽略的环节。测试绿了就觉得完事了,代码能跑就行。但AI写的“最小实现”往往有很多重复、硬编码、不合理的抽象。这时候需要让AI在测试不变的前提下优化代码。

提示词可以这样写:

当前所有测试通过。请重构实现代码,要求: 1. 不修改任何测试文件 2. 消除重复逻辑 3. 提取合理的函数/类 4. 保持所有测试通过 重构后请重新运行测试,确认全绿。

这里的关键是“不修改测试文件”。一旦允许改测试,AI就可能通过修改测试来“通过”重构,这就破坏了TDD的核心约束。

重构阶段我一般会让AI做2-3轮。第一轮消除明显的重复,第二轮提取抽象,第三轮检查命名和结构。每轮之后都跑一遍测试,确保没有回归。

3. 完整实操:从零实现一个折扣计算模块

3.1 项目背景和初始状态

假设我们要实现一个电商折扣计算模块,需求如下:

  • 输入:订单金额、用户等级、优惠券码
  • 输出:折扣后金额
  • 规则:
    • 普通用户:无折扣
    • 银卡用户:95折
    • 金卡用户:9折
    • 优惠券:满100减20,满200减50
    • 优惠券和等级折扣可叠加,但优惠券优先计算
    • 金额为0或负数抛异常
    • 非法等级抛异常
    • 无效优惠券忽略

初始状态:只有一个空的discount.py文件,没有任何实现。

3.2 第一轮红-绿-重构

红阶段:我用上面的提示词让AI生成测试。AI输出了test_discount.py,包含12个测试用例。跑一遍,全部失败,报错是NameError: name 'calculate_discount' is not defined。这符合预期。

绿阶段:我让AI写最小实现。AI生成了一个约30行的函数,用if-else处理各种情况。跑测试,12个全绿。

重构阶段:我让AI重构。AI把折扣计算拆成了_apply_level_discount和_apply_coupon两个辅助函数,主函数变成编排逻辑。测试仍然全绿。

这一轮下来,代码从0变成了一个可用的模块,而且测试覆盖了所有已知场景。

3.3 第二轮:补充边界和异常

第一轮完成后,我检查了一下测试覆盖,发现几个遗漏:

  • 优惠券刚好满100但不满200的情况
  • 优惠券叠加后金额为负的情况(比如订单50元,用了满100减20的券)
  • 用户等级大小写敏感问题

我把这些场景补充到提示词里,让AI生成新的测试。新测试跑起来,有3个失败——说明当前实现确实没处理这些情况。

绿阶段:AI修改实现,增加了金额下限保护、大小写归一化。测试全绿。

重构阶段:AI把优惠券规则抽成了一个配置字典,方便后续扩展。测试仍然全绿。

3.4 第三轮:性能与可读性优化

到这一步,功能已经完整了。但我发现代码里有一些重复的金额格式化逻辑,而且异常信息不够明确。我让AI做最后一轮重构:

  • 提取_format_amount函数
  • 异常信息包含具体数值
  • 添加类型注解

测试全绿,代码可读性明显提升。

整个流程走下来,从空文件到完整模块,总共花了大约40分钟。其中AI生成代码的时间不到5分钟,大部分时间花在检查测试覆盖、确认失败原因、验证重构结果上。这个时间分配是合理的——TDD的核心价值就在于把时间花在“想清楚”上,而不是“改bug”上。

4. 实操中遇到的坑和排查方法

4.1 AI写的测试“假绿”

这是最常见的问题。AI生成的测试看起来通过了,但实际上什么都没测。比如:

def test_calculate_discount(): result = calculate_discount(100, 'normal', '') assert result is not None # 这测了个寂寞

这种测试跑起来是绿的,但它没有验证任何业务逻辑。排查方法是:故意把实现改错,看测试会不会红。如果改错了测试还是绿的,说明测试无效。

我一般的做法是,在绿阶段之后,随机修改实现里的一个常量或分支,跑一遍测试。如果有测试没红,就把它标记出来重写。

4.2 AI在绿阶段“偷懒”

有时候AI为了让测试通过,会直接在实现里硬编码测试的预期值。比如测试里断言calculate_discount(100, 'gold', '') == 90,AI就在实现里写if order_amount == 100 and user_level == 'gold': return 90。

这种实现能通过当前测试,但换一个输入就挂了。排查方法是:在红阶段多写几个不同输入的测试。如果AI硬编码,它需要为每个输入写一个分支,代码会变得明显不合理。

更根本的解决办法是在绿阶段的提示词里加一句:“实现必须对所有合法输入都正确,不能针对特定测试值硬编码。”

4.3 重构阶段测试被“顺手”改了

AI在重构时,有时候会觉得“这个测试写得不好”,然后顺手把测试改了。这破坏了TDD的核心约束——测试是契约,不能随便改。

解决办法是在重构提示词里明确写“不修改任何测试文件”,并且在重构后检查git diff,确认测试文件没有变化。如果AI确实觉得测试有问题,应该单独提出来,由人来决定是否修改。

4.4 常见问题速查表

问题现象可能原因排查方法解决方式
测试全绿但代码有bug测试覆盖不足或断言太弱故意改错实现,看测试是否变红补充边界和异常测试
AI硬编码测试值绿阶段约束不够检查实现里是否有魔法数字增加测试输入多样性
重构后测试失败AI改了测试或实现有回归检查git diff回滚测试文件,重新重构
测试跑不起来依赖缺失或语法错误看报错信息让AI修复测试代码
红阶段测试意外通过已有部分实现或测试写错检查实现文件和测试逻辑删除意外通过的测试或修正预期

提示:每次红-绿-重构循环结束后,建议提交一次git commit。这样如果后续重构出问题,可以快速回滚到上一个稳定状态。

5. 让AI真正理解“测试先行”的提示词工程

5.1 提示词的结构化设计

经过多轮实践,我总结出一个比较稳定的提示词框架,分四个部分:

第一部分:角色和约束

你是一个严格遵循TDD的开发者。你的工作流程是: 1. 先写测试,确认失败 2. 写最小实现,让测试通过 3. 在测试保护下重构 不要跳过任何步骤。不要在没有失败测试的情况下写实现代码。

第二部分:当前状态

当前项目状态:[描述已有代码和测试] 当前测试结果:[全绿/有失败/无测试] 当前任务:[红/绿/重构]

第三部分:具体指令

根据当前阶段,给出明确的输入输出和约束。

第四部分:验证要求

完成后请: 1. 运行所有测试 2. 报告测试结果 3. 如果有失败,说明原因

这个框架的好处是,AI不需要猜“我现在该干什么”,每一步都有明确的边界。

5.2 不同阶段的提示词差异

红阶段的提示词重点在定义预期:

  • 明确输入域和输出域
  • 列出所有异常场景
  • 要求测试命名清晰
  • 要求先不写实现

绿阶段的提示词重点在约束范围:

  • 只让当前失败的测试通过
  • 不添加未覆盖的功能
  • 不做性能优化
  • 不修改测试

重构阶段的提示词重点在保护契约:

  • 不修改测试文件
  • 消除重复
  • 提取抽象
  • 保持测试全绿

这三个阶段的提示词不能混用。我试过用一个提示词让AI“写测试并实现”,结果AI直接跳过了红阶段,写了一个实现然后补了几个测试——测试倒是绿的,但完全没有起到“测试先行”的约束作用。

5.3 多AI协作的分工模式

如果你用多个AI工具(比如一个写测试、一个写实现),可以这样分工:

  • AI-A(测试AI):只负责根据需求写测试,不接触实现代码
  • AI-B(实现AI):只负责让测试通过,不修改测试
  • AI-C(重构AI):只负责优化实现,不碰测试

这种分工的好处是职责隔离。测试AI不知道实现细节,所以不会“顺着”实现写测试;实现AI不能改测试,所以必须真正让测试通过。

我实测下来,这种分工模式比单AI循环更稳定,但沟通成本稍高。适合对代码质量要求较高的场景。

6. 这套方法在实际项目中的效果

6.1 代码质量的变化

我用这套方法跑了三个小项目,对比之前“让AI直接写代码”的方式,几个关键指标的变化:

指标直接让AI写红-绿-重构变化
测试覆盖率约30%约85%+55%
回归bug数量平均每迭代3-5个平均每迭代0-1个显著下降
代码review时间每次约30分钟每次约15分钟减半
重构信心低(怕改坏)高(有测试保护)明显提升

测试覆盖率的数据来自pytest-cov,回归bug数量来自迭代后的bug统计。样本量不大,但趋势很明显。

6.2 开发节奏的变化

最大的感受是节奏变稳了。以前用AI写代码,经常是“写得快、改得也快、崩得也快”。一个功能写完,手动测几下觉得没问题,过两天发现另一个地方挂了。

用了红-绿-重构之后,每个功能都有对应的测试。改代码之前先跑一遍测试,全绿再改;改完再跑一遍,还是全绿就说明没破坏现有功能。这个反馈循环让开发节奏从“冲刺-调试-冲刺”变成了“小步快跑”。

6.3 什么情况下不适合这套方法

这套方法不是万能的。以下几种情况我建议不要硬套:

  • 探索性原型:如果你还在验证“这个方向行不行”,写测试的时间可能比写代码还长。这时候先跑通再说。
  • UI/交互密集型:测试UI的视觉和交互成本很高,TDD的收益不明显。可以用截图对比或手动测试替代。
  • 一次性脚本:跑完就扔的脚本,写测试纯属浪费时间。
  • 遗留代码改造:没有测试的老代码,先补测试再改,但补测试的过程可能很痛苦。建议先补关键路径的测试,不要追求全覆盖。

注意:TDD是一种工具,不是信仰。该用的时候用,不该用的时候别硬套。判断标准很简单:如果写测试的时间超过了它节省的调试时间,就不值得。

7. 从“逼AI写测试”到“让AI主动测试”

7.1 把TDD循环固化成工作流

一开始我是手动走红-绿-重构的每一步,后来发现可以把整个循环写成一个脚本或工作流。比如用Makefile定义三个目标:

test-red: pytest tests/ -x --tb=short test-green: pytest tests/ -x --tb=short refactor: pytest tests/ -x --tb=short # 运行lint和类型检查

然后让AI按顺序执行:先跑test-red确认失败,再写实现,再跑test-green确认通过,再重构,再跑refactor确认全绿。

这个工作流的好处是每一步都有明确的验证点。AI不需要“记住”自己在哪个阶段,它只需要看当前测试是红还是绿。

7.2 让AI自己判断“该写什么测试”

当AI对TDD流程足够熟悉之后,可以尝试让它自己判断测试覆盖是否充分。提示词可以这样写:

当前实现已完成,测试全绿。请分析: 1. 哪些边界条件没有被测试覆盖? 2. 哪些异常路径没有被测试覆盖? 3. 哪些测试的断言太弱,可能漏掉bug? 针对每个发现的问题,写一个新的测试用例。 新测试应该失败(因为当前实现没有覆盖这些情况)。

这个提示词让AI从“被动写测试”变成“主动找漏洞”。我试过几次,AI确实能发现一些我忽略的边界情况,比如浮点数精度问题、空字符串处理、并发场景等。

7.3 长期维护中的测试策略

项目跑起来之后,测试会越来越多。这时候需要定期清理和优化:

  • 删除冗余测试:有些测试测的是同一个逻辑,保留一个就够了
  • 合并相似测试:用参数化测试替代多个重复的测试函数
  • 标记慢测试:把耗时长的测试单独标记,只在必要时运行
  • 定期检查覆盖率:覆盖率下降说明新代码没写测试

我一般每两周做一次测试清理,把冗余和过时的测试删掉。测试文件不是越多越好,关键是每个测试都有明确的验证目标。

8. 一些个人体会和实用建议

8.1 不要追求“一步到位”

我一开始想让AI一次性生成完整的测试套件,结果发现质量很差。后来改成“每轮只关注一个场景”,比如第一轮只测正常路径,第二轮补边界,第三轮补异常。每轮都走完整的红-绿-重构,质量明显好很多。

这其实符合TDD的本意——小步前进,持续反馈。AI擅长执行明确的指令,你把大任务拆成小任务,它的表现会好很多。

8.2 测试命名比测试代码更重要

AI生成的测试,函数名经常是test_case_1、test_case_2这种。这种命名在测试失败时完全看不出问题在哪。我一般会要求AI用描述性命名,比如:

def test_gold_user_with_valid_coupon_gets_stacked_discount(): ... def test_negative_order_amount_raises_value_error(): ...

好的测试命名本身就是文档。测试失败时,看名字就知道哪个场景挂了。

8.3 保留“红”的证据

每次红阶段跑完,我会把失败信息截图或保存下来。这不是为了留档,而是为了确认失败原因和预期一致。如果失败信息是ImportError而不是AssertionError,说明测试压根没跑起来,需要先修测试。

这个习惯帮我避免了很多“假红”——测试确实失败了,但失败原因不对,绿阶段就会走偏。

8.4 最后分享一个小技巧

如果你觉得每次写提示词太麻烦,可以把红-绿-重构的提示词模板保存成代码片段,用的时候直接调用。我用的是VS Code的snippet功能,输入tdd-red就自动展开红阶段的提示词模板,输入tdd-green展开绿阶段的。这样每次启动新循环只需要几秒钟。

另外,如果你用的是支持自定义指令的AI工具,可以把TDD流程写进系统提示词里,这样AI每次都会默认按这个流程走,不需要每次重复说明。

这套方法我用了大半年,最大的感受是:AI不是不会写测试,而是需要你告诉它“先写测试”。红-绿-重构这个循环,本质上是在给AI一个明确的、可验证的工作节奏。一旦节奏建立起来,AI的产出质量会稳定很多。

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

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

立即咨询