cosmic-unittest 苍穹模块单元测试编写技能 v1.4

适用于任何遵循 common / business / opplugin / formplugin 四工程结构的金蝶云苍穹模块  ·  JUnit 4 + Mockito 2.x  ·  6 项性能优化

技能执行流程

Step 0.0
识别模块组
从包名推导前缀
→
Step 0.1
判断测试类型
工程 → 模式
→
Step 0.5
获取 Author
缓存 → git → 询问
→
Step 1
分析被测类
依赖 + Bug 审查
→
Step 2
编写测试类
注解 + Mock + 断言
→
Step 3
覆盖场景
边界值 + 分支
→
Step 4
验证运行
静态分析 + Gradle

v1.4 性能优化专区

按需加载 pattern 文件

根据工程类型推荐只读取 1 个 pattern,避免全量加载:

工程类型pattern 文件
common - POJOcommon-module.md #POJO
common - 枚举common-module.md #Enum
common - 工具类common-module.md #Utils
businessbusiness-helper.md
opplugin - Validatorvalidator.md
opplugin - OpPluginop-plugin.md
opplugin - Convertconvert-plugin.md
formpluginformplugin.md
example 文件仅在需要参考具体写法时按需读取,非必读。

并行全量读取

读取被测类源码后,以下全部同一轮并行发起:

-
常量/枚举类 import 提取 → grep_code 并行搜索
-
BaseTest 源码 search_file 搜索确认预置 Mock
-
对应 pattern 直接 read_file 补读
-
关联类签名 import 提取 → grep_code 搜索方法签名
v1.3 只并行读取常量,v1.4 扩展为全量并行,减少多轮串行等待。

智能 Mock 推断

从 import 自动匹配映射表,规则驱动取代经验驱动:

import 中的类Mock 方式
QueryServiceHelpermockStatic + when(query)
BusinessDataServiceHelpermockStatic + when(load...)
OperationServiceHelpermockStatic + when(executeOperate)
SaveServiceHelpermockStatic + when(save/update)
OrgUnitServiceHelpermockStatic(通常无需 stub)
ResManagermockStatic + thenAnswer(getArg(0))
DB推荐先 ClassPool defrost
ProcessSettleHelper推荐避免直接 mockStatic,用 MockHelper

BaseTest Mock 继承去重

仅声明 BaseTest 未预置的 MockedStatic 差集:

public class SomeHelperTest extends BaseTest {
    // 仅声明 BaseTest 未预置的
    MockedStatic<ProcessPlanHelper> planHelper;

    @Before public void before() {
        super.before();  // 先触发 BaseTest
        planHelper = mockStatic(ProcessPlanHelper.class);
    }
    @After public void after() {
        planHelper.close();
        super.after();
    }
}

批量模式并行策略

同工程多被测类,最大化并行:

步骤并行策略
源码读取所有被测类 + 常量 + BaseTest 同一轮并行
逻辑分析逐类分析,汇总输出一次(跳过确认)
代码生成互不依赖的测试类可并行生成
Gradle 验证只冷启动一次,合并运行
核心价值:Gradle 冷启动一次 + 文件读取并行化 + 测试类并行生成

Step 0.0 — 识别模块组前缀

从被测类的包名或文件路径中提取 <模块组>,后续所有工程名、包名、路径均基于此推导。

包名示例模块组前缀
kd.mmc.sfc.business.XxxHelpermmc-sfc
kd.bd.mpdm.business.XxxHelperbd-mpdm
kd.sys.xkbase.business.XxxHelpersys-xkbase
kd.mmc.pom.business.XxxHelpermmc-pom

Step 0.1 — 工程 → 测试模式

<模块组>-common   公共模块
● POJO/DTO/Bean → POJO 模式 无需 Mock 直接 new
● 枚举类(含业务方法)→ Enum 模式 继承 AbstractJunitNoDependenciesTest
● 工具类 XxxUtil/XxxUtils → Utils 模式 继承 BaseTest
<模块组>-business   业务帮助类
XxxHelper / XxxService → Business Helper 模式,继承 BaseTest 或 AbstractJunitNoDependenciesResManagerTest
<模块组>-opplugin   操作插件工程
● Validator → Validator 模式
● AbstractOperationServicePlugIn → OpPlugin 模式
● ConvertPlugin/BotpPlugin → ConvertPlugin 模式
<模块组>-formplugin   表单插件工程
继承 AbstractFormPlugin/ListPlugin/BillPlugin → FormPlugin 模式,优先继承 BasePluginTest<T>

