ArchUnit 実践:使用範囲が限定されたメソッドの可視性をパッケージプライベートまたはプライベートに強制する


// 実行環境
* AdoptOpenJDK 11.0.9.1+1
* JUnit 5.7.0
* ArchUnit 0.14.1

アーキテクチャテストのモチベーション

16 日目の ArchUnit 実践:同一パッケージからのみ呼び出されるメソッドの可視性をパッケージプライベートまたはプライベートに強制する と 17 日目の ArchUnit 実践:自クラスからのみ呼び出されるメソッドの可視性をプライベートに強制する の 2 つのアーキテクチャテストの対象となるメソッド群は包含関係(自クラスからのみ呼び出されるメソッド ⊆ 同一パッケージからのみ呼び出されるメソッド)にあります。
2 つのテストを同時に実行すると両方のテストで同じメソッドが違反として検出されテスト結果の精査が手間になる可能性があるため、1 つのテストにまとめてみます。

アーキテクチャテストの実装

package com.example;
 
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaAccess;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import org.junit.jupiter.api.Test;

import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;

class ArchitectureTest {

    // 検査対象のクラス
    private static final JavaClasses CLASSES =
            new ClassFileImporter()
                    .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
                    .importPackages("com.example");

    @Test
    void 使用範囲が限定されたメソッドの可視性をパッケージプライベートまたはプライベートに強制する() {
        methods()
            .should(new ArchCondition<>("be package private or be private, if the scope of use is limited") {
                @Override
                public void check(final JavaMethod input, final ConditionEvents events) {
                    Method method = new Method(input);

                    if (! method.isPrivate() 
                        && method.isOnlyCalledInDeclaredClass()
                    ) {
                        // 自クラスからのみ呼ばれるメソッドはプライベートであるべき
                        events.add(SimpleConditionEvent.violated(input,
                            String.format("`%s` should be private.", input.getFullName())));

                    } else if (! method.isPackagePrivate() 
                        && method.isOnlyCalledFromClassesInSamePackage()
                    ) {
                        // 同一パッケージからのみ呼ばれるメソッドはパッケージプライベートであるべき
                        events.add(SimpleConditionEvent.violated(input,
                            String.format("`%s` should be package private.", input.getFullName())));
                    }
                }
            })
            .check(CLASSES);
    }

    class Method {
        private final Set<JavaModifier> modifiers;
        private final JavaClass ownerClass;
        private final Set<JavaClass> callerClasses;

        Method(final JavaMethod method) {
            modifiers = method.getModifiers();
            ownerClass = method.getOwner();
            callerClasses = method.getAccessesToSelf()
                .stream()
                .map(JavaAccess::getOriginOwner)
                .collect(Collectors.toSet());
        }

        boolean isPrivate() {
            return modifiers.contains(JavaModifier.PRIVATE);
        }

        boolean isPackagePrivate() {
            return ! modifiers.contains(JavaModifier.PUBLIC)
                && ! modifiers.contains(JavaModifier.PROTECTED)
                && ! modifiers.contains(JavaModifier.PRIVATE);
        }

        boolean isOnlyCalledInDeclaredClass() {
            return callerClasses
                .stream()
                .allMatch(callerClass -> callerClass.isEquivalentTo(ownerClass.reflect()));
        }

        boolean isOnlyCalledFromClassesInSamePackage() {
            return callerClasses
                .stream()
                .allMatch(callerClass -> callerClass.getPackageName().equals(ownerClass.getPackageName()));
        }
    }
}