ArchUnit をマルチモジュール構成に対して適用する

ArchUnit は、

ArchUnit is a free, simple and extensible library for checking the architecture of your Java code using any plain Java unit test framework. That is, ArchUnit can check dependencies between packages and classes, layers and slices, check for cyclic dependencies and more. It does so by analyzing given Java bytecode, importing all classes into a Java code structure.

とあるように Java 向けの lint ツールのようなもので例えばパッケージ間の依存などをユニットテストの仕組みの乗っかって実行できるツールとなっている。

※ この記事は、 ブログ執筆時点で最新の https://github.com/TNG/ArchUnit/releases/tag/v1.2.1 を対象としている

多くの紹介記事では、「アーキテクチャの検査ツール」と紹介されることが多いが個人的には、もっと広義の lint ツールだと考えていて、例えば付与すべきアノテーションが付与されていないというようなルールをより自然言語に近い形で記述できることが魅力に感じていて、ここ2年くらい自分が担当しているプロダクトで利用している。
例えば以下のような形で検査を書くことができる。

@Suppress("NonAsciiCharacters", "TestFunctionName")
class ShouldNotUseJodaTimeTest {

    @Test
    fun test_JodaTimeをImportしない() {
        val allClasses = ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages("your.target.package")

        val rule: ArchRule = noClasses()
            .should().dependOnClassesThat().resideInAPackage("org.joda.time")
            .because("JSR-310 で導入された java.time 利用するべき")

        rule.check(allClasses)
    }
}

これによって org.joda.time のパッケージに含まれるクラスへの依存がないことを確認することができる。
またこのテスト自体は JUnit を動作させる時と変わらないので既存の CI などにもそのまま組み込むことができる。

このように依存関係やアーキテクチャルールの検査に留まらず、「クラスファイル (≒ Java バイトコード) を対象にした自然言語で記述することができる lint ツール」と捉えている。
またクラスファイルを対象にしているからこそ依存関係やメソッドの呼び出し関係などの情報に気軽にアクセスすることができるため ktlint のような字面を lint するツールとはうまく棲み分けできると思っている。

マルチモジュール構成

本題はこちらで、現在自分が担当しているプロダクトは SpringBoot + Kotlin で開発をしていて、いくつかのモジュールに分割を進めている。
イメージとしては以下のような構成。

. # -> ./backend のようにバックエンド側の実装の全てをこのディレクトリに含んでいる
├── build.gradle.kts
├── settings.gradle.kts
├── moduleA
│   ├── build
│   ├── build.gradle.kts
│   └── src
├── moduleB
│   ├── build
│   ├── build.gradle.kts
│   └── src
└── moduleC
    ├── build
    ├── build.gradle.kts
    └── src

ArchUnit はもともとは、 moduleA 相当の JUnit のテストとして書いていたものの、 moduleB にも moduleC にも適用したくなってきてしまう。
それぞれのテストケースとして書くこともできなくはないが、それだと重複したテストが複数発生したりしてモジュールが増えていくと考えると非常に筋が悪い。

そこで新たに test-archunit というモジュールを横串で作成して全てのモジュールに対してテストを実装することにした。

.
├── build.gradle.kts
├── settings.gradle.kts
├── moduleA
│   ├── build
│   ├── build.gradle.kts
│   └── src
├── moduleB
│   ├── build
│   ├── build.gradle.kts
│   └── src
├── moduleC
│   ├── build
│   ├── build.gradle.kts
│   └── src
└── test-archunit # -> 今回追加したモジュール
    ├── build
    ├── build.gradle.kts
    └── src # -> テストにしかコードがない状態

ArchUnit の挙動としてはいわゆる CLASSPATH に含まれている jar や .class ファイルを自動的に検出して検査対象に含めてくれるという仕組みとなっている。
したがって、基本的には以下のようにテスト実行時の依存関係として含めればそれぞれのモジュールの jar が作られ検査の対象となる。

# ./backend/test-archunit/build.gradle.kts
dependencies {
    // ...

    testImplementation(project(":moduleA"))
    testImplementation(project(":moduleB"))
    testImplementation(project(":moduleC"))

    // ...
}

ところで自分のプロダクトでは、 SpringBoot ということもありテストコードに対して以下のようなルールを定義している

@Suppress("NonAsciiCharacters", "TestFunctionName")
class TransactionalAnnotationTest {
    /**
     * [SpringBootTest] の Context の中においては、[Transactional] を付与すればテスト終了時に自動でロールバックされるため付与すること
     *
     * see also
     * https://spring.pleiades.io/spring-framework/reference/testing/testcontext-framework/tx.html
     */
    @Test
    fun test_DBを操作するテストはすべてTransactionが付与されている() {
        val allClassesInPackages = ClassFileImporter()
            .withImportOption(OnlyIncludeTestsAndTestArchiveImportOption()) // `OnlyIncludeTestsAndTestArchiveImportOption` についてはカスタムクラスなので後述する
            .importPackages("your.package.doing.db.test", "or.other.package.doing.db.test")

        val rule: ArchRule = ArchRuleDefinition.methods()
            .that().areAnnotatedWith(Test::class.java)
            .or().areAnnotatedWith(ParameterizedTest::class.java)
            .should().beAnnotatedWith(Transactional::class.java)
            .because("データベースに対して直接書き込みを行うテストでは、テスト毎にロールバックさせる必要がある。")

        rule.check(allClassesInPackages)
    }
}