Step 0.5 — 获取 Author 信息(三级优先级)

1️⃣
缓存文件 — 读取 .qoder/skills/cosmic-unittest/author-cache.json,若 author 非空直接使用,跳过 git 命令
2️⃣
git config — 缓存不存在或 author 为空时,执行 git config user.name + git config user.email,读取成功后写入缓存
3️⃣
询问用户 — git config 读取失败时,主动询问用户提供姓名和邮箱,填入缓存
格式规范:<name> <<name>@kingdee.com>
例如:zhang_san <zhang_san@kingdee.com>
首次写入 author-cache.json 后,检查 .gitignore 是否已包含该文件的忽略规则,避免个人信息提交到 git。

Step 1 — 分析被测类

1️⃣
读取完整源码 — 识别所有 public/protected 方法
2️⃣
依赖识别 — 静态方法(MockedStatic)、外部服务、DynamicObject 结构
3️⃣
分支识别 — switch 分支 / if-else 判断分支(用于 Step 3 拆分策略)
4️⃣
Bug 审查 — 边界方向错误、空指针风险、逻辑取反、计算错误、集合遗漏
*
v1.4 并行全量读取 — 常量类 + BaseTest + pattern + 关联类方法签名,同一轮并行拿齐
*
v1.4 智能 Mock 推断 — 从 import 自动匹配「import → Mock 映射表」,规则驱动取代经验驱动
分析结果:分析完成后推荐输出「被测逻辑分析」摘要(含 Bug 检查结论),推荐避免跳过直接给代码。
发现 Bug → 测试断言反映正确预期(让测试失败暴露 Bug)+ 注释 // [警告] BUG: ... + 告知用户。

Step 2 — 编写测试类

2.1 通用编写规则

规则说明
测试框架JUnit 4(@Test / @Before / @After)
Mock 框架Mockito 2.x + MockedStatic
Mockito 静态导入[错误] 避免使用通配符 import static ...ArgumentMatchers.*
[通过] 推荐显式逐个导入:any / anyBoolean / anyInt / anyLong / anyString / eq / isNull(ArgumentMatchers);atLeastOnce / mock / never / verify / when(Mockito)
数据构造DynamicObjectMocker 链式 .add(key, value)
基类选择无 ResManager → AbstractJunitNoDependenciesTest
有 ResManager → AbstractJunitNoDependenciesResManagerTest
多依赖 → 继承工程 BaseTest
反射工具ReflectHelper.invokeProtectedMethod()
ReflectHelper.invokeStaticMethod()
资源释放所有 MockedStatic 推荐在 @After 中 .close()
注解要求每个 @Test 方法推荐加 @UnittestCaseInfo 和 @DisplayName
断言质量[错误] 避免假断言(assertTrue(true) 等)
[错误] 避免无断言测试方法
[通过] 每个 @Test 至少一条有效 assert/verify
switch 策略每个 case 推荐拆为独立测试方法
if/else 覆盖尽量覆盖所有条件组合(笛卡尔积),可合并到同一方法
常量/枚举引用找不到时主动告知用户,推荐避免字面值替代
BaseTest Mock 去重不得重复声明 BaseTest 已预置的 MockedStatic,仅声明「候选 Mock 类 - BaseTest 已预置」的差集
@Before 中先 super.before() 再初始化自有 Mock

2.2 @UnittestCaseInfo 注解规范

@UnittestCaseInfo(
    author = "<name> <<name>@kingdee.com>",
    // git config user.name 拼接邮箱
    title = "英文标题(简短描述测试目的)",
    targetClass = "被测类全限定名",
    targetMethod = "被测方法名(小写)",
    lastUpdateTime = "yyyy-MM-dd HH:mm:ss",
    lastUpdateAuthor = "<name> <<name>@kingdee.com>",
    methodSignature = "被测方法完整签名",
    testPoints = {"Functionality"},
    description = "英文描述"
)

2.3 测试方法注释规范

