🫖

Kotlin(Spring Boot) に ArchUnit を導入する

2022/11/25に公開

概要

Java のライブラリに ArchUnit というものが存在します。
Kotlin(Spring Boot)でも導入したかったので、調査して導入方法についてまとめました。
本記事では導入方法と簡単なユニットテストまでを対象とします。込み入った使い方については、後日まとめます。

https://github.com/TNG/ArchUnit

https://www.archunit.org/

ArchUnit について

ArchUnit は名前の通り、アーキテクチャの構造をチェックするライブラリです。パッケージ、クラス、レイヤの依存関係をユニットテストでチェックできます。
たとえば以下のようなパターンをユニットテストで表現できます。

  • 「source」というパッケージが、「hoge」というパッケージに依存しない
  • 「Fuga」で終わるクラスは、「Fuga」クラス以外を参照しない
  • レイヤ間の参照をチェックする
  • 循環参照を禁止する

クリーンアーキテクチャや DDD といったレイヤの依存関係やオブジェクトの生成箇所を制限したい場合に ArchUnit は有効な手段になります。

導入方法

ArchUnit には JUnit4 と JUnit5 サポートが公式から提供されています。今回は JUnit5 を利用するため、com.tngtech.archunit:archunit-junit5を build.gradle.kts に記述します。

dependencies {
    /**
     * Archunit
     *
     * MavenCentral
     * - https://mvnrepository.com/artifact/com.tngtech.archunit/archunit-junit5
     * Main 用途
     * - アーキテクチャの設計思想を単体テスト化
     * Sub 用途
     * - なし
     * 概要
     * - ArchUnit の JUnit5 バージョン
     *   - 他には、JUnit4 バージョンやプレーンな archunit が存在する。
     *   - 本リポジトリでは JUnit5 バージョン以外不要
     * - アーキテクチャの設計思想を守れているか、単体テストで確認可能になる
     *   - CI に組み込むことで、PR、マージ時点で、発見可能
     * - テスト対象
     *   - パッケージ、クラス、レイヤー、循環参照など
     */
    testImplementation("com.tngtech.archunit:archunit-junit5:1.0.0")
}

他には以下の種類のモジュールがあります。1 つ目は JUnit に限らないテストを実施する場合、2 つ目は JUnit4 、残りは JUnit5 の細かい拡張です。

  • archunit
  • archunit-junit4
  • archunit-junit5-api
  • archunit-junit5-engine
  • archunit-junit5-engine-api

適切に理解できていませんが、以下のような用途別に考えられれば利用自体はできると思いました。

用途 モジュール
JUnit5 を使う com.tngtech.archunit:archunit-junit5:1.0.0
JUnit4 を使う com.tngtech.archunit:archunit-junit4:1.0.0
それ以外のテストフレームワークを使う com.tngtech.archunit:archunit:1.0.0

動作確認

ArchUnit を用いた簡単なユニットテストを実装します。
本記事のユニットテストでは、層の依存関係をテストします。

サンプルコード概要

本記事のためにサンプルコードを用意しました。以下のコードに対してテストコードを記述します。

https://github.com/Msksgm/kotlin-spring-boot-archunit-sample

テストとしては以下のような、依存関係逆転の原則を適用したレイヤドアーキテクチャを対象とします。矢印は依存関係の向きを表しています。

依存関係逆転の原則を適用したレイヤードアーキテクチャ

ソースコードは以下になります。具体的な処理は書かれていませんが、あえて依存させるように使用しないけどインスタンス化させている部分があります。

プレゼンテーション層。
src/main/kotlin/com/example/kotlinspringbootarchunitsample/presentation/CustomerController.kt
@RestController
class CustomerController(val customerUseCase: CustomerUseCase) {
    @PostMapping("/customers")
    fun insert(): String {
        return """
            {
                "message": "success"
            }
        """.trimIndent()
    }
}

ユースケース層。
src/main/kotlin/com/example/kotlinspringbootarchunitsample/usecase/CustomerUseCase.kt
interface CustomerUseCase {
    fun insertCustomer(firstName: String, lastName: String)
}

@Service
class CustomerUseCaseImpl(val customerRepository: CustomerRepository) : CustomerUseCase {
    override fun insertCustomer(firstName: String, lastName: String) {
        TODO("Not yet implemented")
        // NOTE: 何かしらの処理で、ドメイン層の Customer を参照したとする
        val customer = Customer("usecaseFirstName", "usecaseLastName")
    }
}

インフラ層。
src/main/kotlin/com/example/kotlinspringbootarchunitsample/infra/CustomerRepository.kt
interface CustomerRepository {
    fun add(firstName: String, lastName: String)
}

@Repository
class CustomerRepositoryImpl: CustomerRepository {
    override fun add(firstName: String, lastName: String) {
        TODO("Not yet implemented")
        // NOTE: 何かしらの処理で、ドメイン層から Customer を参照したと想定する
        val customer = Customer("repositoryFirstName", "repositoryLastName")
    }
}

ドメイン層。
src/main/kotlin/com/example/kotlinspringbootarchunitsample/domain/Customer.kt
data class Customer(
    val firstName: String,
    val lastName: String
)

ArchUnit を用いたテストコード

サンプルコードが依存関係逆転の原則をレイヤドアーテキクチャに適用し、依存関係を守れているのか確認するテストコードを紹介します。

テストコード全体は以下になります。
変数に@ArchTestアノテーションを付与して、宣言的に層の依存関係を守れているか確認をします。内容としてはアーキテクチャの図の通りで、矢印の方向を守れているのか確認しています。

