raahii.meのブログのロゴ画像

ウェブログ

Gradleでテストの依存を共通化する

はじめに

Gradleで複数プロジェクトを管理していると、しばしば共通の依存を集約して書きたいシーンがあります。

その中でも今回は、テスト向けの依存を共通化する方法について書きます。特にテスト向けに絞る必要はないのですが、テストに焦点をあてることでcompileClasspath/testCompileClasspathあたりの理解も深まったので、そのような内容としました。

プロジェクト構成とゴール

今回は次のような構成でプロジェクトを作成します。

まず上図の通りにプロジェクト間の依存だけを宣言し、:test-baseにはテスト用の依存としてorg.opentest4j:opentest4jのみを定義してスタートします(opentest4jを選んだのはdependency treeをシンプルにするためです)。

  • app1とapp2の build.gradle.kts
plugins {
    `java`
}

dependencies {
  implementation(projects.appBase)
}
  • app-baseの build.gradle.kts
plugins {
    `java-library`
}

dependencies {
  implementation(projects.testBase)
}
  • test-baseの build.gradle.kts
plugins {
    `java-library`
}

dependencies {
  implementation("org.opentest4j:opentest4j:1.3.0")
}

そしてゴールは、:test-baseにまとめて定義される依存を、:app1:app2にまで適用することです。 今回はappが2つですが、実際にはもっと増えるイメージなので、各appから直接testImplementation(:test-base)せずにそれを達成したいとします。

GitHub

implementationapiを理解する

最初に現状を確認します。各subprojectの依存、compileClasspathtestCompileClasspathproject-reportプラグインで可視化します。

$ ./gradlew dependencyReport
------------------------------------------------------------
Project ':app1'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- project :app-base

testCompileClasspath - Compile classpath for source set 'test'.
\--- project :app-base
------------------------------------------------------------
Project ':app-base'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- project :test-base

testCompileClasspath - Compile classpath for source set 'test'.
\--- project :test-base
------------------------------------------------------------
Project ':test-base'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- org.opentest4j:opentest4j:1.3.0

testCompileClasspath - Compile classpath for source set 'test'.
\--- org.opentest4j:opentest4j:1.3.0

このようにまず、implementationで宣言した依存はcompileClasspathtestCompileClasspathに入っていることがわかります。 このままでは:test-baseの依存が:app1:app2にまで伝播することはありません。

では次に、各appに:test-baseの依存を伝播させるために、:app-base:test-baseで宣言している依存をapiに変更してみます。

  • app-baseのbuild.gradle.kts
dependencies {
-  implementation(projects.testBase)
+  api(projects.testBase)
}
  • test-baseのbuild.gradle.kts
dependencies {
-  implementation("org.opentest4j:opentest4j:1.3.0")
+  api("org.opentest4j:opentest4j:1.3.0")
}

再度依存を確認すると、:app1:app2にも:test-baseの依存が入ってきました!🎉

------------------------------------------------------------
Project ':app1'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- project :app-base
     \--- project :test-base
          \--- org.opentest4j:opentest4j:1.3.0

testCompileClasspath - Compile classpath for source set 'test'.
\--- project :app-base
     \--- project :test-base
          \--- org.opentest4j:opentest4j:1.3.0

このようにapiを使うことで利用側にもその依存を伝播させることができます。ポイントとして、apiは利用側のさらに利用側にまで連鎖的に依存が伝播することはありません。あくまで1つ上位の利用側のみに公開されるのみです。

さて、これでテスト向けの依存が各appでも利用可能となったわけですが、問題が1つあります。それは、テスト向けの依存なのにappのcompileClasspathにも入ってしまっていることです。

これを解決するために、各appで宣言している:app-baseへの依存をtestImplementationに変更します。

dependencies {
-  implementation(projects.appBase)
+  testImplementation(projects.appBase)
}
------------------------------------------------------------
Project ':app1'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
No dependencies

testCompileClasspath - Compile classpath for source set 'test'.
\--- project :app-base
     \--- project :test-base
          \--- org.opentest4j:opentest4j:1.3.0

testImplementationを使うことでtestCompileClasspathにのみ依存を入れることができました。 しかしこれだと今度は、:app-baseに集約されるであろう、appのmain sourceのコンパイルに必要な依存やビジネスロジックなどが利用できなくなります。真にやりたいのは:test-baseの依存だけをtestCompileClasspathに入れることなのです。

解決策: ルートのbuild.gradle.ktsで共通の依存に定義する

今回のプロジェクト構成では、appが共通に持つ:app-baseという中間のsubprojectを利用してどうにか:test-baseへの依存を共通化できないか模索しましたが難しそうに見えます。

しかし今回の場合は、とりあえず全subprojectに:test-baseの依存が入ればよいだけなので、次のような設定で解決できます。

  • ルートのbuild.gradle.kts
plugins {
  ...
+ `java`
}

subprojects {
  ...

+  apply(plugin = "java")
+
+  dependencies {
+      testImplementation(project(":test-base"))
+  }
}
  • app1, app2のbuild.gradle.kts
dependencies {
-  testImplementation(projects.appBase)
+  implementation(projects.appBase)
}
  • app-baseのbuild.gradle.kts
dependencies {
-  api(projects.testBase)
}

最終的には下記のように、:app-baseの依存はcompileClasspathに残しつつも、:test-baseの依存のみtestCompileClasspathに残すことが出来ました。

------------------------------------------------------------
Project ':app1'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- project :app-base

