CodeArts Agent + JUnit 5 单元测试实战
让 AI 写测试,但不让 AI 决定什么是对的:CodeArts Agent + JUnit 5 为 Java 老项目补齐单元测试
摘要:接手 Java 老项目时,最让人头疼的往往不是新增一个接口,而是修改一段没有测试保护的业务逻辑。本文围绕华为云码道 CodeArts 代码智能体内置的
developer-test-agent,以优惠券结算服务为例,演示从业务规则梳理、测试用例设计,到 JUnit 5 + Mockito 测试生成、执行及覆盖率分析的完整流程,并讨论如何避免 AI 生成“能通过、却没有验证价值”的测试。关键词:CodeArts Agent、华为云码道、Java、Spring Boot、JUnit 5、Mockito、单元测试、遗留系统、AI 编程
一、为什么老项目需要先补测试,再谈重构?
在长期维护的 Java 项目里,经常能遇到这些情况:
Service中挤满了优惠计算、状态判断、异常分支和数据库访问;- 业务规则散落在代码里,文档早已跟不上实际实现;
- 需求看起来只改一个判断,却可能影响多个边界场景;
- 原来的开发者已离职,没人能准确说清哪些行为不能变。
这时直接要求 AI“优化这个类”并不稳妥。AI 可能把冗长的代码改得更漂亮,却顺手改变了业务约束。
更可靠的顺序是:
阅读现有代码
↓
提炼业务规则与风险点
↓
确认哪些行为是业务预期
↓
补充单元测试(建立安全网)
↓
进行小范围重构
↓
执行测试并核对差异
核心原则是:测试预期必须由业务规则和开发者审查决定,不能让 AI 仅根据当前代码反向推断“正确答案”。 否则,现有 Bug 很可能被原封不动地写进测试。
二、CodeArts Agent 在这个流程里能做什么?
华为云官方文档将 developer-test-agent 定义为内置单元测试智能体,覆盖测试设计、用例生成、编译问题识别与修复等环节;在现有文档中,Java 是其重点适配语言之一。
常见调用方式包括:
- IDE 对话:在 CodeArts Agent 聊天窗口切换到“智能体”模式,输入包含
developer-test-agent的指令。 - IntelliJ IDEA 右键调用:在智能体模式下,将光标置于 Java 方法名,选择 华为云码道(CodeArts)代码智能体 → 单元测试(智能体)。这条快捷路径有 IDE 和语言限制,不应理解为所有编辑器、所有语言都支持。
- 码道 CLI:进入目标项目目录,在已安装并登录的环境中启动
codearts,然后以包含developer-test-agent的指令发起测试任务。
官方给出的典型指令形式:
使用developer-test-agent为CouponService.java文件的payableYuan方法生成单元测试。
但在真实项目里,仅说“生成单元测试”还远远不够。后面会把这句话升级成一条带有业务约束和验收标准的提示词。
说明:下文 Java 示例为可供本地复现的演示性案例,不是对某次 CodeArts Agent 运行结果的实测记录。插件入口、界面名称和能力范围应以当前官方文档和实际安装版本为准。
三、实战需求:优惠券结算服务有哪些坑?
假设一个 Spring Boot 项目中,有如下结算规则:
| 场景 | 期望行为 | 容易遗漏的点 |
|---|---|---|
| 订单金额小于或等于 0 | 直接拒绝 | 防止无效金额继续访问数据库 |
| 未传优惠券或只传空白字符 | 原价结算 | 不应查询优惠券表 |
| 优惠券不存在 | 报业务错误 | 不能静默按原价放行 |
| 优惠券已停用 | 报业务错误 | 不能继续抵扣 |
| 优惠券恰好到期 | 视为过期 | 时间边界不能写成 > |
| 优惠金额小于订单金额 | 正常抵扣 | 金额计算准确 |
| 优惠金额大于订单金额 | 应付金额为 0 | 不允许产生负数 |
这张表不是 AI 的猜测,而是开发前需要与产品、测试或现有业务约定确认的规则。
为了让文章聚焦单元测试,示例使用“整数元”表示金额。生产环境的金融结算应根据精度要求选择以分为单位的整数或 BigDecimal,不能直接照搬这里的简化金额模型。
3.1 定义优惠券对象
src/main/java/com/example/coupon/Coupon.java
package com.example.coupon;
import java.time.LocalDateTime;
public record Coupon(
String code,
int discountYuan,
LocalDateTime expiresAt,
boolean enabled
) {}
src/main/java/com/example/coupon/CouponRepository.java
package com.example.coupon;
import java.util.Optional;
public interface CouponRepository {
Optional<Coupon> findByCode(String code);
}
这里通过接口隔离数据库。稍后在单元测试中使用 Mockito 模拟 CouponRepository,不需要启动 MySQL 或整套 Spring 容器。
3.2 编写可测试的 Service
src/main/java/com/example/coupon/CouponService.java
package com.example.coupon;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.Objects;
public class CouponService {
private final CouponRepository repository;
private final Clock clock;
public CouponService(CouponRepository repository, Clock clock) {
this.repository = Objects.requireNonNull(repository);
this.clock = Objects.requireNonNull(clock);
}
public int payableYuan(String code, int orderYuan) {
if (orderYuan <= 0) {
throw new IllegalArgumentException("订单金额必须大于0");
}
if (code == null || code.isBlank()) {
return orderYuan;
}
Coupon coupon = repository.findByCode(code)
.orElseThrow(() ->
new IllegalArgumentException("优惠券不存在"));
if (!coupon.enabled()) {
throw new IllegalStateException("优惠券已停用");
}
if (!LocalDateTime.now(clock).isBefore(coupon.expiresAt())) {
throw new IllegalStateException("优惠券已过期");
}
return Math.max(0, orderYuan - coupon.discountYuan());
}
}
值得注意的是这个构造方法:
public CouponService(CouponRepository repository, Clock clock)
它将外部依赖和时间来源显式注入,而不是在业务代码中直接访问数据库或硬编码 LocalDateTime.now()。
好处是测试可以固定“当前时间”,稳定验证到期前、到期时和到期后的行为,避免测试在深夜或跨时区时偶发失败。
实际老项目若已经存在静态时间工具类或复杂数据库依赖,建议先让 Agent 提出最小可测试性改造方案,经审查后再修改生产代码,而不是直接大规模重构。
四、给 CodeArts Agent 一条真正有约束的提示词
打开 Java 项目,定位 CouponService.java,在智能体模式输入:
使用 developer-test-agent 为 CouponService.java
中的 payableYuan 方法设计并生成单元测试。
技术栈:
- Java 17
- Spring Boot 项目
- JUnit 5 + Mockito
- Maven
已确认的业务规则:
1. 订单金额 <= 0 时抛出 IllegalArgumentException;
2. 优惠券编码为 null、空串或仅包含空白时返回原价,
且不能调用 CouponRepository;
3. 编码存在但数据库没有记录时抛出 IllegalArgumentException;
4. 已停用优惠券不能使用;
5. 当前时间等于到期时间时也视为过期;
6. 正常优惠券按金额抵扣,最终金额不能小于 0。
执行要求:
1. 先输出测试场景清单,不要立即修改代码;
2. 标注正常、异常、边界三类场景;
3. 只修改 src/test 下的测试文件;
4. 使用 Mockito 模拟 Repository,不启动 Spring 容器;
5. 使用固定 Clock 测试时间边界,禁止 Thread.sleep;
6. 不允许为了让测试通过而修改生产业务规则;
7. 生成后执行目标测试,若失败,先分析是测试错误、
环境问题还是生产代码缺陷;
8. 输出运行命令、失败原因(如有)和未覆盖风险。
这里最重要的不是提示词长度,而是三个约束:
先列场景,再写代码:避免 Agent 想到一个分支就立刻写一个断言。
禁止悄悄修改生产逻辑:否则会出现 AI 为了让测试通过而修改被测方法的情况。
明确测试边界:单元测试只验证当前类的行为,不把数据库、网络和 Spring 上下文一起拉进来。
五、JUnit 5 + Mockito 测试代码示例
如果项目继承了 Spring Boot 的依赖管理,通常可以通过 spring-boot-starter-test 引入所需测试基础组件;具体依赖和版本以项目现有配置为准。
在 pom.xml 的 <dependencies> 中确认存在:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
注意:本文使用的 @ExtendWith(MockitoExtension.class) 需要类路径包含 mockito-junit-jupiter。若现有项目的 Starter 版本没有引入它,请单独添加与项目 Mockito 版本兼容的测试依赖。
下面给出一个可参考的测试类。
src/test/java/com/example/coupon/CouponServiceTest.java
package com.example.coupon;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CouponServiceTest {
private static final Clock FIXED_CLOCK = Clock.fixed(
Instant.parse("2026-10-18T00:00:00Z"),
ZoneOffset.UTC
);
@Mock
private CouponRepository repository;
private CouponService service;
@BeforeEach
void setUp() {
service = new CouponService(repository, FIXED_CLOCK);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void noCouponShouldKeepOriginalPrice(String code) {
int payable = service.payableYuan(code, 100);
assertEquals(100, payable);
verifyNoInteractions(repository);
}
@Test
void validCouponShouldDeductAmount() {
when(repository.findByCode("SAVE20"))
.thenReturn(Optional.of(coupon(20,
LocalDateTime.of(2026, 10, 19, 0, 0),
true)));
assertEquals(80, service.payableYuan("SAVE20", 100));
verify(repository).findByCode("SAVE20");
}
@Test
void discountCannotMakePayableNegative() {
when(repository.findByCode("SAVE200"))
.thenReturn(Optional.of(coupon(200,
LocalDateTime.of(2026, 10, 19, 0, 0),
true)));
assertEquals(0, service.payableYuan("SAVE200", 100));
}
@Test
void couponAtExpirationBoundaryShouldFail() {
when(repository.findByCode("EXPIRED"))
.thenReturn(Optional.of(coupon(20,
LocalDateTime.of(2026, 10, 18, 0, 0),
true)));
assertThrows(IllegalStateException.class,
() -> service.payableYuan("EXPIRED", 100));
}
@Test
void disabledCouponShouldFail() {
when(repository.findByCode("OFF"))
.thenReturn(Optional.of(coupon(20,
LocalDateTime.of(2026, 10, 19, 0, 0),
false)));
assertThrows(IllegalStateException.class,
() -> service.payableYuan("OFF", 100));
}
@Test
void unknownCouponShouldFail() {
when(repository.findByCode("UNKNOWN"))
.thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> service.payableYuan("UNKNOWN", 100));
}
@Test
void invalidOrderAmountShouldFailWithoutRepositoryCall() {
assertThrows(IllegalArgumentException.class,
() -> service.payableYuan("SAVE20", 0));
verifyNoInteractions(repository);
}
private static Coupon coupon(
int discountYuan,
LocalDateTime expiresAt,
boolean enabled) {
return new Coupon("DEMO", discountYuan, expiresAt, enabled);
}
}
这段测试有几个值得保留的设计:
@ParameterizedTest:同一规则使用多组数据验证;@Mock:数据库查询被模拟,不依赖测试数据库;Clock.fixed(...):日期相关用例可以精确复现;assertThrows(...):不仅关注成功路径,也验证异常行为;verifyNoInteractions(...):除了返回值,还验证“不应该访问数据库”的约束。
在真实项目中,还可以继续让 Agent 补充对优惠券折扣金额非法值、空到期时间、日志与异常码映射、跨时区语义等场景的分析。它们是否需要单元测试,应由业务模型和数据契约决定。
六、AI 生成的测试通过了,就代表代码安全吗?
不能。
考虑下面这个测试:
@Test
void weakTest() {
assertNotNull(service);
}
它能够通过,但几乎没有验证结算逻辑。
再看另一种看似更“智能”的错误:
int actual = service.payableYuan("SAVE20", 100);
assertEquals(actual, actual);
这个断言永远成立,即使被测方法返回了错误金额。
因此,审查 Agent 生成测试时,要关注断言是否独立于被测实现、能否识别错误行为,而不只是测试是否绿色。
我建议用四个问题审查测试
| 审查点 | 具体问题 |
|---|---|
| 测试预期 | 断言来自明确业务规则,还是抄写了当前实现? |
| 边界值 | 0、空值、恰好到期、优惠额大于订单额是否覆盖? |
| 依赖行为 | 不应查询数据库时,是否验证了零次调用? |
| 可重复性 | 测试是否依赖系统时间、真实网络、执行顺序? |
还可以尝试一个简单的反证检查:在独立分支中故意把过期判断改错,例如将“等于到期时间也失效”改为“仅晚于到期时间才失效”,看看对应测试是否会失败。此类变更只用于本地验证,确认后必须撤销。
如果“故意引入错误”后测试仍然通过,就说明测试的约束力不足。
七、用 JaCoCo 找覆盖盲区,而不是追求漂亮数字
JaCoCo 是常见的 Java 代码覆盖率分析工具,可以生成行覆盖率、分支覆盖率等报告。
例如,在 Maven 项目 <build><plugins> 中添加如下插件配置(示例版本请与实际项目、JDK 兼容性核对):
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.13</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
运行指定测试:
mvn -Dtest=CouponServiceTest test
生成覆盖率报告:
mvn verify
生成报告后可检查:
target/site/jacoco/index.html
不要把“覆盖率达到 90%”等同于“发现了 90% 的 Bug”。覆盖率说明哪些代码被执行过,并不保证断言正确,也不保证业务场景完整。
对遗留系统,我更建议先关注:
- 新修改的方法是否有对应的行为测试;
- 关键异常分支和时间边界有没有覆盖;
- 测试失败时能否快速定位到被破坏的业务规则;
- 测试是否足够稳定,能够持续进入 CI 流程。
当覆盖率报告显示大量缺口时,可以把未覆盖的类、方法和分支列表交给 Agent 分批处理,而不是一次性生成几百个测试文件。
八、从个人提效变成团队可复用流程
给 CodeArts Agent 的测试任务可以分成三轮,通常比“直接给整个项目补测试”更容易审查:
第一轮:只做理解与设计。
分析 OrderService、CouponService 和相关 Repository,
列出已实现的业务规则、潜在异常分支和外部依赖。
先不要修改文件。
请区分“代码现状”和“需要产品确认的业务规则”。
第二轮:按模块生成测试。
使用 developer-test-agent 仅为 CouponService
生成 JUnit 5 + Mockito 单元测试。
仅允许修改 src/test。
测试必须覆盖已确认的业务规则和关键边界值。
第三轮:运行并审查。
执行 CouponServiceTest,并分析失败信息。
结合 JaCoCo 报告列出未覆盖分支。
不要通过删除断言、放宽预期、跳过测试或修改
生产业务代码的方式制造“测试通过”。
最后输出:新增测试、验证结果、剩余风险。
团队还可以在代码审查中约定:凡是 Agent 生成的单元测试,提交者必须解释至少一个关键异常场景和一个边界场景的断言来源。
对于涉及支付、权限、医疗数据或其他高风险业务的代码,还应增加真实集成测试、契约测试和人工复核,不能用 Mock 测试取代所有验证手段。
九、结语
CodeArts Agent 的价值,不只是把“写测试代码”这件重复性工作交给 AI,而是帮助开发者更系统地识别测试场景、管理依赖、覆盖边界,并缩短反馈周期。
但 AI 可以生成测试代码,不能代替开发者决定业务上什么才是正确行为。
一个适合 Java 老项目的落地方式是:先确认规则,再让 developer-test-agent 生成测试;先保证测试质量,再考虑重构;先验证失败场景,再相信绿色构建。
当测试成为每次修改之前的安全网,AI 辅助开发才能真正从“写得更快”走向“改得更稳”。
- 点赞
- 收藏
- 关注作者
评论(0)