src/test/kotlin/com/example/kotlinspringbootarchunitsample/architecture/LayerTest.kt
@AnalyzeClasses(
    packages = ["com.example.kotlinspringbootarchunitsample"],
    importOptions = [
        ImportOption.DoNotIncludeTests::class, ImportOption.DoNotIncludeJars::class
    ],
)
class LayerTest {
    companion object {
        private const val DOMAIN_PACKAGE = "..domain.."
        private const val USECASE_PACKAGE = "..usecase.."
        private const val PRESENTATION_PACKAGE = "..presentation.."
        private const val INFRA_PACKAGE = "..infra.."
    }

    @ArchTest
    val `ドメイン層はプレゼンテーション層、インフラ層、ユースケース層を参照しない` =
        noClasses()
            .that()
            .resideInAPackage(DOMAIN_PACKAGE)
            .should()
            .accessClassesThat()
            .resideInAnyPackage(USECASE_PACKAGE, PRESENTATION_PACKAGE, INFRA_PACKAGE)

    @ArchTest
    val `プレゼンテーション層はドメイン層とインフラ層を参照しない` =
        noClasses()
            .that()
            .resideInAPackage(PRESENTATION_PACKAGE)
            .should()
            .accessClassesThat()
            .resideInAnyPackage(INFRA_PACKAGE, DOMAIN_PACKAGE)

    @ArchTest
    val `ユースケース層はプレゼンテーション層を参照しない` =
        noClasses()
            .that()
            .resideInAPackage(USECASE_PACKAGE)
            .should()
            .accessClassesThat()
            .resideInAPackage(PRESENTATION_PACKAGE)
}

順を追って説明します。

@AnalyzeClasses

@AnalyzeClassesはテスト対象の package とテストから除外する package を指定します。

@AnalyzeClasses(
    packages = ["com.example.kotlinspringbootarchunitsample"],
    importOptions = [
        ImportOption.DoNotIncludeTests::class, ImportOption.DoNotIncludeJars::class
    ],
)

packages が適切に指定できていないと、テストしたい package を見つけられずテストが落ちます。
例では文字列でハードコーディングをしていますが、該当パッケージのクラスを packageOf で指定することで代替も可能です。

@AnalyzeClasses(
    packagesOf = [KotlinSpringBootArchunitSampleApplication::class],
    importOptions = [
        ImportOption.DoNotIncludeTests::class, ImportOption.DoNotIncludeJars::class
    ],
)

また、テストクラスそのものがプロダクトコードの階層と一致している場合(または、一致させた場合)、テストクラスを packageOf で指定してもテスト可能です。

@AnalyzeClasses(
    packagesOf = [LayerTest::class],
    importOptions = [
        ImportOption.DoNotIncludeTests::class, ImportOption.DoNotIncludeJars::class
    ],
)

@ArchTest

@ArchTestアノテーションによって、ArchUnit のテストが可能です。分かりにくいですが、この状態でテストを実行すると、テストの実施が確認できます。
ただし、IntelliJ は変数をテストとして認識でないので、Intellij の機能でテストできません。IntelliJ 上で実行するにはテストクラスごとテストを実行する必要があります。

    @ArchTest
    val `ドメイン層はプレゼンテーション層、インフラ層、ユースケース層を参照しない` =
        noClasses()
            .that()
            .resideInAPackage(DOMAIN_PACKAGE)
            .should()
            .accessClassesThat()
            .resideInAnyPackage(USECASE_PACKAGE, PRESENTATION_PACKAGE, INFRA_PACKAGE)

関数として切り出すことも可能です。
しかし、importClasses: JavaClassesは package 全体を探索しているため、関数ごとにインスタンス化されると、遅くなる問題があります。
そのため、上記のやり方の方が高速に実行可能です。

    @ArchTest
    fun `ドメイン層はプレゼンテーション層、インフラ層、ユースケース層を参照しない`(importClasses: JavaClasses) {
        noClasses()
            .that()
            .resideInAPackage(DOMAIN_PACKAGE)
            .should()
            .accessClassesThat()
            .resideInAnyPackage(USECASE_PACKAGE, PRESENTATION_PACKAGE, INFRA_PACKAGE)
            .check(importClasses)
    }

    @ArchTest
    fun `ユースケース層はプレゼンテーション層を参照しない`(importClasses: JavaClasses) {
        noClasses()
            .that()
            .resideInAPackage(USECASE_PACKAGE)
            .should()
            .accessClassesThat()
            .resideInAPackage(PRESENTATION_PACKAGE)
            .check(importClasses)
    }

テスト実行

ユニットテストに ArchUnit が含まれるので、./gradlewを用いて実行します。

> ./gradlew test
Starting a Gradle Daemon (subsequent builds will be faster)

BUILD SUCCESSFUL in 19s
4 actionable tasks: 1 executed, 3 up-to-date

紹介した例では、成功パターンのみでした。
ArchUnit で必ず落ちるテストを記述するとしっかりテストが落ちます。

     @ArchTest
     val `落ちるテスト「ドメイン層は、プレゼンテーション層、インフラ層、ユースケース層を参照する」` =
         classes()
             .that()
             .resideInAPackage(DOMAIN_PACKAGE)
             .should()
             .accessClassesThat()
             .resideInAnyPackage(USECASE_PACKAGE, PRESENTATION_PACKAGE, INFRA_PACKAGE)

まとめ

本記事では、ArchUnit を Kotlin(Spring Boot)に対して導入する方法を紹介しました。
JUnit サポートがあるため、既存のプロジェクトに対して後付けで導入しやすいと考えています。
ただし、ArchUnit の書き方そのものは、独自のものなので初期の導入コストと新規参画者に対するサポートが必要だと思いました。
ArchUnit は込み入った条件(特定の名前のメソッド呼び出しの禁止、など)のユニットテストも可能ですので、今後も調査を続けてまとめます。

Discussion