よくよく考えればそれはそうという感じだけれど、 testImplementation(project(":moduleA")) で依存関係に含まれる jar にはプロダクションコード (≒ main/ のコード) のみが含まれている。
つまり上記の依存関係の定義だけでは、テストコードを対象にすることはできないのである。

なんとかしてテストクラスも CLASSPATH に含めようと色々と調査したところ以下のように test/ のみを対象として Jar を作成するタスクと configuration を設定することにした。

# ./backend/build.gradle.kts
# 全てのモジュールについて "test" という configuration を追加している
subprojects {
    configurations {
        create("test")
    }

    tasks.register<Jar>("testJar") {
        archiveClassifier.set("test-archive")
        from(project.the<SourceSetContainer>()["test"].output)
    }

    artifacts {
        add("test", tasks["testJar"])
    }
}

このように定義することで以下のように “test” という configuration を利用して依存解決をすることができるようになる。

# ./backend/test-archunit/build.gradle.kts
dependencies {
    // ...

    testImplementation(project(":moduleA"))
    testImplementation(project(":moduleB"))
    testImplementation(project(":moduleC"))

    testImplementation(project(":moduleA", "test"))
    testImplementation(project(":moduleB", "test"))
    testImplementation(project(":moduleC", "test"))

    // ...
}

ちなみにもうちょっとだけ楽したいので以下のように全てのモジュールは自動で含まれるようにしている (構成によってはサボるのは難しいかもだけど、自分のプロダクトでは問題なかった)

# ./backend/test-archunit/build.gradle.kts
dependencies {
    // ...

    rootProject.subprojects.forEach {
        testImplementation(project(it.path))
        testImplementation(project(it.path, "test"))
    }

    // ...
}

また後述するといった OnlyIncludeTestsAndTestArchiveImportOption の話が最後にある。
上記のような依存解決を行えば、 CLASSPATH には含まれる形になるものの (詳細はこの辺をデバッガーで止めるとわかる)、実は既存の ImportOption.ONLY_INCLUDE_TESTS (該当コード) をみると、以下のような定義になっているため、上記で作成するようにしたテストコードが含まれた jar は ImportOption.ONLY_INCLUDE_TESTS では対象にすることができない。

// ...

ONLY_INCLUDE_TESTS {
    private final OnlyIncludeTests onlyIncludeTests = new OnlyIncludeTests();

    @Override
    public boolean includes(Location location) {
        return onlyIncludeTests.includes(location);
    }
},

// ...

// ここのパスのパターンが jar には対応しておらず `/build/classes` にあるテストの class file だけを対象としている
static final PatternPredicate MAVEN_TEST_PATTERN = new PatternPredicate(".*/target/test-classes/.*");
static final PatternPredicate GRADLE_TEST_PATTERN = new PatternPredicate(".*/build/classes/([^/]+/)?test/.*");
static final PatternPredicate INTELLIJ_TEST_PATTERN = new PatternPredicate(".*/out/test/.*");
static final Predicate<Location> TEST_LOCATION = MAVEN_TEST_PATTERN.or(GRADLE_TEST_PATTERN).or(INTELLIJ_TEST_PATTERN);

final class OnlyIncludeTests implements ImportOption {
    @Override
    public boolean includes(Location location) {
        return TEST_LOCATION.test(location);
    }
}

// ...

したがって、今回テストクラスだけを含めた jar も対象に加えるために以下のようなカスタムの ImportOption を用意した

class OnlyIncludeTestsAndTestArchiveImportOption : ImportOption {
    private val onlyIncludeTests = OnlyIncludeTests() // 今回追加した test-archunit のモジュールに含まれるテストコードも対象とする
    private val testArchiveJarPathPattern = Pattern.compile(".*/build/libs/.*-test-archive.jar!.*")

    override fun includes(location: Location): Boolean {
        return onlyIncludeTests.includes(location)
            .or(location.matches(testArchiveJarPathPattern))
    }
}

これによって、以下の gradle の設定によって出力された jar を対象に組み込むことができる。

tasks.register<Jar>("testJar") {
    archiveClassifier.set("test-archive")
    from(project.the<SourceSetContainer>()["test"].output)
}

ktlint との使い分け

ところで Kotlin では、 ktlint という lint 界の覇者がすでにいて、自分のプロダクトでも ArchUnit を使いながらも ktlint も利用している。
この ktlint もカスタムのルールが以下のようなコードで割と簡単に作ることができる ( see also https://pinterest.github.io/ktlint/1.0.1/api/custom-rule-set/ )。

// code is from https://github.com/pinterest/ktlint/blob/master/ktlint-ruleset-template/src/main/kotlin/yourpkgname/NoVarRule.kt

public class NoVarRule :
    Rule(
        ruleId = RuleId("$CUSTOM_RULE_SET_ID:no-var"),
        about =
            About(
                maintainer = "Your name",
                repositoryUrl = "https://github.com/your/project/",
                issueTrackerUrl = "https://github.com/your/project/issues",
            ),
    ) {
    override fun beforeVisitChildNodes(
        node: ASTNode,
        autoCorrect: Boolean,
        emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit,
    ) {
        if (node.elementType == ElementType.VAR_KEYWORD) {
            emit(node.startOffset, "Unexpected var, use val instead", false)
        }
    }
}

なので ArchUnit でやるべきか、 ktlint のカスタムルールを追加すべきかで悩むこともあるかと思うが個人的には、

という棲み分けだと考えていて、それぞれを必要な場面で運用している。