JUnit 5 でどこが変わったのか

JUnit5 でどこが変わったのか、今いるチームの開発メンバや、JUnit5ざっくり知りたい人向けに書いておきます。 この記事では以下の内容について書いていきます。

  • なぜ移行するか
  • どこが変わったか
  • まとめ

なぜ移行するのか

なぜ移行するかを簡単に説明しておきます。 以下のような内容になってくるかなと思います。

  • やっぱり新しいライブラリ・ツールを使いたい
  • 新しい @ParameterizedTest を使いたい
  • Java 11のサポート

僕としては、これから何年かはJUnit5で書いていくことになると思うので、ツールを早く導入していきたいという気持ちがあり 「やっぱり新しいライブラリ・ツールを使いたい」という理由を挙げています。 また、junit-jupiter-params@ParameterizedTest を使いたい気持ちがあります。まだAPIとしては experimental ですが、 個人的には、以前のものと比べると、使いやすいなぁと思っています。 また、JUnit5はモジュールの構成が拡張のしやすさもあり、今後使っていきたいところです。

JUnit5はJava11のサポートがありますが、JUnit4では動く保証がありません。 そのため、Java11へ移行するのであれば、JUnit5へ移行していく必要があります。 とはいいつつ、JUnit4は今の所、新しいJavaのバージョンになっても、手元のアプリケーションでは、なんとなく動いています。 Javaのアップデートのために急ぐ必要はないと思っています。 また、JUnit5にはJUnit4で書かれたテストを動かす機能もあるので、テストコードの修正なしに Gradleなどのビルドツールの設定と依存関係を追加することによって Java11への移行がすんなり行えるかもしれません。

そんなこんなで、今すぐ変えよう!という感じではないのですが、やっぱり新しいものは使っていきたいね、というところで 「どこが変わったのか」について書いていきたいと思います。 「どうやって移行するのか」については、この記事を読んだあとに以下の記事を読んでいただければな、と思います。

どこが変わったのか

まずはざっくり、マイグレーションガイドを見つつ、自分が知っている変わった点の一覧を書いてみます。

  • アノテーションのパッケージ名とアノテーション自体の名前の変更
  • @Test にあった expectedtimeout の属性の削除
  • アサーションのパッケージ名変更とインターフェースの変更
  • 新しい拡張モデルとして、Extensionの追加
  • Extensionの追加に伴う、Rule, ClassRuleの廃止
  • テストコードに public な修飾子をつけなくてもよくなった
  • 4 --> 5 のマイグレーション用のモジュールの追加
  • JUnit 5向けのテストをJUnit4で動かすためのモジュール
  • EnclosedParameterizedTest が モダンな感じになった
  • JUnit5を動かすために、Gradleへの設定が必要
  • Java9 以降のJDKのバージョンへのサポート

色々ありますが、ドキュメントにも書かれていますが、 日本語の情報だと、irofさんという方の「どうしよう JUnit5」というスライドに大体書かれています。

この記事では、JUnit5と書いていますが、JUnit5ってなんなのかを表す言葉をユーザガイドから引用すると

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

になります。

JUnit Platformというテストを動かすためのAPIや実装で JUnit Jupiterは JUnitの5として新しいプログラミングモデルと拡張モデルのAPIとPlatformで動かすためのEngineの実装が含まれています。 JUnit Vintageは JUnit Platform上で JUnit4のプログラミングモデルをPlatformで動かすためのEngineの実装です。 また、JUnit PlatformにはIDEやビルドツールへの対応も含まれており、開発者は、自分達でEngineを書いて 好きな環境で動かすことも可能になっています。僕の知っているEngineの実装としては jmockitというライブラリがあるのですが、その中にEngineの実装があります。 余談ですが、元々jmockitoはJUnit4のクラスにモンキーパッチを当てていましたが 一段抽象化が入ることにより、ダーティなハックをすることなく、強力なモックを使うことが出来るようになっていそうです。 実際使ってことないのでわかりませんがw

