AI 辅助写单测:如何覆盖边界,而不是凑覆盖率

149 阅读15分钟

在这里插入图片描述

开篇

让 AI 写单测,最常见的用法是:

请为这个方法生成单元测试。

几秒钟后,AI 可能会生成一组看起来很完整的测试:

  • 一个正常输入测试。
  • 一个空值测试。
  • 一个异常测试。
  • 一个成功返回测试。

测试数量不少,代码也能运行。

但这并不代表测试真的有价值。

很多测试只是把实现代码重新描述了一遍:

输入有效参数
调用方法
断言返回成功

它们可能让覆盖率上升,却没有回答真正重要的问题:

  • 业务规则是否被验证?
  • 状态不允许时是否会拒绝操作?
  • 重复请求会不会产生重复数据?
  • 外部依赖失败后,数据是否保持一致?
  • 权限不足时,敏感操作是否被阻止?
  • 修复一个线上问题后,测试能否防止它再次出现?

上一篇文章,我们讨论了如何把排障上下文交给 AI。

排障结束后,最有价值的动作之一,就是把已经确认的故障条件转成自动化测试。

这篇文章重点讨论:

如何让 AI 帮你设计真正覆盖边界的单测,而不是帮你凑出一个漂亮的覆盖率数字。

本文不会讨论什么

单元测试不是测试工作的全部,也不是覆盖率越高越好。

本文不会:

  • 认为 100% 覆盖率就代表代码没有问题。
  • 建议为每一行代码机械地生成一个测试。
  • 用 Mock 代替所有真实依赖验证。
  • 认为 AI 生成的测试天然符合业务预期。
  • 把测试数量、断言数量和测试价值画等号。

本文关注的是一套更实用的思路:

先识别业务风险
  ↓
再列出边界场景
  ↓
设计可观察结果
  ↓
让 AI 生成测试
  ↓
执行并审查测试

一、覆盖率高,为什么仍然可能没有覆盖风险

假设有这样一个下单方法:

public OrderResult createOrder(CreateOrderCommand command) {
    validate(command);
    Coupon coupon = couponService.find(command.getCouponId());
    Money amount = priceCalculator.calculate(command, coupon);
    Order order = orderRepository.save(command, amount);
    eventPublisher.publish(new OrderCreatedEvent(order.getId()));
    return OrderResult.success(order.getId());
}

AI 可能很快生成以下测试:

1. 有效商品和优惠券,创建成功。
2. command 为空,抛出异常。
3. couponService 返回优惠券。
4. orderRepository 被调用一次。
5. eventPublisher 被调用一次。

这些测试能够覆盖主流程和部分分支。

但它们可能遗漏:

  • 优惠券已经过期。
  • 优惠券不属于当前用户。
  • 商品价格在查询后发生变化。
  • 库存不足但订单已经写入。
  • 数据库写入成功,事件发布失败。
  • 同一个请求重复提交。
  • 当前用户没有创建订单权限。
  • 订单创建成功但返回响应时连接断开。

覆盖率解决的是“代码是否被执行过”。

它不一定解决“系统是否在危险情况下表现正确”。

所以我会把测试目标拆成三层:

层次要回答的问题
执行覆盖这段代码是否被测试走到?
行为覆盖正常和异常行为是否符合预期?
风险覆盖关键边界和线上风险是否被证明?

真正有价值的单测,至少要达到第二层;涉及核心业务时,还要尽量覆盖第三层。

二、先从需求和风险生成测试场景

不要一上来让 AI 读取方法并生成测试代码。

第一步应该是让它先设计测试场景:

请先不要生成测试代码。

请根据以下需求和验收标准,设计测试场景:

需求:
用户可以使用优惠券创建订单。

规则:
- 只能使用当前用户拥有的优惠券。
- 已过期优惠券不能使用。
- 已使用优惠券不能重复使用。
- 库存不足时不能创建订单。
- 订单创建失败时不能产生已扣库存状态。
- 同一个幂等键重复提交,最终只能创建一个订单。

