Kotlin で JUnit 5 を使い始めました。両者を組み合わせる事例はあまり見かけませんでしたが、よい感触を得ましたので所感を残しておこうと思います。
背景
フルスクラッチで API サーバを書く案件が始まり、トレンドに乗って Kotlin で実装することになりました。テストフレームワークは使い慣れた JUnit を採用しましたが、せっかくの機会ですので JUnit も最新 5 系に挑戦することにしました。
本記事とは直接関係ありませんが、前提とするプロジェクトは以下の通りです。
- 全員が Java の API サーバ開発経験あり
- Kotlin 経験はまちまち
- 実装・テストともにすべて Kotlin
- サーバのフレームワークは Spring
良かったこと: モダンなテストが書ける
JUnit 4 から大きく機能拡張されたことと、Kotlin による簡素な記述のおかげで、現代的で保守しやすいテストが書けると感じました。特に、以下が容易に実現できる点はありがたいです。
- ビヘイビア駆動開発 (BDD)
- Parameterized Test
非 static な内部クラスでネストできる
JUnit 4 でも static な内部クラスでネストすることはできましたが、JUnit 5 では @Nested
アノテーションを使うことで、static でない内部クラスをネストすることができるようになりました。この機能は 前提条件を共通化できる という意味で大変気に入っています。たとえば以下のようにクラス名とフィクスチャを対応させることで、BDD のようなテスト記述ができるようになります。
interface Subscriber {
fun receive(message: String): Boolean
}
class Publisher(private val subscriber: Subscriber) {
fun send(message: String) = subscriber.receive(message)
}
internal class PublisherTest {
private lateinit var publisher: Publisher
@Mock
private lateinit var subscriber: Subscriber
@BeforeEach
fun setUp() {
MockitoAnnotations.initMocks(this)
publisher = Publisher(subscriber)
}
@Nested
inner class WhenSubscriberCannotReceive {
// "Subscriber cannot receive" という状態を記述できる.
@BeforeEach
fun setUp() {
whenever(subscriber.receive(any())).thenReturn(false)
}
@Test
fun thenThenFailsToSend() {
val actual = publisher.send("bye")
assertThat(actual, equalTo(false))
verify(subscriber).receive("bye")
}
}
@Nested
inner class WhenSubscriberReceives {
// "Subscriber receives" という状態を記述できる.
@BeforeEach
fun setUp() {
whenever(subscriber.receive(any())).thenReturn(true)
}
@Test
fun thenSucceedsToSend() {
val actual = publisher.send("hello")
assertThat(actual, equalTo(true))
verify(subscriber).receive("hello")
}
}
}
初歩的ですが、Kotlin で書く際は、Java と異なり内部クラスがデフォルトで static であるため、inner
キーワードを忘れないように注意が必要です。
テストに関連するオブジェクトのスコープが柔軟に変更できるので、ヘルパーメソッドによる共通化がしやすいと感じました。Kotlin の簡潔な記述とも相性がよさそうです。
internal class FooTest {
private lateinit var publisher: Publisher
@Mock
private lateinit var subscriber: Subscriber
@BeforeEach
fun setUp() {
MockitoAnnotations.initMocks(this)
publisher = Publisher(subscriber)
}
@Nested
inner class WhenSubscriberCannotReceive {
@BeforeEach
fun setUp() = stubSubscriber(canReceive = false)
@Test
fun thenThenFailsToSend() =
assertThat(testSending("bye"), equalTo(false))
}
@Nested
inner class WhenSubscriberReceives {
@BeforeEach
fun setUp() = stubSubscriber(canReceive = true)
@Test
fun thenSucceedsToSend() =
assertThat(testSending("hello"), equalTo(true))
}
// ヘルパーメソッドでテストを共通化する.
// ここから `publisher` や `subscriber` が見えているところがポイント.
private fun stubSubscriber(canReceive: Boolean) {
whenever(subscriber.receive(any())).thenReturn(canReceive)
}
private fun testSending(message: String): Boolean {
val succeeded = publisher.send(message)
verify(subscriber).receive(message)
return succeeded
}
}
Parameterized テストがとても書きやすい
Parameterized テストは、テストケースとテストロジックを分離できる手法で、特に多くの組み合わせの検証が必要なケースでは大変強力です。JUnit 4 の parameterized テストは、おまじないがエグい ためかなり苦労させられたのですが、JUnit 5 では極めて直感的に記述できるようになりました。
簡単なテストケースであればアノテーションだけでも記述できるのですが、実用上は @MethodSource
と @ArgumentsSource
の 2 つが、手軽さと柔軟さのバランスがよいと感じています。
@MethodSource
は static メソッドに記述するだけで本当にお手軽に利用できます。Java の static メソッドを参照するので、Kotlin で書く際は companion object に関数を定義して @JvmStatic
を付与します。
internal class AddTest {
@ParameterizedTest
@MethodSource("testCases")
fun addTest(lhs: Int, rhs: Int, expected: Int) {
val actual = lhs + rhs
assertThat(actual, equalTo(expected))
}
companion object {
@Suppress("unused") // used by `addTest`
@JvmStatic
fun testCase() = listOf(
arguments(0, 0, 0),
arguments(1, 1, 2),
arguments(1, -1, 0)
)
}
}
細かいですが、Kotlin だと Java で書くよりもテストケースを並べている感があって、相性がよさそうだと感じました。
@MethodSource
もあまり不満はないのですが、気になる点を挙げるならば以下の通りです。いずれも気にしすぎかもしれませんが。
- IntelliJ が (執筆時点では) メソッド不使用を警告してくる
- いちいち抑制するのが面倒
- 正しく解決されるかどうか実行されるまで分からない
- IDE の補完が効かない
-
@JvmStatic
のメタ情報が気持ち悪い- Kotlin なのに!
これらの問題はすべて @ArgumentsSource
で解決できます。
internal class AddTest {
@ParameterizedTest
@ArgumentsSource(TestCase::class)
fun addTest(lhs: Int, rhs: Int, expected: Int) {
val actual = lhs + rhs
assertThat(actual, equalTo(expected))
}
private class TestCase : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext?): Stream<out Arguments> = Stream.of(
arguments(0, 0, 0),
arguments(1, 1, 2),
arguments(1, -1, 0)
)
}
}
強いて気になるのは、コード量が少しだけ増えることと、Kotlin なのに Stream
を強要されることでしょうか。これもご愛嬌のレベルだと思います。
例外テストが書きやすい
JUnit 5 から assertThrows
が採用され、格段に例外テストが書きやすくなりました。Java でも重宝しますが、なんと Kotlin 向けの assertThrows
が提供されており、大変気持ちよく記述できます。
val error = assertThrows<IOException> {
process()
}
assertThat(error.message, equalTo("expected message"))
Java 用とパッケージを間違えないようにだけ注意が必要です。Kotlin 用は org.junit.jupiter.api.assertThrows
です。
Kotlin に助けられたこと
JUnit 5 と Kotlin を組み合わせで困ることはほとんどなかったのですが、現実的には JUnit の関連ライブラリの利用において困ったこともありました。これらは別の記事で取り扱おうと思いますが、一方で Kotlin のおかげで偶然解決した問題もありましたので紹介します。
PowerMock の代わりに MockK が使える
Java では主に static メソッドのモンキーパッチとして活躍していた PowerMock ですが、残念ながら本記事執筆時点では JUnit 5 をサポートしていないようです (案件自体はあるようですが)。本プロジェクトはフルスクラッチの開発であり、かつ DI が強力な Spring を採用しましたので、基本的には PowerMock を使いたくなる状況自体を回避する方針をとっています。
しかし、以下のような例外的状況も発生しました。
- Kotlin なので、純粋関数や拡張関数を使いたいケースももちろんある
- サードパーティーのライブラリで static メソッドを利用せざるを得ないケースはどうしようもない
これらを解決してくれるライブラリとして、MockK を採用しました。使用感は Python の patch に似ていて、すぐ慣れることができました。以下の例は拡張関数にモックを適用していますが、純粋関数や Java の static メソッドにも同じようにモックを適用できます。
// Function.kt
fun String.extension() = "orange"
// FunctionTest.kt
internal class FunctionTest {
@Test
fun mockedSuccessfully() {
assertThat("apple".extension(), equalTo("banana"))
}
companion object {
private const val MOCKED_PACKAGE = "mocked.FunctionKt"
@Suppress("unused") // IntelliJ IDEA warns...
@JvmStatic
@BeforeAll
fun initialize() {
mockkStatic(MOCKED_PACKAGE)
every { "apple".extension() } returns "banana"
}
@Suppress("unused") // IntelliJ IDEA warns...
@JvmStatic
@AfterAll
fun finalize() {
unmockkStatic(MOCKED_PACKAGE)
}
}
}
MockK はとても多機能なため全容を研究しきれていませんが、モックライブラリの機能に頼りすぎて設計が疎かになっては元も子もありませんので、どうしようもないときだけ MockK で実現できないか調査する、という方針をとっています。
まとめ
JUnit 5 はモダンなテストが書けて大変使いやすく、Kotlin との相性もよさそうでした。MockK という思わぬメリットを享受できた点も幸運でした。両者の組み合わせについて大きなデメリットも感じませんでしたので、今後も活用していきたいと思います。