とまぁ、本題からずれてしまったのですが、より1段階抽象化されており JUnit4と5が共存出来る形になっています。

アノテーションのパッケージ名とアノテーション自体の名前の変更

アノテーション周りが org.junit から org.junit.jupiter.api に変更されています。 また、@Ignore@Disable になっていたりと 細かい変更はありますが、基本的にはパッケージの移動が行われています。

また、@Test にあった expectedtimeout の属性の削除なども行われており こちらは、アサーションによって、テストするように変更されています。

どう書き換えていくかというと、以下のようなイメージです。

- @Test(expected = HogeException.class)
+ @Test
public void testThrows() {
-    foo()
+    Exception e = assertThrows(HogeException.class, () -> foo());
+    assertEquals("Hoge", e.getMessage());
}

timeoutについては省略しますが、assertTimeoutというアサーションjunit-jupiter に追加されています。 そちらを使って書き換えることになるでしょう。

アサーションのパッケージ名変更とインターフェースの変更

org.junit.jupiter.api.Assertionsアサーションが移動しています。 また、assertThat の廃止がされています。 加えて、

新しい拡張モデルとして、Extensionの追加

Extensionという新しいインターフェースが追加されています。 JUnit4でいうSpringRunnerだと、SpringのチームからSpringExtensionが追加されていたり MockitoのチームからはMockitoExtensionが提供されています。 順次サポートが広がってきており、エコシステムも充実して使える状態になってきています。

加えて、Extensionの追加に伴う、Rule, ClassRuleが廃止されています。 この辺は、自分で書いている場合は自分で移行したり、ライブラリの開発チームから提供されるのを待つ必要があります。 ただ、Extensionになって、簡単に書けるようになっているので 自分で実装を追加して、ライブラリにコントリビュートしても良いかもしれないですね

Extensionについては以下のUserGuideを見てほしいのですが、RunWithと違う点を一つ書いておきます。

JUnit4では、RunWithでは一つしか指定できなかった拡張モデルを、Extensionでは複数指定できるようになっています。

@ExtendWith(DatabaseExtension.class)
@ExtendWith(WebServerExtension.class)
class MySecondTests {
    // ...
}

また、JUnit5からは全体的に合成アノテーションのサポートがされており 以下のような @LargeTest のようにアノテーションをまとめる事ができます。 この辺はSpringをがっつり使っている方には馴染み深いものかと思います。 ドキュメント的にはComposed Annotationとして紹介されています。

ドキュメントには以下のリンク先に書かれています。

@ExtendWith(DatabaseExtension.class)
@ExtendWith(WebServerExtension.class)
// その他必要なアノテーションを省略
@interface LargeTest {} // DatebaseExtensionとWebServerExtensionをまとめるアノテーション

@LargeTest
class MySecondTests {
    // ...
}

このExtensionのモデルは非常に拡張しやすい形になっているのですが Rule, ClassRuleで出来ていたテストコード側で状態を持つような拡張がしづらくなったかなぁと思っています。

追記: 2019/07/03 この点は RegisterExtension (JUnit 5.1.1 API) を使えば良さそう

どんなコードかというと、WireMockのRuleがそれに当たります。

@Rule
WireMockRule wireMock = new WireMockRule(WireMockConfiguration.wireMockConfig().dynamicPort());

これを移行する方法を考えた時にどうするか、というと Extensionの定義に加えて、Extensionに設定を伝えるアノテーションやルールが必要になってきます。 wiremock本体ではないですが、コミュニティから出ている、wiremock-extensionであれば、以下のような形に書き換えることになります。

@Managed
WireMockServer wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());

という風に、いくつか不便になりましたが、 Extension自体が簡単に書きやすくなった、というのもあるので トレードオフという感じですね。

