Junit5とMockk

今回はJunit5とMockkを使ってテストを書く際のコツについてまとめたいと思います!こちらを参考したのでお時間があるとき是非見て頂きたいです.

テストをメソッドやカテゴリ別に分ける方法

みなさんよくテストをクラス毎に作成をしませんか?僕は良くそうしています..その時にクラスにたくさんのメソッドが生えているときにフラットでテストを作成してしまうと,どのメソッドがどのテストかわからなくなってしまいます. 例えば以下のような計算用のクラスがあるとします.

class Calculator {
    fun add(v1: Double, v2: Double) = v1.plus(v2)
    fun multiply(v1: Double, v2: Double) = v1.times(v2)
}

普通にテストを書いていくと以下のようにフラットになりどれがaddのテストかどうかだんだんとわかりづらくなってきます..(今回はメソッドもテストも少ないですがこれが膨らんでいくことを想像してください)

internal class CalculatorTestNotInner {
    private val calculator = Calculator()

    @Test
    fun `足し算ができる`() {
        assertEquals(3.0, calculator.add(1.0, 2.0))
    }

    @Test
    fun `引数の順番を入れかえても足し算の結果は同じである`() {
        assertEquals(3.0, calculator.add(1.0, 2.0))
        assertEquals(3.0, calculator.add(2.0, 1.0))
    }

    @Test
    fun `掛け算ができる`() {
        assertEquals(3.0, calculator.add(1.0, 2.0))
    }
}

これをメソッド毎に分ける方法が@Nestedというアノテーションです.使い方は以下のようになります.

internal class CalculatorTest {
    private val calculator = Calculator()

    @Nested
    inner class Add {
        @Test
        fun `足し算ができる`() {
            assertEquals(3.0, calculator.add(1.0, 2.0))
        }
        @Test
        fun `引数の順番を入れかえても足し算の結果は同じである`() {
            assertEquals(3.0, calculator.add(1.0, 2.0))
            assertEquals(3.0, calculator.add(2.0, 1.0))
        }
    }

    @Nested
    inner class Multiply {
        @Test
        fun `掛け算ができる`() {
            assertEquals(3.0, calculator.add(1.0, 2.0))
        }
    }
}

こうすることでコードも見やすくなりますし,結果も階層構造でみることができるようになります! f:id:harada-777:20200628141902p:plain 是非活用してみてください.

オブジェクトを作成するヘルパー関数の導入

テストをする時にテスト用のオブジェクトを毎回作るのが大変って感じる時ありませんか?今回紹介するのはその手間を少し楽にしてくれる方法です! 例えば以下のようなユーザーとそれをDBなどに作成するユースケースがあるとします.

data class User(val firstName: String,
                val lastName: String,
                val old: Int,
                val profile: String)
                
class UserCreateUseCase {
    fun execute(user: User) {
       // Userを作成する
    }
}

このユースケースのテストとして以下のようなケースがあるとします.

internal class UserCreateUseCaseTestNoHelper {
    private val useCase = UserCreateUseCase()

    @Test
    fun `Userを作成することができる`() {
        val user = User("firstName", "lastName", 20, "test")
        assertDoesNotThrow {
            useCase.execute(user)
        }
    }

    @Test
    fun `ファーストネームが空文字のUserを作成することができない`() {
        val user = User("", "lastName", 20, "test")
        assertThrows<Throwable> {
            useCase.execute(user)
        }
    }


    @Test
    fun `ラストネームが空文字のUserを作成することができない`() {
        val user = User("firstName", "", 20, "test")
        assertThrows<Throwable> {
            useCase.execute(user)
        }
    }
}

何回もユーザーをインスタンス化するコードを書いてますね..ここで登場するのがヘルパー関数です.以下がその例です.

