🧪

kotlin-reflectでプライベート関数のユニットテストを制する

2024/06/24に公開

これはなに?

Kotlinプライベートメソッドのユニットテストの書き方がわかるよ

Privateメソッドのテスト

「Privateメソッドは公開I/Fを通じて間接的にテストすべき」が一般的な気がしていますが、それでもプライベートメソッドのユニットテストを書きたいときがあります。
軽く調べてみたところ、Javaのリフレクションを使ったサンプルはありましたが、KotlinのReflectionを使ったサンプルは見当たらなかったのでそちらを利用してやってみます。

引数なしプライベートメソッドの場合

まずは引数を取らないプライベートメソッドのユニットテストからいきます。

1. サンプル用意

サンプルとして、以下のプライベートメソッドを持つクラスがあり、isValidをテストするとします。

MyService.kt
package org.example.app
class MyService {
    fun execute(): String {
        return if (isValid()) {
            "OK"
        } else {
            "NG"
        }
    }
    private fun isValid(): Boolean {
        return true
    }
}

2. Gradleに依存関係追加

プライベートメソッドをアクセス可能にするために以下のkotlin-reflectを依存関係に追加します。

build.gradle.kt
dependencies {
    testImplementation(kotlin("reflect"))
}

3. テスト書く

テストは以下のように書けます。
主なポイントは以下です。

  1. MyService::class.declaredMemberFunctionsMyServiceクラス内のメソッドにアクセスし、テストしたいプライベートメソッドをnameで探す。
  2. isAccessible=trueとすることで、テスト対象のプライベートメソッドをアクセス可能にする。
  3. isValid.call(myService)のようにプライベートメソッドを持つクラスのインスタンスを渡して実行。
MyServiceTest.kt
package org.example.app
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.reflect.KFunction
import kotlin.reflect.full.declaredMemberFunctions
import kotlin.reflect.jvm.isAccessible
class MyServiceTest {
    private lateinit var myService: MyService
    private lateinit var isValid: KFunction<*>
    @BeforeEach
    fun setUp() {
        myService = MyService()
        isValid = MyService::class.declaredMemberFunctions.first { it.name == "isValid" }.apply { isAccessible = true }
    }
    @Test
    fun `should be true`() {
        assert(isValid.call(myService) == true)
    }
}

引数ありプライベートメソッドの場合

次に引数を取るプライベートメソッドのユニットテストです。

1. サンプル用意

先ほどのコードに引数としてnameを追加したisValidをテストするとします。

MyParameterService.kt
package org.example.app
class MyParameterService {
    fun execute(name: String): String {
        return if (isValid(name)) {
            "OK"
        } else {
            "NG"
        }
    }
    private fun isValid(name: String): Boolean {
        return name == "Apple"
    }
}

2. Gradleに依存関係追加

ここは先ほどと同じなので省略。

3. テスト書く

テストは以下のように書けます。サンプルコードのように、引数をcallの第2引数に渡すことでパラメタありのプライベートメソッドのユニットテストが可能です。

MyParameterServiceTest.kt
package org.example.app
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.reflect.KFunction
import kotlin.reflect.full.declaredMemberFunctions
import kotlin.reflect.jvm.isAccessible
class MyParameterServiceTest {
    private lateinit var myParameterService: MyParameterService
    private lateinit var isValid: KFunction<*>
    @BeforeEach
    fun setUp() {
        myParameterService = MyParameterService()
        isValid = MyParameterService::class.declaredMemberFunctions.first { it.name == "isValid" }.apply { isAccessible = true }
    }
    @Test
    fun `should be true`() {
        // NOTE: 第2引数にパラメータを渡す
        assert(isValid.call(myParameterService, "Apple") == true)
    }
    @Test
    fun `should be false`() {
        // NOTE: 第2引数にパラメータを渡す
        assert(isValid.call(myParameterService, "Banana") == false)
    }
}

おまけ

拡張関数の場合

拡張関数も同様に可能なのかついでに調査しました。
結論可能です。

1. サンプル用意

MyExtensionService.kt
class MyExtensionService {
    fun execute(name: String): String {
        return if (name.isValid()) {
            "OK"
        } else {
            "NG"
        }
    }
    private fun String.isValid(): Boolean {
        return this == "Apple"
    }
}

2. Gradleに依存関係追加

ここは先ほどと同じなので省略。

3. テスト書く

テストは以下のように書けます。
MyExtensionService::class.declaredMemberExtensionFunctionsで、MyExtensionService内に定義されているプライベートな拡張関数を取得する点以外ほとんど同様にテストが可能です。(プライベートメソッドはdeclaredMemberFunctionsで取得していた)

MyExtensionServiceTest.kt
package org.example.app
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.reflect.KFunction
import kotlin.reflect.full.declaredMemberExtensionFunctions
import kotlin.reflect.jvm.isAccessible
class MyExtensionServiceTest {
    private lateinit var myExtensionService: MyExtensionService
    private lateinit var isValid: KFunction<*>
    @BeforeEach
    fun setUp() {
        myExtensionService = MyExtensionService()
        // NOTE: declaredMemberExtensionFunctions で拡張関数を取得
        isValid = MyExtensionService::class.declaredMemberExtensionFunctions.first { it.name == "isValid" }.apply { isAccessible = true }
    }
    @Test
    fun `should be true`() {
        // NOTE: 第2引数にパラメータを渡す
        assert(isValid.call(myExtensionService, "Apple") == true)
    }
    @Test
    fun `should be false`() {
        // NOTE: 第2引数にパラメータを渡す
        assert(isValid.call(myExtensionService, "Banana") == false)
    }
}

Discussion