请按以下类别输出:
1. 正常流程。
2. 参数边界。
3. 业务状态。
4. 权限安全。
5. 依赖失败。
6. 并发和幂等。
7. 数据一致性。

每个场景包含:前置条件、操作、预期结果、需要验证的副作用。

这一步的输出应该是一张场景表,而不是测试代码。

示例:

类别场景预期结果重点验证
正常有效优惠券和充足库存创建成功订单和事件各一次
状态优惠券已过期创建失败不写订单、不扣库存
权限使用他人优惠券拒绝请求不泄露优惠券信息
依赖库存服务超时返回可识别错误不产生部分成功
幂等相同幂等键并发提交只有一个订单数据库只有一条记录

三、边界不是“传个 null”这么简单

AI 生成测试时,最容易把边界理解成空值和负数。

但业务边界通常还包括状态、时序、数据量和系统依赖。

1. 输入边界

  • null、空字符串和空集合。
  • 最小值、最大值和超长值。
  • 非法格式和特殊字符。
  • 重复元素和数量过大。

2. 状态边界

  • 未创建、处理中、已完成、已取消。
  • 已过期、已使用、已删除。
  • 当前用户拥有和不拥有。
  • 资源存在和不存在。

3. 时间边界

  • 刚好到期。
  • 到期前一秒和到期后一秒。
  • 跨时区和跨天。
  • 重试窗口和超时窗口。

4. 依赖边界

  • 返回空结果。
  • 返回错误码。
  • 超时。
  • 部分成功。
  • 重复响应。
  • 依赖版本或配置不一致。

5. 并发边界

  • 两个相同请求同时到达。
  • 第一次请求成功但响应丢失。
  • 消息被重复消费。
  • 数据在查询和写入之间被其他请求修改。

可以让 AI 对已有测试做一次“边界反查”:

请不要根据测试数量评价质量。

请反向检查当前测试集还缺少哪些边界:
1. 输入边界。
2. 状态边界。
3. 时间边界。
4. 依赖失败边界。
5. 并发和幂等边界。

每个遗漏场景请说明:
- 为什么它重要。
- 可能造成什么后果。
- 应该观察什么结果。

四、让 AI 写测试前,先告诉它“应该观察什么”

测试不是为了让方法执行一次,而是为了证明结果正确。

因此,在生成测试代码前,我会先定义可观察结果。

1. 返回值

断言返回订单号、状态和错误类型符合预期。

2. 数据状态

断言成功时订单被写入,失败时订单没有新增或状态没有错误变化。

3. 依赖调用

断言库存预占成功后才允许写订单。
断言优惠券校验失败时不会调用扣库存。

4. 消息和事件

断言成功只发布一次事件,失败不发布错误事件。

5. 日志和审计

断言安全操作产生审计记录,但不包含完整手机号和令牌。

可以使用下面的 Prompt:

请为下面的测试场景设计“可观察结果”,暂时不要写代码。

请分别说明:
1. 应该断言什么返回值。
2. 应该检查什么数据库状态。
3. 哪些依赖应该被调用或不应该被调用。
4. 是否需要检查事件、缓存、日志或审计。
5. 哪些结果不能只通过 Mock 验证。

这一步可以减少只断言 notNull、只断言方法被调用一次这类低价值测试。

五、AI 生成单测时,最容易出现的 6 个问题

1. 测试和实现绑定得太紧

如果测试大量验证私有方法、内部变量和具体调用顺序,代码一重构,测试就全部失效。

更好的测试优先验证:

  • 对外行为。
  • 业务结果。
  • 重要副作用。
  • 失败后的状态。

2. 只验证“调用了”,不验证“调用是否正确”

下面的断言价值有限:

verify(repository).save(any());

还应该确认:

  • 保存的是哪个用户的数据。
  • 金额和状态是否正确。
  • 保存发生在什么条件下。
  • 失败时是否根本不应该调用。

3. Mock 返回值过于理想

AI 经常让所有依赖都返回成功结果:

库存成功、支付成功、数据库成功、消息成功。

