CodeArts Agent + JUnit 5 单元测试实战

举报
yd_269451999 发表于 2026/10/18 16:45:23 2026/10/18
【摘要】 让 AI 写测试,但不让 AI 决定什么是对的:CodeArts Agent + JUnit 5 为 Java 老项目补齐单元测试摘要:接手 Java 老项目时,最让人头疼的往往不是新增一个接口,而是修改一段没有测试保护的业务逻辑。本文围绕华为云码道 CodeArts 代码智能体内置的 developer-test-agent,以优惠券结算服务为例,演示从业务规则梳理、测试用例设计,到 J...

让 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 是其重点适配语言之一。

常见调用方式包括:

  1. IDE 对话:在 CodeArts Agent 聊天窗口切换到“智能体”模式,输入包含 developer-test-agent 的指令。
  2. IntelliJ IDEA 右键调用:在智能体模式下,将光标置于 Java 方法名,选择 华为云码道(CodeArts)代码智能体 → 单元测试(智能体)。这条快捷路径有 IDE 和语言限制,不应理解为所有编辑器、所有语言都支持。
  3. 码道 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”。覆盖率说明哪些代码被执行过,并不保证断言正确,也不保证业务场景完整。

对遗留系统,我更建议先关注:

  1. 新修改的方法是否有对应的行为测试;
  2. 关键异常分支和时间边界有没有覆盖;
  3. 测试失败时能否快速定位到被破坏的业务规则;
  4. 测试是否足够稳定,能够持续进入 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 辅助开发才能真正从“写得更快”走向“改得更稳”。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。