1. 为什么我会想到让 AI 来写单测
先说背景。我手上有个内部业务系统,Python 后端,历史包袱挺重:核心模块跑了三年多,功能迭代一直没停,但单元测试覆盖率常年徘徊在 35% 到 40% 之间。每次版本发版前,QA 同学都要手工回归一大堆用例,效率低,漏测风险还高。领导某天丢了一句“把单测补一补”,这活儿就落到了我头上。
一开始我是打算硬写的。但看了两天代码,心态直接崩了。问题不在于“不会写”,而在于“量太大、太琐碎”:一个订单状态机模块光分支就有 60 多个,一个外部接口封装类要 mock 的依赖有七八个。按我手速,一天能稳定产出 100 行有效单测就算不错了,按这个速度,光把核心模块补到 80% 覆盖率,至少得三周。这个时间成本,项目等不起。
后来我转变思路,为什么不试试 AI 辅助?当时我手头正好在用 AI 编程助手写业务代码,体验还不错,那让它写测试代码是不是也行?抱着试试看的心态,我跑了一轮小实验:挑了个工具函数模块,让 AI 根据函数签名和注释生成 pytest 用例,我再人工复核。结果出乎意料,AI 生成的用例覆盖面比我预想的广得多,边界条件、异常分支它都会主动考虑到。那个模块的覆盖率从 52% 直接拉到了 94%。
这一下就勾起了我的兴趣。我意识到,写单测这件事本质上就是一个“读代码、理解逻辑、设计输入输出”的过程,而对大模型来说,这种模式化、逻辑性强的任务恰恰是它的强项。于是我做了一个更完整的实验计划:用 AI 辅助重写和补全核心模块的单测,目标是把整体覆盖率从 40% 提升到 80% 以上。最终结果确实做到了,整体覆盖率提升了整整 40 个百分点。这篇文章就是整个过程的完整复盘。
引申一句,为什么选择 pytest?其实业界可选方案不少,unittest、nose、pytest 各有拥趸。但在 AI 辅助写单测这个场景下,pytest 的优势非常明显:fixture 机制让依赖注入写起来极其简洁,assert 断言不需要写一堆 self.assertEqual,AI 生成代码时出错概率更低;再加上 pytest-cov 插件能直接输出覆盖率报告,整个“生成—运行—分析—再生成”的循环非常顺畅。如果你还在用 unittest 写测试,我建议你认真考虑迁到 pytest,这个迁移成本很低,但后续收益非常大。
2. 建立基线:先把"目标"和"现状"量化清楚
2.1 覆盖率基线怎么跑才准确
任何优化工作,第一步永远是摸清现状。很多同学上来就闷头写测试,写完一看覆盖率,发现统计口径不对,白忙活。我在这次项目中踩过这个坑,所以单独说一下。
我用的是 pytest-cov 插件。安装很简单:
pip install pytest-cov然后在项目根目录的pytest.ini或pyproject.toml里加上配置。我用的是pyproject.toml:
[tool.pytest.ini_options] addopts = "-v --cov=. --cov-report=term-missing --cov-report=html --cov-config=.coveragerc" testpaths = ["tests"]这里有几个关键点需要解释:
--cov=.表示对整个项目做覆盖率统计,而不是只统计被测试文件。--cov-report=term-missing会在终端输出哪些行没有被覆盖,这个信息在后续迭代中非常重要。--cov-report=html生成 HTML 报告,用浏览器打开可以逐行查看覆盖情况。--cov-config=.coveragerc指定 coverage.py 的配置文件,用来排除不需要统计的文件。
.coveragerc文件我建议这样写:
[run] branch = True source = . [report] exclude_lines = pragma: no cover def __repr__ if self.debug: if __name__ == .__main__. raise AssertionError raise NotImplementedErrorbranch = True一定要打开,它会把分支覆盖率也统计进去。很多团队只看行覆盖率,这其实是不够的,一个if语句两个分支只测了一个,行覆盖率是 100%,但分支覆盖率只有 50%。既然要补测试,就从一开始就把标准定高一点,我这次的目标同时包含行覆盖率和分支覆盖率。
跑完基线之后,我的项目情况是:整体行覆盖率 38.7%,分支覆盖率 31.2%。这个数据挺难看的,但也意味着提升空间巨大。
2.2 梳理被测代码,区分"优先攻击"和"暂不处理"
拿到覆盖率报告后,不要急着开写。先花一个小时理清楚:哪些模块值得投入,哪些模块可以直接跳过。
我当时的筛选原则有三条:
- 核心业务逻辑优先:订单状态机、价格计算、权限校验这类模块,逻辑复杂、出 bug 影响大,优先级最高。
- 基础设施和外部依赖强的模块优先:缓存封装、消息队列封装、HTTP 客户端封装,这类代码改动频繁,没有测试保护等于裸奔。
- 展示层和模板渲染代码暂不处理:Django 视图函数、Jinja2 模板这类代码并不适合单测来覆盖,更多是依赖集成测试。硬写单测性价比极低。
根据这个原则,我把项目的测试目标锁定在 8 个核心模块上,这 8 个模块加起来占了整体代码量的 42%,但贡献了项目里绝大多数历史 bug。把这块硬骨头啃下来,整体覆盖率自然就上去了。
3. AI 写单测的工作流:从 prompt 设计到人工审核
3.1 核心思路:不要让 AI 自由发挥,用模板约束它
试过 AI 写代码的朋友应该都有感受,AI 写出来的东西乍一看很有道理,仔细一看全是问题。它的单测尤其如此——测试名起得很好,结构也很完整,但断言经常是错的,mock 经常对不上,甚至会为了通过测试而偷工减料。
所以我总结了第一原则:绝对不要让 AI 凭空写测试,它写的前提是"我提供该函数的签名、文档字符串、关键实现逻辑和依赖关系"。信息越充分,AI 生成的测试越可靠。
实际执行中,我设计了一套 prompt 模板,每次生成单测前先填充模板:
请基于以下信息,生成 pytest 单元测试。 【被测模块】 模块路径:order_service.py 函数名:create_order(user_id, items) 函数说明:根据用户 ID 和商品列表创建订单,返回订单对象。如果商品库存不足,抛出 InsufficientStockError。 【依赖与上下文】 - 数据库模型:Order, OrderItem, User - 外部服务:inventory_client.check_stock(item_id) -> int,返回商品剩余库存 - 当前用户余额通过 user_service.get_balance(user_id) 获取 - create_order 内部逻辑: 1. 校验 user_id 是否存在 2. 校验 items 是否为空列表 3. 遍历 items,调用 inventory_client.check_stock 检查库存 4. 如果库存不足,抛出 InsufficientStockError 5. 如果库存充足,计算总价,调用 user_service.get_balance 校验余额 6. 余额不足抛出 InsufficientBalanceError,否则创建订单并返回 【要求】 1. 使用 pytest fixture 构建测试所需的依赖 2. 对 inventory_client 和 user_service 使用 unittest.mock 进行 mock 3. 覆盖正常路径、库存不足、余额不足、用户不存在、items 为空这 5 种场景 4. 每个测试函数的 docstring 说明测试意图 5. 断言要具体,不要只断言没有异常,要断言订单对象的属性值这个模板看起来很长,但它值得。AI 就像一个很聪明的实习生,你给的背景越详细,它的产出越靠谱。第一次跑这个模板时,AI 生成的测试文件基本能直接通过 pytest,这让我很惊喜。
还有一点要说明,你提供的“函数说明”和“内部逻辑”不一定要完全精确到每一行。你可以从代码里复制核心逻辑,也可以按自己理解写个大概。AI 会根据这些信息去生成对应的 mock,如果实际逻辑和描述有偏差,测试跑挂了,它会给出失败信息,你再根据失败信息去修正描述。这个交互过程其实就是人机协同的精髓。
3.2 三种典型场景的 prompt 差异
不同复杂度的代码,prompt 的设计侧重点完全不同。我总结了三类,分别写一下:
场景一:纯函数、无外部依赖
这种最简单,比如一个日期格式化函数、一个金额计算函数。prompt 里只需要提供函数签名、输入输出示例和要求覆盖的边界条件。
请为以下纯函数生成 pytest 测试。函数没有外部依赖。 函数签名:def calculate_discount(price: float, member_level: str) -> float 函数行为:根据会员等级计算折扣价。normal=9折,vip=8折,svip=7折。price<=0 时抛出 ValueError。 要求:覆盖所有会员等级、price=0、price为负数、超大数值等边界情况,共至少 8 个测试函数。这种场景下 AI 生成的成功率极高,基本一次就能跑通,而且边界条件想得比人还全。有时候它还会主动加上浮点数精度断言,考虑得挺周到。
场景二:类方法、有实例状态和属性依赖
这种就要关注self和实例方法之间的调用关系。prompt 里要明确说明有哪些实例变量、它们在__init__里怎么初始化、被测方法会修改哪些属性。
请为以下类的 process 方法生成 pytest 测试。 类名:OrderProcessor __init__ 初始化 self.items=[],self.total=0,self.status='pending'。 process(self, item_ids: list[int]) 方法逻辑: 1. 遍历 item_ids,调用 self._fetch_item(item_id) 获取商品 2. 将商品加入 self.items,并累加价格到 self.total 3. 设置 self.status='processed' 4. 如果 item_ids 为空,status 置为 'empty',抛出 ValueError _fetch_item 是私有方法,需要 mock。 要求:mock self._fetch_item,分别测试正常流程、空列表流程、_fetch_item 抛出异常时 process 的行为。这种场景下注意提醒 AI 使用unittest.mock.patch.object来 mock 实例方法,因为很多 AI 会习惯性地 mock 整个类,导致被测的类也被 mock 掉,测试就失去了意义。
场景三:有外部依赖(数据库、网络、消息队列)
这是最复杂的场景,也是 AI 最容易翻车的。我的经验是:不要指望 AI 能同时 mock 好所有外部依赖,它经常会出现漏 mock 或者 mock 错路径的情况。这种场景我建议把任务拆小,一次只让它处理一个依赖点。
请为 save_order 函数生成 pytest 测试。 函数签名:def save_order(order: Order) -> int 函数说明:将订单写入数据库,返回订单 ID。写入前检查 order.id 是否存在,存在则执行更新,不存在则执行插入。 依赖:使用全局 db_session 对象与数据库交互,db_session.query(Order).filter(...).one_or_none() 查询,db_session.add() 添加,db_session.commit() 提交。 要求:使用 unittest.mock.patch 分别 mock db_session 的 query、add、commit 方法。覆盖两种情况:id 存在时调用 update,id 不存在时调用 add。这里有个细节值得注意,prompt 里我明确指定了 mock 的对象和方式。如果你只说“mock 数据库操作”,AI 可能会抛出一个类似于“使用 pytest-mock 创建 mocker fixture”的方案,这也没问题,但风格可能和你项目里现有测试不一致。为了保持项目统一,我一直用unittest.mock.patch风格,并在 prompt 里明确要求。一致性很重要,因为后续维护测试代码的是人。
3.3 循环工作流:生成、运行、分析、反馈
AI 写单测不是一个一次性的动作,而是迭代过程。我最终跑通的循环是:
- 选取一个函数,按模板编写 prompt。
- AI 生成测试代码,我粘贴到项目 test 文件中。
- 运行 pytest,查看失败项。
- 把失败信息粘贴回 AI,让它修正(注意不是只贴报错信息,要把相关代码也贴过去)。
- 测试通过后,查看该文件的覆盖率报告,确认覆盖到了预期分支。
- 如果覆盖率报告显示有未覆盖的行,把这些行号对应的代码片段复制给 AI,让 AI “补测”。
整个流程看起来是“AI 在写”,实际上穿插了大量人工反馈。这个过程中我觉得自己是“主编”,AI 是“写手”,写手跑偏了,主编得及时拉回来。
具体操作时,我会开两个窗口:一个编辑器窗口,一个 AI 对话窗口。在编辑器中复制代码,在对话窗口中粘贴并补充需求描述,等 AI 回复后把代码贴回编辑器。项目大点时一次处理十个以上函数,我一般会批量化操作:挑一个文件,把里面所有待测函数都丢给 AI,让它一次生成一整个测试文件。整体效率比一个一个问要高很多。
不过批量化操作有个坑:AI 上下文一长,容易出现前面 mock 了 A 函数,后面忘了 mock B 函数的情况。所以我更推荐按文件粒度分块,一个文件一处理,而不是把所有文件一股脑全塞进去。
4. 覆盖率提升 40% 的具体执行记录
4.1 首轮:集中火力啃下工具类模块
我的执行计划分为三轮。第一轮目标是“技术复杂度低但覆盖缺口大”的工具函数模块。
我先选了utils/date_utils.py,这个文件大概 200 行,包含了日期格式化、日期范围计算、工作日判断等函数。都是纯函数,依赖很少,非常适合测试 AI 的稳定产出能力。
我给 AI 准备的 prompt 基本就是前面说的场景一模板,针对每个函数分别要了测试。AI 一次性生成了 34 个测试函数,覆盖了正常路径、边界值(比如 2 月 29 日、跨年、时区问题)、异常输入(None、空字符串、非法格式)。
运行后有一半测试直接通过,另一半挂了。挂的原因主要有三类:
- 浮点数精度问题,AI 断言
2.675 * 100 == 267.5挂了。这个测试其实很经典,列出来正好说明 AI 对浮点数的敏感度还不够。 - 时区问题,AI 测试里构造本地时间时用了
datetime.now(),但函数内部是用datetime.utcnow()的,两边对不上。 - 函数对
None的处理方式和 AI 预期的异常类型不一样,AI 预期抛ValueError,但代码里实际抛的是TypeError。
我没有直接改 AI 的代码,而是把这些失败信息一股脑贴回 AI,在对话里加了一句:“请根据 pytest 失败信息,修正测试代码,确保和被测函数的行为一致”。AI 会自己分析原因,修正断言和 mock 方式。这一轮下来,date_utils 这个文件的覆盖率从 45% 提到了 93%。
4.2 第二轮的成果:每个文件的覆盖率记录
第二轮目标转向核心业务模块,这时候 AI 产出的测试开始不稳定了,经常出现“测试本身跑通,但根本没有覆盖到关键分支”的情况。
我举一个典型的例子。订单状态机的transition方法大概有 70 行,内部有 5 个状态之间的转换规则。AI 生成的测试只测了两个状态之间的正常跳转,对于非法状态转换、重复转换、条件不满足时的行为都没有覆盖。如果不看覆盖率报告,这段测试一眼看过去还挺像样的,但实际覆盖率只有 35%。
解决方法是把覆盖率报告中的missing lines复制给 AI,让 AI 看具体哪些行没覆盖到,然后补充针对这些行的测试。这样来的效率非常高。AI 看到“第 42 行没有覆盖”之后,会自动脑补出对应场景,比如“42 行是if self.status == 'cancelled'的异常分支,应该在测试中构造一个 cancelled 状态的订单并尝试 transition”。
我以第二轮运行完的节点做了一次整体覆盖率统计,整体行覆盖率已经到了 71%,分支覆盖率到了 63%。此时距离开工只过了三个工作日。坦白说这个进度比我预期的快很多,如果纯手写,我估计这个状态至少需要十天。
4.3 第三轮:补遗与防回归
覆盖率到了 70% 以上之后,继续无脑“补测试”的性价比开始下降。因为剩下没覆盖的行,往往是一些极难构造的分支,比如异常处理分支、日志记录分支、极端并发场景等。
这轮的策略转向“查漏补缺”,分两步走:
第一步,针对覆盖率报告里每份文件最后未覆盖的行,逐个检查,判断这些分支是否为“真需要覆盖”。有些属于防御性编程,比如except Exception: log.error(),测不测都行,加了一行# pragma: no cover就可以排除;有些是核心逻辑的必要分支,就继续让 AI 补。
第二步,也是我从这轮学会的一个重要操作:让 AI 检查已有测试代码的质量,而不是一味追求新增测试。把已经生成的测试文件贴回给 AI,让它找问题。AI 能找出不少断言不够严格、mock 过度、测试函数命名不规范的问题。这种“代码审查”用起来很舒服,因为 AI 对测试代码本身的理解能力比业务代码更强。
最终,三轮干完,整体行覆盖率从 38.7% 提升到了 80.4%,净增 41.7 个百分点;分支覆盖率从 31.2% 提升到了 67.8%。核心 8 个模块的行覆盖率都在 85% 以上,有 3 个模块超过了 95%。
下面是我记录的几个典型模块覆盖率前后对比:
| 模块 | 初始行覆盖率 | 最终行覆盖率 | 主要覆盖内容 |
|---|---|---|---|
| order_service.py | 36% | 92% | 订单创建、状态流转、异常分支 |
| payment_gateway.py | 28% | 88% | 支付回调、签名校验、超时处理 |
| user_auth.py | 51% | 97% | 登录、权限校验、token 刷新 |
| inventory_client.py | 42% | 90% | 库存查询、库存扣减、连接异常 |
| cache_manager.py | 19% | 84% | 缓存命中/未命中、过期、熔断 |
| date_utils.py | 45% | 93% | 日期计算、格式化、时区处理 |
| price_calculator.py | 57% | 96% | 折扣计算、税费、满减策略 |
| message_queue.py | 23% | 86% | 发送、消费、重试、死信队列 |
看到这些数字,说实话是有点成就感的。而且这不只是数字好看,后面发版时 QA 同学反馈说,回归测试的 bug 数量明显减少,几个之前反复出现的老问题,在发版前就被单测拦住了。
5. 常见问题与避坑清单
AI 写单测并不是银弹,实际操作中遇到了一堆问题。这些问题如果不处理,AI 产出再多测试代码也没用,反而会增加错误测试的维护负担。
5.1 AI 常见“翻车”场景
翻车一:AI 生成的测试在“假装覆盖”。
这是最坑的一个问题。AI 有时会用with pytest.raises(Exception)包住一个根本不触发异常的函数调用,测试通过了,但什么都没测到。还有一种情况,它 mock 了整个被测类,然后直接调用 mock 对象的方法,这种测试跑得通,但不产生覆盖。
我后来养成了一个习惯:每批测试生成完,必然打开覆盖率报告确认。如果某个函数显示“测试通过但覆盖率没有提升”,基本可以断定它写的测试没有真正执行到被测代码,这种测试直接删掉,重新生成。
翻车二:mock 路径不对。
Python 的 mock 路径非常容易搞错。比如被测代码是from utils.http_client import request,那么在测试里 mock 时要用patch('utils.http_client.request'),而不是用patch('request')。AI 经常在这种路径细节上出错,它会自以为聪明地 mock 了一个同名但完全不对的路径。
检查逻辑也很直接:如果 mock 路径错了,mock 不会生效,测试运行时还是会发起真实请求。我会观察测试运行时间和是否访问了外部资源来判断。
翻车三:AI 过度依赖大而全的 fixture。
这个问题出现在我让 AI 一次处理整个文件时。AI 会倾向于把所有依赖都变成 fixture,结果导致一个测试函数依赖 5 个 fixture,每个 fixture 都做了很多初始化。看起来结构清晰,实际上耦合度极高,改一个 fixture 所有测试全崩——而且 AI 自己生成多个测试文件时,可能在两个文件里定义了同名的 fixture,导致测试互相污染。
我的处理方式是:在 prompt 里明确要求“fixture 只在需要时使用,优先使用局部变量构造测试数据”。对于简单场景,直接用局部变量,不搞 fixture,这样测试可读性更高。
翻车四:断言的强度不够。
AI 生成的测试断言经常偏弱。比如“订单创建成功后,assert order is not None”,这只能证明create_order没有抛异常,没有验证 order 的属性值是否正确。一个合格的订单测试,至少要断言订单号、总金额、状态、商品明细这些字段。
我在 prompt 里专门加了一条硬性要求:“每个测试函数的断言至少验证两个以上的返回值属性”,这样能逼着 AI 写更具体的断言。我自己 review 测试代码时,也会重点检查断言的强度,看到只断言 not None 的直接改掉。
翻车五:AI 不会主动清理测试产生的垃圾数据。
这个在有真实依赖的测试里尤其明显。AI 生成的测试里如果包含了对数据库的写操作,测试跑完会把测试数据留在库里,多次运行后数据越积越多。我在 prompt 里加了一段提示:“测试结束后清理所有测试数据,删除或回滚。”但 AI 大概率会忽略。所以最终还是要靠 fixture 的yield之后清理逻辑,或者用事务回滚。
5.2 我的八条实操经验
这些经验是我这次项目中最值钱的部分,整理成清单:
- 一定要先跑基线覆盖率,目标不是拍脑袋想出来的,是从报告里推算出来的。
- prompt 里提供的信息越详细越好,不要嫌麻烦。每次生成前花两分钟填模板,生成的测试质量能翻倍。
- 一个文件一个文件处理,不要一次性把整个项目的代码都丢给 AI。
- 永远不要直接相信 AI 生成的测试,必须人工 review,重点看断言强度和 mock 路径。
- 覆盖率报告是指导 AI 生成测试的“地图”,用
term-missing展示的具体未覆盖行来引导 AI 修正。 - 测试代码的质量比数量重要。删除一个“假装覆盖”的测试,比新增十个无效测试更有价值。
- 让 AI 审查已有的测试代码,它找出的测试设计问题往往比人更全面。
- 不要追求 100% 覆盖率。最后几个百分点通常性价比极低,把时间省下来投入到其他更有价值的测试层级(比如接口测试和集成测试)。
5.3 什么时候该停,AI 的边界在哪
做了这个项目之后,我对 AI 在测试领域的能力边界有了更清晰的认识。
AI 擅长的是:基于已有代码生成模式化的测试用例、快速覆盖已知分支、根据失败信息修正测试代码、辅助审查测试代码的整体结构、生成边界值测试数据。这些任务都有一个共同特点:逻辑清晰、模式固定、反馈明确。
AI 不擅长的是:理解复杂的业务意图、设计端到端的测试策略、判断某个测试是否真正贴合业务需求、处理需要“跨模块理解”的复杂依赖场景。在这些场景下,AI 生成的测试要么流于表面,要么根本没法跑通。
如果你准备在团队里推广这个工作流,我建议从“工具函数模块”开始试点,先让大家在低风险区域积累经验和 prompt 模板,然后再逐步扩大范围。团队里如果有人对 AI 生成结果的质量有疑虑,最好先做一个小范围对比实验:同一批函数,一半人工写测试,一半 AI 写测试,对比覆盖率、测试通过率和 review 时间。有了数据,容易达成共识。
另外,AI 生成的测试代码也是一段需要维护的代码。我建议在测试文件头部的 docstring 里注明“由 AI 辅助生成,人工 review 后使用”,并留下 review 人的名字和日期。这样后续如果测试出问题,追溯起来会很方便。项目里有几处 AI 生成的测试因为业务逻辑变更而删改,如果之前没有这个标注,后期维护的人看到风格怪异的测试代码,大概率会一头雾水。
从我个人的实际体验来看,AI 写单测最大的价值不是“替代”我写测试,而是把我从重复劳动里解放出来,让我把精力集中在测试策略和复杂场景设计上。以前我写单测,一半时间在敲键盘,一半时间在想“这个函数还有哪些分支没覆盖”。现在键盘部分交给 AI,思考部分我留着,效率和产出质量都有了本质提升。