这只能覆盖最顺利的主流程。

要主动要求它生成:

  • 空结果。
  • 异常。
  • 超时。
  • 错误码。
  • 重复响应。
  • 部分成功。

4. 断言过于宽松

例如:

assertNotNull(result);

这无法证明状态、金额、错误类型和资源状态正确。

断言应该尽量描述业务结果,而不是只证明“返回了一个对象”。

5. 测试数据没有表达业务含义

如果测试中到处都是:

"test", 1, 2, true, null

读者很难知道这些值为什么重要。

建议使用有意义的数据构造:

CreateOrderCommand command = commandOf(
    userId("user-42"),
    couponId("coupon-expired"),
    quantity(2)
);

6. 测试只验证当前实现,没有验证需求

如果实现里错误地把“优惠券不存在”当作“无需优惠”,而测试也按照这个逻辑断言,测试就会把缺陷固定下来。

所以测试场景应该先来自需求和验收标准,再来自代码分支。

六、用一个真实场景设计测试矩阵

假设有一个“修改手机号”服务:

输入:用户 ID、新手机号、旧手机号验证码
规则:
- 只能修改当前用户自己的手机号。
- 验证码必须正确且未过期。
- 新手机号不能被其他用户占用。
- 修改成功后发送安全通知。
- 修改失败时原手机号保持不变。

可以先让 AI 设计矩阵:

场景前置条件操作预期结果副作用
正常修改验证码正确,新手机号未占用提交修改修改成功发送一次通知
验证码错误验证码错误提交修改返回验证码错误手机号不变
验证码过期验证码过期提交修改返回验证码过期手机号不变
越权修改请求用户不是当前用户提交修改拒绝访问不查询敏感信息
手机号占用新手机号已被占用提交修改返回业务错误手机号不变
重复提交相同请求重复提交连续提交行为符合幂等规则通知不重复
并发修改两个请求修改同一用户并发提交只有一个结果生效数据不覆盖错误
通知失败手机号已保存,通知服务失败提交修改按约定返回或补偿记录失败状态

注意最后一行。

“手机号已保存,但通知失败”到底算成功还是失败,不应该由测试作者自行决定,而应该先确认业务规则。

七、让 AI 先生成测试设计,再生成测试代码

我建议把过程拆成两轮。

第一轮:测试设计

请根据需求、验收标准和现有代码,设计测试矩阵。

要求:
- 暂时不要写测试代码。
- 先覆盖正常、边界、状态、权限、异常、并发和幂等场景。
- 每个场景写清前置条件、操作、预期结果和副作用。
- 标记哪些规则尚未确认。
- 按风险优先级排序。

第二轮:生成测试代码

请根据已经确认的测试矩阵生成单元测试。

要求:
1. 遵循当前项目已有测试风格。
2. 优先验证业务行为和重要副作用。
3. 不要为了覆盖率测试私有实现细节。
4. 对外部依赖明确区分成功、空结果、异常、超时和重复响应。
5. 测试名称要表达业务场景和预期结果。
6. 每个测试只验证一个主要行为。
7. 生成代码后,说明每个测试覆盖了哪条业务规则。

如果 AI 直接生成了测试代码,也可以让它回头生成“测试与规则映射表”,检查是否有重要规则没有对应测试。

八、把线上故障转成回归测试

Day 16 讲过,排障时应该记录:

  • 触发条件。
  • 实际现象。
  • 时间线。
  • 关键日志。
  • 最终根因。

这些内容可以直接转换为回归测试:

请根据下面的线上故障记录,设计一个最小回归测试。

线上现象:
用户重复点击提交后,创建了两条订单。

已确认根因:
请求在查询幂等记录和插入订单之间存在竞态。

请说明:
1. 测试前置数据。
2. 并发或重复请求的触发方式。
3. 最终必须断言的数据状态。
4. 该测试应该放在单元、集成还是端到端层。
5. 如果单测无法可靠复现,还需要什么更高层测试。