testCompileClasspath - Compile classpath for source set 'test'.
+--- project :app-base
\--- project :test-base
     \--- org.opentest4j:opentest4j:1.3.0

GitHub

ちなみに今回は簡単のために全subprojectに一斉適用していますが、例えばappだけに適用したいという場合も convention plugin 等を使えば可能です(この辺りは別途書こうと思っているのでここでは割愛)。

参考: Sharing build logic between subprojects Sample

別解: testFixturesを使う

java-test-fixtures というプラグインを導入すると、testFixturesCompileClasspath というclasspathが追加され、そこの依存を利用側ではtestImplementation(testFixtures(:app-base)のようにして引き継ぐことができます。

これまでのimplementaion(:app-base)testImplemenntation(:app-base)では、:app-base自体とapiで定義されたcompileClasspath上のクラスしか引き継ぐことが出来ませんでしたが、testFixtures(:app-base)とすることでtestFixturesCompileClasspath上のクラスを引き継ぐことができるようになります。

なのでこんな事ができます。まずルートのbuild.gradle.ktsjava-test-fixturesプラグインを適用します。

plugins {
  ...
+  `java-test-fixtures`
}

subprojects {
+  apply(plugin = "java-test-fixtures")
  ...
}

:test-baseの持つ依存はapi("org.opentest4j:opentest4j:1.3.0")のままで、依存だけ確認するとtestFixturesCompileClasspathにも追加されていることがわかります。

------------------------------------------------------------
Project ':test-base'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- org.opentest4j:opentest4j:1.3.0

testCompileClasspath - Compile classpath for source set 'test'.
+--- org.opentest4j:opentest4j:1.3.0
+--- project :test-base (*)
\--- project :test-base (*)

testFixturesCompileClasspath - Compile classpath for source set 'test fixtures'.
+--- project :test-base (*)
\--- org.opentest4j:opentest4j:1.3.0

次に:app-basebuild.gradle.ktsでは:test-basetestFixturesapiで引き継ぎます。

dependencies {
+  testFixturesApi(testFixtures(projects.testBase))
}

すると:test-baseの依存はtestFixturesCompileClasspathにだけ放り込むことができます。

------------------------------------------------------------
Project ':app-base'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
No dependencies

testCompileClasspath - Compile classpath for source set 'test'.
+--- project :app-base (*)
+--- project :app-base (*)
\--- project :test-base
     +--- project :test-base (*)
     \--- org.opentest4j:opentest4j:1.3.0

testFixturesCompileClasspath - Compile classpath for source set 'test fixtures'.
+--- project :app-base (*)
\--- project :test-base
     +--- project :test-base (*)
     \--- org.opentest4j:opentest4j:1.3.0

最後に、各appで:app-basetestFixturesだけを引き継ぎます。

dependencies {
  implementation(projects.appBase)
+  testImplementation(testFixtures(projects.appBase))
}

そうすると:test-baseの依存が:app-baseを超えて各appのtestCompileClasspathにだけ入ってきました。

------------------------------------------------------------
Project ':app1'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- project :app-base

testCompileClasspath - Compile classpath for source set 'test'.
+--- project :app-base
|    +--- project :app-base (*)
|    \--- project :test-base
|         +--- project :test-base (*)
|         \--- org.opentest4j:opentest4j:1.3.0
+--- project :app1 (*)
+--- project :app-base (*)
\--- project :app1 (*)

testFixturesCompileClasspath - Compile classpath for source set 'test fixtures'.
\--- project :app1 (*)

とはいえ、この方法だと構造がややこしい上に、結局各appでtestImplementation(testFixtures(projects.appBase))を追加するなら、最初からtestImplementation(projects.testBase)すれば良いだけ…となるのでちょっと微妙ですね。

ただ、classpathの関係を理解するのには良い題材かなという感じがしました。

GitHub

余談: testApiについて

超余談ですが、kotlin pluginを使うと testApi というAPI(configuration)が使えます。testApi(:app-base) のように使います。

これ、いかにも:app-baseの依存をtestCompileClasspathに入れつつ、:app-baseの利用側でもtestCompileClasspathを引き継げるようなAPIに見えるのですが、ここまでの説明で分かる通り、testImplementation(:app-base)と書いても引き継げるのはcompileClasspathで公開されているクラスだけなので、そういうことはできません。

意味不明だなと思って調べていたら、同じことを言っている人がいて少し安心しました。そうそう、test dependenciesに関してapiって関係がないんですよね。

I’m not sure why this was added, but as best I can tell it increases complexity without much benefit.

For one: test source set code generally isn’t depended upon. Gradle offers Test Fixtures for that. If the idiomatic purpose for Gradle api configurations is to signify dependencies that are part of an outgoing variant’s public ABI, it’s not really relevant for test dependencies.

KGP: Deprecate and remove `testApi` configuration : KT-63285

もしかするとtestFixturesのようにtestCompileClasspathを引き継ぐ方法もあるのかもしれませんが、ややこしいので使う必要はなさそうです。

結論

  • 依存は、ルートの build.gradle.kts や convention plugin を使って共通化できる。
  • implementaionapi を使った依存は compileClasspathtestCompileClasspath の両方に入る。一方 testImlementaion の依存は testCompileClasspath にしか入らない。
  • api で宣言した依存は利用側にも公開され伝播する。ただし、連鎖的に伝播することはない。
  • testApi というAPIが存在するが、そもそも testCompileClasspath の依存は利用側に公開されないので使い道がない。testFixtures の仕組みを使うと testFixturesCompileClasspath にテスト向けの依存を入れ伝播させていくことが可能だが、やや複雑である。