@Test
@DisplayName("中文描述:测试场景说明")
public void testMethodName_ScenarioDescription() {
    //step 准备测试数据 / 构建 Mock
    ...
    //step 执行被测方法
    ...
    //assert 断言结果
    ...
    reset(); // 如有 reset 方法
}
DisplayName 来自 kd.bos.form.unittest.DisplayName(非 org.junit.DisplayName)

2.4–2.5 MockedStatic & DynamicObjectMocker

MockedStatic 生命周期

// 成员变量声明
MockedStatic<XxxHelper> xxxHelper;

@Before
public void before() {
    xxxHelper = mockStatic(XxxHelper.class);
}
@After
public void after() {
    xxxHelper.close(); // 推荐及时关闭!
}
public void reset() {
    xxxHelper.reset();
}

DynamicObjectMocker 链式构造

// 简单对象
DynamicObject obj = new DynamicObjectMocker()
    .add("id", 1L)
    .add("billno", "TEST-001")
    .getObject();

// 分录集合(用 getCollection)
DynamicObject bill = new DynamicObjectMocker()
    .add("entryentity", new DynamicObjectMocker()
        .add("qty", BigDecimal.TEN)
        .getCollection())
    .getObject();

[警告] 2.6 DB 类特殊 Mock(ClassPool Defrost)

DB 类存在字节码冻结问题,mock 前推荐先执行 defrost 处理:

@Before
public void before() {
    try {
        CtClass cls = ClassPool.getDefault()
            .get(DB.class.getName());
        if (cls.isFrozen()) {
            cls.defrost();
        }
    } catch (Exception e) {}
    dbMocked = Mockito.mockStatic(DB.class);
}

[警告] 2.7 ProcessSettleHelper 特殊 Mock

含分布式缓存,不可直接 mockStatic:

MockedStatic<ProcessSettleHelper> mock =
    ProcessSettleMockHelper
        .mockDistributeSessionlessCache();
在当前被测类所属工程的 src/test/java/ 下查找 ProcessSettleMockHelper。找不到则主动告知用户在对应工程 test 目录下添加。

2.8–2.10 高级 Mock 技巧

MockedConstruction(Mock 构造函数)

@Test
public void testMethod() {
  try (MockedConstruction<PushArgs> m =
       Mockito.mockConstruction(PushArgs.class)) {
    SomeUtil.doSomething(params);
    assertEquals(1, m.constructed().size());
  } // 自动关闭
}

thenAnswer 动态返回

helper.when(() -> QueryServiceHelper
    .query(anyString(), anyString(), any()))
  .thenAnswer(inv -> {
    String entity = inv.getArgument(0);
    if ("mmc_plan".equals(entity))
      return mockPlanData();
    return new DynamicObjectCollection();
  });

DynamicObject 断言规范

方法默认值断言
getString()""assertEquals("", ...)
getLong()0LassertEquals(0L, ...)
getBigDecimal()ZEROassertEquals(ZERO, ...)
getBoolean()falseassertFalse(...)
getDate()可能 nullassertNull(...)

Step 3 — 测试场景设计

场景类型与边界值要求

场景类型说明
正常路径标准输入,预期正常输出
边界条件空值 / 空集合 / 零值
★ 边界值(重点)比较运算符临界点,推荐覆盖「刚好满足」和「刚好不满足」两侧
异常路径查询返回 null、抛异常
业务规则特定业务条件分支

边界值覆盖要求

运算符推荐覆盖的值
> 0等于0(不通过)、大于0(通过)
>= 0等于0(通过)、小于0(不通过)
compareTo > 0a==b(不通过)、a>b(通过)、a<b(不通过)
size() > Nsize==N(不通过)、size==N+1(通过)
isEmpty()空集合、恰好1条、多条

switch / if-else 分支拆分策略

分支类型拆分规则
switch 分支每个 case 推荐定义为独立测试方法
testPropertyChanged_CaseFieldA()
switch case 内部多场景允许在同一方法中测试(过复杂时可进一步拆分)
if/else 判断覆盖所有条件组合(笛卡尔积),可合并到同一方法
关联方法的分支被测方法调用的其他方法中的分支也需覆盖

Step 4 — 验证与运行

