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 のカスタムルールを追加すべきかで悩むこともあるかと思うが個人的には、
- ktlint : 文法に対する検査
- ArchUnit : クラスなどの関係に対する検査 (そもそも class file を対象にしているので文法は検査できない)
という棲み分けだと考えていて、それぞれを必要な場面で運用している。