39
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Kotlin で JUnit 5 を使ってみた

Last updated at Posted at 2019-02-27

Kotlin で JUnit 5 を使い始めました。両者を組み合わせる事例はあまり見かけませんでしたが、よい感触を得ましたので所感を残しておこうと思います。

背景

フルスクラッチで API サーバを書く案件が始まり、トレンドに乗って Kotlin で実装することになりました。テストフレームワークは使い慣れた JUnit を採用しましたが、せっかくの機会ですので JUnit も最新 5 系に挑戦することにしました。

本記事とは直接関係ありませんが、前提とするプロジェクトは以下の通りです。

  • 全員が Java の API サーバ開発経験あり
  • Kotlin 経験はまちまち
  • 実装・テストともにすべて Kotlin
  • サーバのフレームワークは Spring

良かったこと: モダンなテストが書ける

JUnit 4 から大きく機能拡張されたことと、Kotlin による簡素な記述のおかげで、現代的で保守しやすいテストが書けると感じました。特に、以下が容易に実現できる点はありがたいです。

非 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 という思わぬメリットを享受できた点も幸運でした。両者の組み合わせについて大きなデメリットも感じませんでしたので、今後も活用していきたいと思います。

参考

39
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?