こちらは「コドモン Advent Calendar 2023」の 7日目の記事です
こんにちは。プロダクト開発部の上代です。
私のチームではKotestを使用してテストコードを書いており、モッキングライブラリのMockKを採用しています。 MockKはKotlin専用のモッキングライブラリのため、モックの作成と管理がとてもやりやすいと思っています。 今回はコードサンプルを用いて、MockKの使い方を紹介していきます。
前提条件
バージョン
- Kotlin 1.8.21
- MockK 1.13.8
テスト対象
テスト対象として以下のコードを用意しました。
interface UserRepository { fun getUser(userId: String): User fun updateUser(userId: String, user: User): Boolean suspend fun awaitUpdateUser(userId: String, user: User): Boolean } class UserService(private val userRepository: UserRepository) { fun getUser(userId: String): User { return userRepository.getUser(userId) } fun updateUser(userId: String, user: User): Boolean { return userRepository.updateUser(userId, user) } suspend fun awaitUpdateUser(userId: String, user: User): Boolean { return userRepository.awaitUpdateUser(userId, user) } } data class User(val userId: String, val name: String, val email: String)
ユーザーサービスがユーザーリポジトリを使用してユーザー情報を取得したり、更新したりするコードです。 このコードに対して3つのテストを書いてみました。
テストコード
import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.runBlocking class UserServiceTest : DescribeSpec({ val mockUserRepo = mockk<UserRepository>() val userService = UserService(mockUserRepo) describe("UserService") { it("returns a user for getUser") { val userId = "user123" val expectedUser = User(userId, "Test User", "test@user.com") every { mockUserRepo.getUser(userId) } returns expectedUser val resultUser = userService.getUser(userId) resultUser shouldBe expectedUser verify(exactly = 1) { mockUserRepo.getUser(userId) } } it("throws an exception for getUser when user not found") { val userId = "user999" every { mockUserRepo.getUser(userId) } throws NoSuchElementException("User not found") val exception = shouldThrowAny { userService.getUser(userId) } exception.message shouldBe "User not found" verify(exactly = 1) { mockUserRepo.getUser(userId) } } it("updates a user correctly for updateUser") { val userId = "user123" val newUserDetails = User(userId, "Updated User", "update@user.com") val userSlot = slot<User>() every { mockUserRepo.updateUser(eq(userId), capture(userSlot)) } answers { userSlot.captured.email == newUserDetails.email && userSlot.captured.name == newUserDetails.name } val isUpdated = userService.updateUser(userId, newUserDetails) isUpdated shouldBe true userSlot.captured.name shouldBe newUserDetails.name userSlot.captured.email shouldBe newUserDetails.email verify { mockUserRepo.updateUser(eq(userId), any()) } } describe("suspend functions") { it("updates a user correctly with awaitUpdateUser") { val userId = "user123" val newUser = User(userId, "Async User", "async@update.com") coEvery { mockUserRepo.awaitUpdateUser(userId, newUser) } returns true val result = runBlocking { userService.awaitUpdateUser(userId, newUser) } result shouldBe true coVerify(exactly = 1) { mockUserRepo.awaitUpdateUser(userId, newUser) } } } } })
解説
テストを1つずつ解説していきます。
モックの作成
UserRepositoryのモックを作成してUserServiceのコンストラクタに渡しています。
val mockUserRepo = mockk<UserRepository>() val userService = UserService(mockUserRepo)
1つ目のテスト
it("returns a user for getUser") { val userId = "user123" val expectedUser = User(userId, "Test User", "test@user.com") every { mockUserRepo.getUser(userId) } returns expectedUser val resultUser = userService.getUser(userId) resultUser shouldBe expectedUser verify(exactly = 1) { mockUserRepo.getUser(userId) } }
everyはモックされたオブジェクトの特定のメソッド呼び出しに対する振る舞いを定義します。 ここではgetUserの振る舞いを明示的に定義しています。
every { mockUserRepo.getUser(userId) } returns expectedUser
verify関数はモックオブジェクトのメソッド呼び出しを検証します。 ここではgetUserが1回呼び出されたことを検証しています。
verify(exactly = 1) { mockUserRepo.getUser(userId) }
2つ目のテスト
it("throws an exception for getUser when user not found") { val userId = "user999" every { mockUserRepo.getUser(userId) } throws NoSuchElementException("User not found") val exception = shouldThrowAny { userService.getUser(userId) } exception.message shouldBe "User not found" verify(exactly = 1) { mockUserRepo.getUser(userId) } }
everyで例外を発生させることも可能です。
every { mockUserRepo.getUser(userId) } throws NoSuchElementException("User not found")
3つ目のテスト
it("updates a user correctly for updateUser") { val userId = "user123" val newUserDetails = User(userId, "Updated User", "update@user.com") val userSlot = slot<User>() every { mockUserRepo.updateUser(eq(userId), capture(userSlot)) } answers { userSlot.captured.email == newUserDetails.email && userSlot.captured.name == newUserDetails.name } val isUpdated = userService.updateUser(userId, newUserDetails) isUpdated shouldBe true userSlot.captured.name shouldBe newUserDetails.name userSlot.captured.email shouldBe newUserDetails.email verify { mockUserRepo.updateUser(eq(userId), any()) } }
slotはモックしたメソッドに渡される引数をキャプチャすることができます。 Userタイプの引数をキャプチャするためのslotを作成しています。
val userSlot = slot<User>()
capture(userSlot)で、メソッドの第二引数として渡されるUserオブジェクトをキャプチャするために使用されます。
every { mockUserRepo.updateUser(eq(userId), capture(userSlot)) } answers { userSlot.captured.email == newUserDetails.email && userSlot.captured.name == newUserDetails.name }
updateUserの第二引数として渡されるUserオブジェクトのnameとemailの値を検証しています。
userSlot.captured.name shouldBe newUserDetails.name userSlot.captured.email shouldBe newUserDetails.email
4つ目のテスト
it("updates a user correctly with awaitUpdateUser") { val userId = "user123" val newUser = User(userId, "Async User", "async@update.com") coEvery { mockUserRepo.awaitUpdateUser(userId, newUser) } returns true val result = runBlocking { userService.awaitUpdateUser(userId, newUser) } result shouldBe true coVerify(exactly = 1) { mockUserRepo.awaitUpdateUser(userId, newUser) } }
Kotlinでコルーチンを用いた非同期処理を行う場合、標準のeveryブロックではなく、coEveryを使用する必要があります。
coEvery { mockUserRepo.awaitUpdateUser(userId, newUser) } returns true
Kotlinでコルーチンを用いた非同期処理を行う場合、標準のverifyブロックではなく、coVerifyを使用する必要があります。
coVerify(exactly = 1) { mockUserRepo.awaitUpdateUser(userId, newUser) }
さいごに
MockKの使い方を紹介しました。 さらに詳しい使い方を知りたい方は、MockKのドキュメントを参照してみてください。