[通过]
静态分析 import 完整 / MockedStatic 全部关闭 / 方法命名清晰 / 常量引用正确 / BaseTest 未重复声明(v1.4) / super.before() 在前(v1.4)
>
Gradle 运行前快速判断 — 先检查 ① biz lib 中被测模块 jar 是否存在 ② config.gradle 中 biz 路径是否真实存在;任一失败 → 直接跳过 Gradle,提示用户 IDE 运行(节省 ~8min)
>
Gradle 运行 (将 <模块组> 替换为实际前缀)
gradle :<模块组>-common:test
gradle :<模块组>-business:test
gradle :<模块组>-opplugin:test
gradle :<模块组>-formplugin:test
# 例如 mmc-sfc 模块:
gradle :mmc-sfc-business:test

BaseTest 差异对比

通用 BaseTest 发现规则

实际使用时推荐先读取目标工程的 BaseTest 源码,确认其预置了哪些 MockedStatic。

工程BaseTest 包名规律查找路径
<模块组>-commonkd.<包路径>.common.BaseTest<模块组>-common/src/test/.../BaseTest.java
<模块组>-businesskd.<包路径>.business.BaseTest<模块组>-business/src/test/.../BaseTest.java
<模块组>-oppluginkd.<包路径>.opplugin.BaseTest<模块组>-opplugin/src/test/.../BaseTest.java
<模块组>-formpluginkd.<包路径>.formplugin.BasePluginTest<模块组>-formplugin/src/test/.../BasePluginTest.java

mmc-sfc 模块已知预置 Mock(参考)

三个工程(common/business/opplugin)共同预置 8 个 MockedStatic:OrgUnitServiceHelper、BillTypeParamHelper、ResManager、SystemParamServiceHelper、QueryServiceHelper、BusinessDataServiceHelper、OperationServiceHelper、BaseDataServiceHelper。

工程额外预置 Mock
mmc-sfc-commonSaveServiceHelper + EntityMetadataCache
mmc-sfc-businessSaveServiceHelper + IDataModel/IFormView;BillUnitAndQtytHelper(仅声明未初始化)
mmc-sfc-oppluginSingleton + BFTrackerServiceHelper + MutexUtils
mmc-sfc-formpluginBasePluginTest<T> 独立管理,以实际文件为准

常用 Mock 静态类清单

静态类用途常见 Mock 方式特殊要求
QueryServiceHelper数据库查询.when(() -> query(...)).thenReturn(...)—
BusinessDataServiceHelper业务数据加载.when(() -> loadSingle/load/loadFromCache(...)).thenReturn(...)—
OperationServiceHelper操作执行.when(() -> executeOperate(...)).thenReturn(opResult)—
SaveServiceHelper数据保存.when(() -> save/update(...)).thenReturn(...)—
BaseDataServiceHelper基础资料.when(() -> getBaseDataFromCache(...)).thenReturn(...)—
ResManager多语言资源.thenAnswer(inv -> inv.getArgument(0))—
EntityMetadataCache元数据缓存直接 mockStaticcommon 工程常用
CacheFactory缓存工厂Mock getCommonCacheFactory 链式调用—
ConvertServiceHelperBOTP 转换Mock push/convert 方法—
DB(kd.bos.db.DB)数据库操作mockStatic推荐先 ClassPool defrost(见 2.6)
ProcessSettleHelper工序结算ProcessSettleMockHelper.mockDistributeSessionlessCache()推荐避免直接 mockStatic(见 2.7)

高频混淆场景速查

