首页 / 资讯中心 / 文章详情

用AI写单元测试,我把覆盖率从38%提升到80%

用AI写单元测试,我把覆盖率从38%提升到80% ★ FEATURED ARTICLE
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.assertEqualAI 生成代码时出错概率更低再加上 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-reportterm-missing --cov-reporthtml --cov-config.coveragerc testpaths [tests]这里有几个关键点需要解释--cov.表示对整个项目做覆盖率统计而不是只统计被测试文件。--cov-reportterm-missing会在终端输出哪些行没有被覆盖这个信息在后续迭代中非常重要。--cov-reporthtml生成 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 函数行为根据会员等级计算折扣价。normal9折vip8折svip7折。price0 时抛出 ValueError。 要求覆盖所有会员等级、price0、price为负数、超大数值等边界情况共至少 8 个测试函数。这种场景下 AI 生成的成功率极高基本一次就能跑通而且边界条件想得比人还全。有时候它还会主动加上浮点数精度断言考虑得挺周到。场景二类方法、有实例状态和属性依赖这种就要关注self和实例方法之间的调用关系。prompt 里要明确说明有哪些实例变量、它们在__init__里怎么初始化、被测方法会修改哪些属性。请为以下类的 process 方法生成 pytest 测试。 类名OrderProcessor __init__ 初始化 self.items[]self.total0self.statuspending。 process(self, item_ids: list[int]) 方法逻辑 1. 遍历 item_ids调用 self._fetch_item(item_id) 获取商品 2. 将商品加入 self.items并累加价格到 self.total 3. 设置 self.statusprocessed 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 存在时调用 updateid 不存在时调用 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.py36%92%订单创建、状态流转、异常分支payment_gateway.py28%88%支付回调、签名校验、超时处理user_auth.py51%97%登录、权限校验、token 刷新inventory_client.py42%90%库存查询、库存扣减、连接异常cache_manager.py19%84%缓存命中/未命中、过期、熔断date_utils.py45%93%日期计算、格式化、时区处理price_calculator.py57%96%折扣计算、税费、满减策略message_queue.py23%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思考部分我留着效率和产出质量都有了本质提升。
阅读完成 · 觉得有帮助?
咨询建站