AI 写 JUnit 很快,但怎么判断测试真的有效?
现在让 AI 给 Java 代码补单元测试,已经非常快了。给它一个 Service,很快就能生成十几个测试方法。代码能编译,测试全绿,JaCoCo 覆盖率也涨了,看上去没有问题。
把金额判断里的代码从
AI 写 JUnit 很快,但怎么判断测试真的有效? (java)
amount >= 1000改成
AI 写 JUnit 很快,但怎么判断测试真的有效? (java)
amount > 1000测试可能还是全部通过。继续看测试代码,断言也许只有
notNull,也可能是整个折扣规则已经被 mock 掉了。生产代码执行过,覆盖率也确实增加了,可这条业务规则并没有被测试约束。判断方法很直接。故意修改一条业务规则,再看原本负责它的测试会不会失败。规则已经改错,测试仍然全部通过,说明场景或断言有问题。
不要先生成测试,先列行为矩阵
很多提示词只写两句话。
AI 写 JUnit 很快,但怎么判断测试真的有效? (text)
给 OrderPricingService 补充单元测试。
覆盖率达到 90%。AI 接下来通常会围绕当前实现补测试。看到一个
if,就写两个分支;看到一个异常,就补一条 assertThrows;看到几个依赖,就全部 mock。这种写法的问题很明确,测试只是重复当前实现。实现本身漏掉场景或者写错规则时,测试也会跟着错。先把范围限定在一个类、一个领域服务,或者当前改动涉及的几个方法,再让 AI 输出行为矩阵。拿折扣规则举例,订单金额达到 1000 元时打九折,至少应该包含下面四种情况。
场景 | Given | When | Then | 可以发现的问题 |
|---|---|---|---|---|
阈值前 | 金额 999 | 计算应付金额 | 返回 999 | 折扣提前生效 |
阈值点 | 金额 1000 | 计算应付金额 | 返回 900 | >= 被误写成 > |
阈值后 | 金额 1001 | 计算应付金额 | 返回 900.9 | 折扣或金额精度错误 |
非法输入 | 金额为负数 | 计算应付金额 | 抛出约定异常 | 输入校验遗漏 |
1000 这个阈值点不能漏。如果测试只有 999 和 1001,
>= 改成 > 后很可能仍然全部通过,行覆盖率甚至完全不变。行为矩阵也不用把所有可能性都塞进去。空值、并发、幂等和极端值是否需要测试,要看当前代码和业务风险。金额算错、权限放宽、状态跳错、重复扣款或者重复发消息,应该优先覆盖。
场景确定以后,先读现有测试
让 AI 在同模块找两三个测试文件,看看项目怎样命名测试、构造数据、使用 mock 和编写断言。
项目原本统一使用 AssertJ,AI 却写成原生 JUnit,测试当然可以运行,但没有必要混用。
AI 写 JUnit 很快,但怎么判断测试真的有效? (java)
assertThat(result).isEqualTo(expected);
Assertions.assertEquals(expected, result);项目已经有
OrderFixture.validOrder(),AI 又在每个方法里手写几十行 Builder,也会增加维护成本。更麻烦的是,领域对象本来可以直接实例化,AI 却把它们全部 mock 掉。现有测试里还可能有 README 没写出来的约定,比如测试基类、自定义 JUnit Extension、Fixture Builder、Test Data Factory 和 AssertJ 自定义断言。代码已经有统一写法时,直接沿用即可。
测试业务结果,不要固定实现过程
单元测试应该验证外部可以观察到的结果。返回值、异常、聚合状态和领域事件都属于这类结果。某个协作者调用本身具有业务含义时,也可以验证它的参数或次数。
下面这种测试把方法内部的执行过程全部写进了断言。
AI 写 JUnit 很快,但怎么判断测试真的有效? (java)
verify(repository).findById(id);
verify(ruleEngine).evaluate(order);
verify(calculator).calculate(order);
verify(repository).save(order);生产代码只要调整调用顺序,即使最终业务结果没有变化,测试也可能失败。这样的测试会增加重构成本。金额和状态应该直接检查结果。
AI 写 JUnit 很快,但怎么判断测试真的有效? (java)
assertThat(order.status()).isEqualTo(PAID);
assertThat(order.payableAmount()).isEqualByComparingTo("900.00");Mock 只隔离外部边界
数据库、网络、消息系统和第三方 API 适合 mock。折扣策略、权限策略、状态机、价格规则和金额对象包含当前需要验证的业务逻辑,应该尽量使用真实实现。
如果测试直接规定折扣策略返回 900
AI 写 JUnit 很快,但怎么判断测试真的有效? (java)
when(discountPolicy.calculate(any()))
.thenReturn(BigDecimal.valueOf(900));那么测试只能证明 mock 返回 900 后,Service 也返回了 900。折扣规则有没有写对,仍然不知道。
判断时可以直接看这个对象是否包含当前要验证的规则。包含规则就用真实对象,负责数据库或第三方调用时再考虑 mock。
代码难测时,先处理具体障碍
业务代码直接调用
LocalDateTime.now(),测试就很难固定时间。可以注入 Clock,生产代码改用 Instant.now(clock),测试再传入 Clock.fixed(...)。随机数也一样。测试需要控制 UUID 时,可以注入 ID 生成器。外部 I/O 和业务判断混在一个很长的 Service 里,可以先拆出一段不依赖 I/O 的业务规则。
这类调整要小。时间不可控就先处理时间,随机数不可控就处理随机数,不要借补测试的机会重写整个模块。每次修改以后,用原有测试或特征测试确认外部行为没有变化。
测试通过以后,再打开文件检查一次
AI 生成的测试第一次通过以后,至少再检查下面几种写法。
- 断言只有
notNull,没有检查金额、状态或其他业务字段 - 只使用
assertDoesNotThrow,操作完成后的状态没有验证 - 用
Thread.sleep等待异步结果,运行速度和稳定性取决于机器 - 单元测试访问真实网络,结果受 DNS 和第三方服务影响
- 多个测试共享静态对象、数据库记录或缓存,执行顺序会影响结果
发现这些问题时,应该修改断言或测试结构。继续增加同类测试,只会让测试数量和覆盖率继续上涨。
JaCoCo 用来检查遗漏
先运行目标测试,再运行模块测试,最后按项目配置执行完整验证。
AI 写 JUnit 很快,但怎么判断测试真的有效? (bash)
mvn -Dtest=OrderPricingServiceTest test
mvn test
mvn verifymvn verify 是否生成 JaCoCo 报告,取决于项目自己的 Maven 配置。行覆盖率可以找出从未执行的代码,分支覆盖率可以找出没有走过的判断路径。折扣规则只有 999 和 1001 两个测试时,
amount >= 1000 的真假分支都可能已经覆盖。把 >= 改成 > 后,这两个测试的结果都不变,分支覆盖率仍然可以是 100%。两种写法只在输入 1000 时产生不同结果,覆盖率不会替测试设计补上这个边界。用 PIT 检查断言能否发现规则变化
PIT 会修改一小处生产代码,然后重新运行测试。前面的
>= 被改成 > 后,金额为 1000 的测试应该失败。PIT 把这种结果记为 Killed。所有测试仍然通过时,该 mutant 会被记为 Survived。Survived mutant 需要逐条判断。代码变化已经影响外部行为,测试却没有失败,就补场景或加强断言。代码虽然改变,外部行为没有变化,可能是等价变异,不必为了分数强行增加测试。
项目配置好 PIT Maven 插件后,可以运行下面的命令。
AI 写 JUnit 很快,但怎么判断测试真的有效? (bash)
mvn test-compile org.pitest:pitest-maven:mutationCoveragePIT 比普通单元测试慢很多,优先用在金额计算、权限判断、状态机、结算逻辑和复杂条件上。DTO、getter、简单 mapper 和框架胶水没有必要投入同样的成本。覆盖率和 Mutation Score 的门槛也应该按风险制定,不能把一个数字套给整个项目。
单元测试通过,不代表集成没有问题
如果 PostgreSQL、Kafka、Redis 和 HTTP 调用全部被 mock,单元测试只能验证这些 mock 按预期返回时,当前业务代码能够运行。它无法验证 Flyway migration、SQL 查询、JPA 映射、Kafka 配置、Redis 序列化、Spring Bean 扫描和 HTTP 契约。
层级 | 常用工具 | 主要验证内容 |
|---|---|---|
单元测试 | JUnit 5、Surefire | 业务规则、校验、映射和小决策 |
变异测试 | PIT | 测试能否发现业务规则被错误修改 |
集成测试 | Testcontainers | 数据库、迁移、Kafka、Redis 和真实装配 |
启动冒烟 | @SpringBootTest、Testcontainers | Spring 扫描、配置和 ApplicationContext |
API 测试 | Bruno | HTTP 状态码、响应结构、鉴权、幂等和契约 |
E2E 测试 | 完整 Spring Boot 应用、Testcontainers | 少量核心业务流程 |
压力测试 | k6 | 并发性能、大数据量和查询表现 |
业务规则使用 JUnit 和 PIT 验证。数据库查询、迁移脚本、Kafka 和 Redis 集成使用 Testcontainers。Spring Bean 没有扫描到,
@SpringBootTest 应该失败。HTTP 状态码、鉴权和响应结构放在 API 测试层。E2E 只保留少量重要流程,否则运行会越来越慢,内部调整也会带来更多用例修改。不要只接受“所有测试已通过”
AI 完成任务后,至少应该列出修改的测试文件、新增场景、实际执行的命令、测试结果、JaCoCo 报告位置和 PIT 报告位置。还有哪些 Survived mutant,哪些风险没有覆盖,也要说明原因。
下面这段提示词可以直接放进 Coding Agent 的任务里。
AI 写 JUnit 很快,但怎么判断测试真的有效? (text)
请为指定的 Java 代码补充单元测试。
先阅读同模块或同层级的现有测试,引用具体文件并总结以下约定。
- 测试命名方式
- Fixture 和测试数据构造方式
- Mock 使用方式
- 断言风格
- 测试基类和 Extension
不要立即创建测试文件。先输出行为矩阵,分析正常路径、业务边界、异常路径、需要验证的副作用和高风险业务规则。
根据确认后的行为矩阵创建测试。验证可观察的业务结果,不测试私有实现,不要过度验证调用顺序,也不要 mock 当前要验证的领域规则。
完成后依次运行目标测试、相关模块测试和项目要求的完整测试。生成 JaCoCo 行覆盖率与分支覆盖率报告。高风险领域代码继续运行 PIT。
最后列出修改的测试文件、新增场景、实际命令、测试结果、JaCoCo 和 PIT 报告位置、Survived mutant,以及尚未覆盖的风险和原因。交付前再改错一条重要的业务规则。原本负责这条规则的测试没有失败,就回到行为矩阵和断言继续修改。