如何使用 ArchUnit 强制要求每个业务类都存在对应测试类

本文介绍如何通过 archunit 的自定义 `archcondition` 实现“每个顶层业务类必须有同名后缀为 `test` 的对应测试类”的强制校验规则,包含可复用的代码实现与关键注意事项。

在基于 Java 的模块化项目中,保障测试覆盖率不仅依赖开发自觉,更需通过架构约束实现自动化守门。ArchUnit 提供了强大的静态分析能力,但其内置规则(如 testClassesShouldResideInTheSamePackageAsImplementation())仅校验包结构一致性,无法直接验证“每个被测类是否拥有命名匹配的测试类”。要实现这一强约束,需借助 非局部条件(non-local condition) ——即让规则在执行前预先扫描全部类,构建测试类名称索引,再逐个校验业务类是否“被覆盖”。

核心思路是:

  1. 收集所有以 "Test" 结尾的类(如 UserServiceTest),提取其对应业务类名(如 UserService);
  2. 对每个符合条件的业务类(非接口、非枚举、非 record、非匿名类、顶层类),检查其全限定名是否存在于上述索引中;
  3. 若不存在,则触发失败事件,阻断构建。

以下是完整、可直接集成的 ArchUnit 测试规则:

@ArchTest
static final ArchRule relevant_classes_should_have_tests =
    classes()
        .that()
            .areTopLevelClasses()
            .and().areNotInterfaces()
            .and().areNotRecords()
            .and().areNotEnums()
        .should(haveACorrespondingClassEndingWith("Test"));

private static ArchCondition haveACorrespondingClassEndingWith(String testClassSuffix) {
    return new ArchCondition("have a corresponding class with suffix " + testClassSuffix) {
        private Set testedClassNames = Collections.emptySet();

        @Override
        public void init(Collection allClasses) {
            this.testedClassNames = allClasses.stream()
                .map(JavaClass::getName)
                .filter(name -> name.endsWith(testClassSuffix))
                .map(name -> name.sub

string(0, name.length() - testClassSuffix.length())) .collect(Collectors.toSet()); } @Override public void check(JavaClass clazz, ConditionEvents events) { // 跳过测试类自身(避免 self-match) if (clazz.getName().endsWith(testClassSuffix)) { return; } boolean hasCorrespondingTest = testedClassNames.contains(clazz.getName()); String message = String.format( "%s %s a corresponding test class ending with '%s'", clazz.getSimpleName(), hasCorrespondingTest ? "has" : "lacks", testClassSuffix ); events.add(new SimpleConditionEvent(clazz, hasCorrespondingTest, message)); } }; }

关键说明与最佳实践:

  • init() 方法是关键:ArchUnit 会自动在规则执行前调用它,并传入当前扫描范围内所有 JavaClass 对象,因此可安全构建全局索引;
  • 跳过测试类自身校验:check() 中显式 return 掉以 Test 结尾的类,防止误判(如 UserServiceTest 错误地被要求匹配 UserServiceTestTest);
  • ⚠️ 注意类路径范围:该规则默认作用于 classes() 扫描的所有类(含 src/main/java 和 src/test/java)。若项目结构复杂(如多模块、测试类位于不同 source set),建议配合 importOption 精确控制扫描范围;
  • ? 支持灵活后缀:将 "Test" 替换为 "IT" 或 "IntegrationTest" 即可适配集成测试命名规范;
  • ? 可组合增强:可进一步结合 resideInAnyPackage(...) 限定校验范围(如仅检查 com.example.service.. 下的类),避免污染第三方或配置类。

最后,在 CI/CD 流程中启用此规则,即可将“无测试即不合法”真正落地为工程红线——既提升可维护性,也强化团队对测试驱动文化的共识。