fun createUser(
    firstName: String = "firstName",
    lastName: String = "lastName",
    old: Int = 20,
    profile: String= "test"
) = User(firstName, lastName, old, profile

このようにデフォルト引数を用意したオブジェクトを生成する用の関数を用意しておきます(CreationUtil.ktとかに作成)! これを使うと以下のようにテストをかけます!

internal class UserCreateUseCaseTest {
    private val useCase = UserCreateUseCase()

    @Test
    fun `Userを作成することができる`() {
        assertDoesNotThrow {
            useCase.execute(createUser())
        }
    }

    @Test
    fun `ファーストネームが空文字のUserを作成することができない`() {
        assertThrows<Throwable> {
            useCase.execute(createUser(firstName = ""))
        }
    }

    @Test
    fun `ラストネームが空文字のUserを作成することができない`() {
        assertThrows<Throwable> {
            useCase.execute(createUser(lastName = ""))
        }
    }
}

だいぶコード量減らすことができますし,どの値をテストで見ようとしているかより明確になります.

モックを再利用する

モックの生成にはコストがかかってします.なので以下のように毎回テストの度生成するとテストにかかる時間が増えてしまいます.

internal class UsersGetUseCaseTestNoReuse {
    lateinit var userGateway: UserPort
    private val useCase = UsersGetUseCase(userGateway)

    @BeforeEach
    fun setUp() {
        userGateway = mockk()
    }

    @Test
    fun `ユーザーのリストを取得できる`() {
        every { userGateway.getUsers() } returns listOf(createUser(), createUser(firstName = "taro"))
    }
}

そこで登場するのがclearMocksという関数です.これにモックを渡すことでmockをリセットすることができます.

internal class UsersGetUseCaseTest {
    private val userGateway: UserPort = mockk()
    private val useCase = UsersGetUseCase(userGateway)

    @BeforeEach
    fun setUp() {
        clearMocks(userGateway)
    }

    @Test
    fun `ユーザーのリストを取得できる`() {
        every { userGateway.getUsers() } returns listOf(createUser(), createUser(firstName = "taro"))
    }
}

このようにモックはできるだけ再利用しましょう!

ラムダ式を受け取る拡張関数のモック

最後にラムダ式を受け取る拡張関数をどうモックするかについて説明する.これは僕がExposedというORマッパーをモックしてテストを行おうとした際にハマったのでご紹介させて頂きます. Exposedはラムダ式を受け取るtransactionという拡張関数のスコープ内でDao(データアクセスオブジェクト)を使用してテーブルからデータを取ってきます.またこのtransactionDataSourceから生成できるDatabaseオブジェクトを受けとり.こ実際にデータベースに接続ができないとエラーを起こしてします. このtransactionが呼ばれているかをveryfyしようとテストを書こうとすると,本来は接続が可能なデータベースに対するDatabaseオブジェクトを渡さないと行けません.今回はこれをMockすることでデータベースなしでテストが可能にする方法をお伝えします. 拡張関数のモックはクラス名がが必要となります.Exposedのtransactionをモックしたいときは以下のようになります.

    mockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")

モックは以下のように解除できます.

        unmockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")

次にtransactionのモックの定義を書きます.

every {
    transaction(statement = captureLambda<Transaction.() -> Any>(), db = any())
} answers {
    lambda<Transaction.() -> Any>().invoke(mockk())
}

captureLambdaラムダ式を受け取る関数のモックを作成することができます.今回はtransactionが受け取る関数の型に合わせています.さらにMcokkでは answersを使ってこのモックが呼ばれたとき何を実行するかを定義することができます.lambda<Transaction.() -> Any>()でモックに渡したラムダ式をキャプチャーし,実行させています.今回テストではtransaction内でdaoが実行されているかを検証するためにこのように書きました.(transactionとdaoの呼び出しをveryfyするテストを書きたかった) 実際Exposedのテストを書くと以下の感じになります.

internal class UserGatewayTest {
    private val dao: UserDao.Companion = mockk()
    private val gateway = UserGateway(dao, mockk())

    @BeforeEach
    fun setUp() {
        mockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")
        every {
            transaction(statement = captureLambda<Transaction.() -> Any>(), db = any())
        } answers {
            lambda<Transaction.() -> Any>().invoke(mockk())
        }
        clearMocks(dao)
    }

    @AfterEach
    fun tearDown() {
        unmockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")
    }

    @Test
    fun `ユーザーを取得することができる`() {
        val result = gateway.getUser("tanaka", "taro")
        verify {
            transaction(db = any()) { }
        }
        verify {
            dao.find("tanaka", "taro")
        }
    }

まとめ

今回は僕がKotlinでテストを書く際に覚えておいた方がいいコツなどをまとめてみました.他にもこうするといいよ!っていうのがあれば是非教えてください!! 今回のコードは以下に上げています.

https://github.com/Yoshiaki-Harada/junit5-and-mockk-blog