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)) } } }
こうすることでコードも見やすくなりますし,結果も階層構造でみることができるようになります! 是非活用してみてください.
オブジェクトを作成するヘルパー関数の導入
テストをする時にテスト用のオブジェクトを毎回作るのが大変って感じる時ありませんか?今回紹介するのはその手間を少し楽にしてくれる方法です! 例えば以下のようなユーザーとそれを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(データアクセスオブジェクト)を使用してテーブルからデータを取ってきます.またこのtransaction
はDataSource
から生成できる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でテストを書く際に覚えておいた方がいいコツなどをまとめてみました.他にもこうするといいよ!っていうのがあれば是非教えてください!! 今回のコードは以下に上げています.