场景[错误] 错误做法[通过] 正确做法
测试 protected 方法改方法可见性为 publicReflectHelper.invokeProtectedMethod()
测试 private static 方法跳过或改可见性ReflectHelper.invokeStaticMethod()
校验器收集错误直接 validate 不收集匿名子类重写 addMessage/addErrorMessage 收集到 List
POJO 测试用 Mock给纯 POJO 加 MockedStatic直接 new 对象,无需任何 Mock
断言写法assertTrue(true) 假断言 / 测试方法无任何 assert断言真实输出或 verify() 交互;提前返回用 verify(mock, never()).method()
Mockito 重载歧义any() 无法区分重载显式指定 any(DynamicObject.class)
any() 类型歧义any() 用于 Date/Set/String 等具体类型参数推荐明确类型:any(Date.class)、any(Set.class)、anyString();any() 仅在参数类型为 Object 且无歧义时使用
varargs 参数匹配any(String[].class)anyString()(varargs 专用)
DynamicObjectCollection 状态new DynamicObjectCollection().add() 后 isEmpty/size 不可靠mock(DynamicObjectCollection.class) + 显式 stub
点分隔字段名DynamicObjectMocker.add("a.b", v) 后 getLong("a.b") 抛 ORMDesignException平展查询结果用 mock(DynamicObject.class) + when().thenReturn()
Mock DB 类直接 mockStatic(DB.class)先执行 ClassPool defrost(见 2.6)
常量/枚举类引用不到用字面值 "A" 替代推荐避免字面值替代,主动告知用户补充
DynamicObject 空值断言assertNull(obj.getString("field"))getString()/getLong()/getInt()/getBigDecimal()/getBoolean() 平台默认不返回 null(即使 set null 也转为基本值):空字符串用 assertEquals("", obj.getString(...))、空数值用 assertEquals(0L, obj.getLong(...))、空 BigDecimal 用 assertEquals(BigDecimal.ZERO, ...)、布尔用 assertFalse(obj.getBoolean(...))。
若被测代码对 getter 结果做 null 检查(如 if (obj.getBigDecimal("qty") != null)),说明字段可能开启「允许为空」,测试建议覆盖 null 和非 null 两分支;无法判断时默认按非 null,在测试方法中加注释 // 假设字段未开启"允许为空",若实际可为 null 请告知。
getDate(key) 是例外,始终可能为 null,用 assertNull 或具体日期值断言。
DynamicObjectMocker + .set()被测代码调 dataEntity.set(field, v),dataEntity 由 DynamicObjectMocker 创建改用 mock(DynamicObject.class) + stub,.set() 在 mock 对象上是空操作,不抛 ORMDesignException

各测试模式快速参考

common 模块 — POJO 模式

public class XxxModelTest {
    private XxxModel model;

    @Before
    public void setUp() { model = new XxxModel(); }

    @UnittestCaseInfo(author = "...", ...)
    @Test
    public void testGetSetFieldName() {
        model.setFieldName("value");
        assertEquals("value", model.getFieldName());
    }

    @Test
    public void testDefaultValues() {
        // 输出字段默认为 ZERO,输入字段默认 null
        assertEquals(BigDecimal.ZERO, model.getAmount());
        assertNull(model.getFormId());
    }
}

business 模块 — Helper 模式

public class XxxHelperTest
    extends AbstractJunitNoDependenciesResManagerTest {

    MockedStatic<QueryServiceHelper> qsh;

    @Before public void before() {
        qsh = mockStatic(QueryServiceHelper.class);
    }
    @After public void after() { qsh.close(); }

    @UnittestCaseInfo(...)
    @Test
    @DisplayName("正常路径")
    public void testMethod_NormalCase() {
        qsh.when(() -> QueryServiceHelper.query(...))
            .thenReturn(mockData);
        Object result = XxxHelper.method(param);
        assertNotNull(result);
        reset();
    }
}

opplugin — Validator 模式

public class XxxValidatorTest
    extends AbstractJunitNoDependenciesResManagerTest {

    private final List<String> errors = new ArrayList<>();

    private XxxValidator buildValidator() {
        return new XxxValidator() {
            @Override
            protected void addErrorMessage(
                ExtendedDataEntity e, String msg) {
                errors.add(msg);
            }
        };
    }

    @Test
    public void testValidate_ShouldPass() {
        XxxValidator v = buildValidator();
        v.setDataEntities(buildData());
        v.validate();
        assertEquals(0, errors.size());
    }
}

formplugin — FormPlugin 模式

public class XxxPluginTest
    extends BasePluginTest<XxxPlugin> {

    private MockedStatic<SomeHelper> someHelper;

    @Before public void before() {
        someHelper = mockStatic(SomeHelper.class);
    }
    @After public void after() {
        someHelper.close();
    }

    // switch 每个 case 独立方法
    @Test
    public void testPropertyChanged_CaseMaterialId() { ... }
    @Test
    public void testPropertyChanged_CaseQty() { ... }
}
cosmic-unittest v1.4  ·  适用于 common / business / opplugin / formplugin 四工程结构的苍穹模块  ·  6 项性能优化