一个好的回归测试,不只是证明“这次 Bug 修好了”,还要尽量保留当时的触发条件。

九、单测、集成测试和端到端测试怎么分工

不是所有边界都适合用单测覆盖。

场景更适合的测试层
参数校验和纯业务计算单元测试
Service 与 Repository 的事务行为集成测试
数据库唯一约束和并发写入集成测试或并发测试
消息发布与消费集成测试
第三方接口真实协议契约测试或集成测试
完整用户流程端到端测试

可以让 AI 帮你判断测试层级:

请判断下面每个测试场景适合放在哪一层:单元、集成、契约或端到端。

判断依据包括:
1. 是否需要真实数据库约束。
2. 是否需要真实事务边界。
3. 是否需要消息或网络交互。
4. 是否需要验证多个模块协作。
5. 是否需要真实用户入口。

不要为了运行速度把必须依赖真实系统行为的场景强行改成单测。

十、如何判断一组 AI 生成的测试是否有价值

我会从下面几个角度检查:

[ ] 每个关键业务规则都有至少一个测试场景
[ ] 测试不只有成功路径
[ ] 边界覆盖了输入、状态、时间、依赖和并发
[ ] 失败时的数据状态也有断言
[ ] 重要副作用有明确验证
[ ] Mock 没有把真正要验证的行为全部替换掉
[ ] 断言表达业务结果,而不是只断言对象不为空
[ ] 测试数据能看出业务含义
[ ] 测试名称能说明场景和预期
[ ] 线上故障已经转成回归测试
[ ] 需要真实数据库或消息的场景没有被错误地伪装成单测
[ ] 测试失败时能帮助定位问题

最后一项尤其重要。

如果测试失败后只能看到“expected true but was false”,它对排障帮助很小。

十一、我的 AI 单测 Prompt

下面是一份可以直接复用的完整 Prompt:

请作为一名重视业务风险的测试工程师,协助我为下面的代码设计和生成测试。

一、需求和验收标准
<填写业务需求、规则和验收标准>

二、项目上下文
<填写技术栈、测试框架、已有测试风格和依赖约束>

三、待测试代码
<填写代码、调用方、返回值和关键依赖>

四、已知风险
<填写边界、线上故障、权限、并发和数据一致性风险>

请分两阶段工作。

第一阶段:只输出测试设计,不写测试代码。
- 按正常、边界、状态、异常、安全、并发、幂等分类。
- 每个场景包含前置条件、操作、预期结果和副作用。
- 指出需要人工确认的规则。
- 标记适合的测试层级。

第二阶段:等待我确认测试设计后,再生成测试代码。

生成代码时要求:
- 使用当前项目已有测试风格。
- 测试名称表达业务场景。
- 优先验证行为和结果,不测试无价值的内部细节。
- 对成功、空结果、异常、超时和重复响应分别设计测试。
- 对失败场景断言数据没有错误变化。
- 对并发和幂等风险说明单元测试是否足够。
- 每个测试标注覆盖的业务规则。

最后输出测试覆盖矩阵,并列出仍然没有被证明的风险。

十二、总结

AI 可以很快写出大量单测,但测试的价值不在数量,而在于它是否证明了重要行为。

一组高价值测试,应该覆盖:

  1. 正常流程。
  2. 输入边界。
  3. 业务状态。
  4. 权限和安全。
  5. 依赖失败。
  6. 并发和幂等。
  7. 数据一致性。
  8. 线上问题回归。

我会坚持这套节奏:

先从需求识别风险
  ↓
再设计测试矩阵
  ↓
明确应该观察什么
  ↓
让 AI 生成测试代码
  ↓
执行并审查测试质量

请记住:

覆盖率告诉你代码走过哪些路,好的测试还要告诉你走错路时系统会怎样。

下一篇文章,我们继续进入工程判断:

用 AI 做重构前评估:哪些代码值得改,哪些别碰。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注专栏。
也欢迎在评论区留言:你让 AI 写单测时,最容易遗漏边界、异常,还是并发场景?


✍坚持原创,求关注,点赞,收藏