これからJUnit5向けにExtensionが出てくると思うので 移行期間はJUnit4で動かすのか、JUnit5で頑張って動かすのか考えつつ 移行していきたいですね。

テストコードに public な修飾子をつけなくてもよくなった

地味な変更です。

テストメソッドやテストクラスに public をつける必要がありましたが、必要ありません。 テストコードのノイズが減って良いですね。

- public class FooServiceTest {
+ class FooServiceTest {

    @Test
-    public void test() {
+    void test() {
        // ...
    }

}

変えなくても動くので、古いコードについては別にそのままで良いかなぁと思います。 新しいテストについては消していきたい気がしますが、好みのレベルと思います。

4 --> 5 のMigration用のモジュールの追加

いくつか、マイグレーション用のモジュールがあります。

  • JUnit5でJUnit4のテストを動かすための JUnit Vintage
  • JUnit4でJUnit5のテストを動かすための @RunWith(JUnitPlatform.class)
  • JUnit5でJUnit4の機能を一部再現するための junit-jupiter-migrationsupport

といったように、マイグレーションをサポートするモジュールや段階的に切り替えていくためのモジュールがたくさん用意されています。 規模が大きいのであれば、これらを駆使して移行していきたいですね。

EnclosedParameterizedTest が モダンな感じになった

Enclosed という 実はExperimentalだったアノテーションがあるのですが このアノテーションを使ってテストを構造化すると、以下のように少し複雑な形になります。 publicstatic が必要なのもあってかちょっと冗長に見えます。

@RunWith(Enclosed.class)
public class OuterTest {

    public static class InnerTest {
    }
    
}

Nested という追加されたアノテーションを使って構造化すると以下のようになります。

class OuterTest {

    @Nested
    class InnerTest {
    }
}

修飾子が要らなくなったのも合わせて対応してみると、非常に簡潔に構造を示すことが出来ます。 ただ、Enclosed と比べて注意する点が一つあります。 それは InnerClassstatic をつけていると動きません。 移行時に注意しておきたいですね。

@ParameterizedTest について書こうと思ったんですが ドキュメント見ろ、で終わる話なので詳しく書きませんが 非常に便利になったと思います。 以前書こうと思ったら、残念すぎて心が折れた記憶があります。

JUnit4でも、サードパーティのモジュールに JUnitParams というのがありますが そっちを使えば、良い感じに書けるそうですが やっぱり、公式でモダンな形でサポートされているのは強いですね。

詳しくは下のリンクを見て下さい

JUnit5を動かすために、Gradleへの設定が必要

Gradleのデフォルトのテストでは、JUnit4で動くようになっており GradleでJUnit5を動かすには設定の追加が必要です。 ただ、新しいプロジェクトでは以下のように、簡単に設定が出来ます。

test {
    useJUnitPlatform()
}

もちろん、Jacocoの対応もばっちりです。 Gradle 4.6から使えるようになっているので、Gradleのアップデートがまだな方はアップデートしていきましょう。

移行期間に伴い、JUnit4で動かしたい場合は、JUnit Vintageを使う必要があります。 以下にような形で設定を追加すれば良いでしょう。

test {
    useJUnitPlatform {
        includeEngines 'junit-jupiter', 'junit-vintage'
    }
}

MavenMavenで設定が必要になるとは思います。

詳しくは以下のリンク先を見てください

まとめ

今回の記事では、ざっくり知っている範囲について、メモとして書きました。 JUnit5のプログラミングモデル どうやって移行したか、については以前書いた記事をご覧ください。

以前書いた記事では、全部書き換える手法を取りましたが、成長したプロダクトでは 一気に書き換えるとPRのサイズがめちゃくちゃでかくなります。 上の記事で書いた、移行したプロジェクトのサイズは、そこそこ小さかったはずですが、+-共に2000行程度になり、4000行程度の差分が生まれました。 (僕は、同僚氏にでかいPRを投げつけました、の札。(画像略)) そのため、本来はゆっくり移行すべきかと思います。

また、前回の記事から、もう一つSpring Bootで書かれたサーバのプロジェクトを移行しましたが 大体似たような移行方法になったので、記事にはしないことにしました。

今度はJUnit Vintageを使って段階的に移行する方法を取ってみて、記事を書いてみようかなと思います。

内部で使っているライブラリを JUnit 5 (jupiter) に 移行した

この記事では、JUnit5への移行を行うと共に どういう書き換えをしたかを書いておきます。

以下の内容で書いていきます。

  • 今回移行したプロジェクトの前提
  • どういう方法で移行するか
  • PMDのアップデート
  • junit 4系の依存を完全に外す
  • junit-jupiterの依存とテストの設定を追加
  • アノテーション周りをsedで置換
  • @Test(expected = HogeException.class) を書き換える
  • アサーション周りをAssertJで書き直す
  • Wiremockの移行
  • Mockito周りの書き換え
  • まとめ

今回移行したプロジェクトの前提

  • @Category を使ってない
  • @AfterClass, @BeforeClass を使ってない
  • @RunWith(Enclosed.class) を使ってない
  • アサーションは基本的にAssertJを使っている
  • Gradleでビルドされている小さいライブラリ
  • 自分達でRuleは書いてない
  • SpringやMockitoのバージョンは最新に維持されていること

どういう方法で移行するか

今回は小さいプロジェクトの移行だったので、ガッとやりました。 本当はもうちょっとスモールステップで切り替えた方が良いのかなぁと思いつつ そんな規模のプロジェクトだと、色々ありそうで険しそうだなぁって気持ちになりました。

  • PMDのアップデート
  • junit 4系の依存を完全に外す
  • junit-jupiterの依存とテストの設定を追加
  • アノテーション周りをsedで置換
  • @Test(expected = HogeException.class) を書き換える
  • アサーション周りをAssertJで書き直す
  • Wiremockの移行
  • Mockito周りの書き換え

PMDのアップデート

PMDのアップデートをしましょう。 JUnit4 向けの検査で false positiveになります。

Issueとしては以下になります。

6.7.0で対応されているので 6.7.0以降にアップデートしましょう

JUnit 4系の依存を完全に外す

Gradleを使っているので、以下の記述を追加して、JUnit 4系の依存を外します。

configurations.all {
    exclude group: 'junit', module: 'junit'
}

なぜ外すかというとわかりやすく移行するためです。

junit-jupiterの依存とテストの設定を追加

build.gradleに、下の設定を追加してください。 ちなみに、この機能はGradle 4.6に追加されたもののようです。

test {
    useJUnitPlatform()
}

また、junit-jupiterの依存を追加しておきます。 以下の junit-jupiter は推移依存に junit-jupiter-params が入っており 依存関係としてはシンプルで便利なので、おすすめです。

dependencies {
    testCompile "org.junit.jupiter:junit-jupiter:5.4.2"
}

アノテーションsedで置換

下の記事にも書かれていますが、大体のアノテーションは置き換えるだけで動きます。

今回はひとまず、sedで置換します。(Macで動かしていて、gnu sedではないので必要に応じて読み替えてください) ここでは、MockitoやSpringのRunnerも同時に書き換えています。必要に応じて飛ばしてください。

git ls-files '*.java' | xargs sed -i '' 's/import org.junit.runner.RunWith/import org.junit.jupiter.api.extension.ExtendWith/g'
git ls-files '*.java' | xargs sed -i '' 's/@RunWith(SpringJUnit4ClassRunner.class)/@ExtendWith(SpringExtension.class)/g'
git ls-files '*.java' | xargs sed -i '' 's/@RunWith(SpringRunner.class)/@ExtendWith(SpringExtension.class)/g'
git ls-files '*.java' | xargs sed -i '' 's/import org.springframework.test.context.junit4.SpringJUnit4ClassRunner/import org.springframework.test.context.junit.jupiter.SpringExtension/g'
git ls-files '*.java' | xargs sed -i '' 's/import org.springframework.test.context.junit4.SpringRunner/import org.springframework.test.context.junit.jupiter.SpringExtension/g'
git ls-files '*.java' | xargs sed -i '' 's/@RunWith(MockitoJUnitRunner.class)/@ExtendWith(MockitoExtension.class)/g'
git ls-files '*.java' | xargs sed -i '' 's/import org.mockito.junit.MockitoJUnitRunner/import org.mockito.junit.jupiter.MockitoExtension/g'

git ls-files '*.java' | xargs sed -i '' 's/import org.junit.After/import org.junit.jupiter.api.AfterEach/g'
git ls-files '*.java' | xargs sed -i '' 's/@After/@AfterEach/g'

git ls-files '*.java' | xargs sed -i '' 's/import org.junit.Before/import org.junit.jupiter.api.BeforeEach/g'
git ls-files '*.java' | xargs sed -i '' 's/@Before/@BeforeEach/g'

git ls-files '*.java' | xargs sed -i '' 's/import org.junit.Test/import org.junit.jupiter.api.Test/g'
git ls-files '*.java' | xargs sed -i '' 's/@Test/@Test/g'

git ls-files '*.java' | xargs sed -i '' 's/import org.junit.Ignore/import org.junit.jupiter.api.Disabled/g'
git ls-files '*.java' | xargs sed -i '' 's/@Ignore/@Disabled/g'

ここでコンパイルエラーになることはあると思いますが 置いておきます。

@Test(expected = HogeException.class) を書き換える

@Test(expected = HogeException.class) を書き換えます。 junit-jupiterからは、アノテーションで投げられる例外の宣言ができなくなっています。 そのため、junit-jupiterのアサーションではassertThrows もしくは、AssertJのassertThatThrownByなどを使ってください。

junit-jupiterのアサーションに関しては以下の記事が参考になると思います。

コンパイルエラーから見つけて直す形で書き換えていきました。

アサーション周りをAssertJで書き直す

JUnit4のアサーション周りを使っていたコードをひたすら直します。 コンパイルエラーになっているはずなので、Intellij IDEAで書き換えてはコンパイルを繰り返します。 この辺は頑張って書き換えました。

今回は使いませんでしたが、JUnit5のアサーションを使う場合は、 assertThatなどの引数が入れ替わっていたりするので書き換えましょう。

wiremockの移行

一番悩みました。WireMock本体からはExtensionが出されていません。 そのため、WireMockRuleを使ったテストを書き換える必要があります。 正直、BeforeEach/AfterEachでWireMockServerをstart/stopするのでも良いのですが 書いているテストの量が多い場合は難しい気がします。

今回は、3rd partyのモジュールを使うことにしました。 その理由としては、先程も書いた通り、3rd partyのモジュールを使ったとしても 自分で書き換えるのは苦ではないかな、というところで考えています。

使ったのは以下のライブラリです。

以下の形でテストコードで使っている @Rule を書き換えると良いと思います。

- @Rule
- WireMockRule wireMock = new WireMockRule(WireMockConfiguration.wireMockConfig().dynamicPort());
+ @Managed
+ WireMockServer wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());

依存関係としては以下の記述を追加しました。

repositories {
    // ... 省略
+    maven { url "https://jitpack.io/" } // for wiremock-extension
}

dependencies {
+   testCompile("com.github.JensPiegsa:wiremock-extension:0.4.0") {
+       exclude module: "wiremock"
+   }
}

Mockito周りの書き換え

依存関係として、 mockito-junit-jupiter を 追加してください。 MockitoExtensionが別のモジュールとして追加されています。

dependencies {
    testCompile "org.mockito:mockito-core:$mockitoVersion"
+    testCompile "org.mockito:mockito-junit-jupiter:$mockitoVersion"
}

テストのライフサイクルが小さくなってしまいます。そのため、いままで通っていたテストが通らなくなります。 どういうテストが通らなくなるかというと、以下のようなテストです。 一部のテストで使う mockのセットアップが UnnecessaryStubbingとして検出されるようになります。

@ExtendWith(MockitoExtension.class)
public class CustomHealthIndicatorTest {

    @Mock
    Health.Builder builder;

    @BeforeEach
    public void setUp() throws Exception {
        when(builder.up()).thenReturn(builder);
        when(builder.down()).thenReturn(builder);
    }

    @Test
    public void testUp() throws Exception {
        builder.up(); // 実際にはテスト対象のメソッドを読んでいる
    }

    @Test
    public void testDown() throws Exception {
        builder.down(); // 実際にはテスト対象のメソッドを読んでいる
    }

}

このテストの場合は、2つしかないので、それぞれをテストにインライン化するだけで済みますが 複数のテストがある場合、テストの書き方を工夫する必要があるかなと思います。

例えば、@Nested を使って、それぞれのmockが必要なテストを分離します。

@ExtendWith(MockitoExtension.class)
public class CustomHealthIndicatorTest {

    @Nested
    class Up {
        
        @BeforeEach
        public void setUp() {
            when(builder.up()).thenReturn(builder);
        }
        
        @Test
        public void testStreamExists() throws Exception {
            builder.up();
        }
        
    }

    @Nested
    class Down {
        // 省略
    }
}

この挙動ですが、実はIssueに上がっています。 Issueとしては下のリンクのものです。

元の挙動のほうが移行する場合は楽なのですが、Issueが立っている以上、今のタイミングでは仕方ないので諦めました。 テストコードを見直す機会になっても良いと思います。

まとめ

正直今回はすぐに切り替えられました。Mockitoの1系から2系への移行よりも楽だった認識です。

今回移行したライブラリはそんな大きなモジュールではありませんでした。 そのため、すぐに移行することが出来ました。 また、Deprecatedなクラスや廃止される機能をできるだけ使わずに、基本的にアップデートした際に書き換える方針でやっていたので そもそも移行する手間が少なかった、というのも有ると思います。

皆さんも junit-jupiter で移行していきましょう。 @ParameterizedTest が最高に気持ちいいので移行するの、オススメです。

今回はライブラリということもあり、大きなテストもなくスッとあげられましたが これから、もっと他のサーバーサイドアプリケーションに展開していきます。 他に分かったことがあれば、別途記事を書きます。

それではまた。

Checkstyle 8.13からClassFanOutComplexityでカウントされる対象が増えて死んだ話

以下のリリースノートにこんな記述がありました。

checkstyle.org

  • ClassFanOutComplexity: count complexity base annotations/extends/implements/methods params. Author: kazachka #4092

annotation, extends, implementsで使われているクラスもClassFanOutComplexityのカウント対象に含まれるようになりました。 それに伴い、Bean ValidationやJacksonのアノテーションをつけているクラスやSpringのアノテーションをつけているクラスでエラーになるようになりました。 ClassFanOutComplexity の設定できる項目を確認します。 対処する方法として以下の4つが考えられます。

  1. コメントでignoreする
  2. そもそもチェックしない(ClassFanOutComplexityを外してしまう)
  3. ClassFanOutComplexityのmax thresholdを変更する
  4. ClassFanOutComplexityのexclude設定を追加する
<module name="ClassFanOutComplexity">
  <!-- 3つ目の案 -->
  <property name="max" value="40"/> <!-- そもそも今のプロジェクトだと30にしてた -->
  <!-- 4つ目の案 -->
  <property name="excludedPackages" value="your.own.package"/>
</module>

個人的には、4つ目の、excludeするパッケージを追加で対応かなぁ
Bean ValidationやlombokアノテーションのパッケージをexcludedPackagesに入れるとかが現実的な気がしています。