0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KotlinのService層をMockKでテストしたい

Last updated at Posted at 2024-08-28

この記事の対象者

  • Kotlinでバックエンドを開発
  • MockKを使ってテストを作成
  • every, justRun, verifyの使い方に自信がない

こんな人(主にワタクシ)が大いにテストを書くのにハマり、どうやったらテストが書けるのかをサンプルコードを使いながらメモして自分でスラスラ書けるようになりたいと願いメモを残す。

背景

以下のようなケースの実装を行う際のテストで苦戦しました。

  • 複数の引数を取るservice層のクラスにビジネスロジックがある
  • 返り値を使うものと返り値がないメソッドを呼び出す必要がある
  • ロジック中に型変換を伴う

といったメソッドのテストを作成しながら実装を行う必要がありました。

実装側のサンプルコード

便宜上、interfaceやentity, data classのコードもまとめて書いていますが、
別ファイルに切り出した方が良きです。

POSTメソッドを叩いた時を想定したサンプルコードです。

  • SampleRepositoryのsaveメソッドを叩くことで、SampleEntityにデータが保存される
  • 保存したら保存したデータを返り値として返す
  • someServiceのdoSomethingメソッドは引数を一つとり返り値はなし
  • createメソッドの返り値はentityに保存された時間を取り除いたデータ型に変換して返す

といったロジックが含まれているものを考えます。

import java.time.Instant

// SampleRepository (JPASampleRepository) のインターフェース
interface SampleRepository {
    fun save(entity: SampleEntity): SampleEntity
}

// 任意の引数のインターフェース
interface SomeService {
    fun doSomething(savedEntity: SampleEntity): Unit
}

// 共通エンティティクラス
open class CommonEntity(
    open val id: Long,
    open val name: String
)

// RequestEntity データクラス
data class RequestEntity(
    override val id: Long,
    override val name: String
) : CommonEntity(id, name)

// ResponseEntity データクラス
data class ResponseEntity(
    override val id: Long,
    override val name: String
) : CommonEntity(id, name)

// SampleEntity データクラス
data class SampleEntity(
    val id: Long,
    val name: String,
    val createdAt: Instant? = null
) {
    // SampleEntity から ResponseEntity に変換するファクトリメソッド
    companion object {
        fun toResponseEntity(sample: SampleEntity): ResponseEntity {
            return ResponseEntity(id = sample.id, name = sample.name)
        }
    }
}

// SampleServiceImpl クラスの実装
@Service
class SampleServiceImpl(
    private val sampleRepository: SampleRepository,
    private val someService: SomeService
) {
    fun create(entity: RequestEntity, argument: String): ResponseEntity {
        // RequestEntity から SampleEntity へ変換し、保存
        val sampleEntity = SampleEntity(id = entity.id, name = entity.name)
        val savedEntity = sampleRepository.save(sampleEntity.copy(createdAt = Instant.now()))

        // someService のメソッドを savedEntity を引数として呼び出す
        someService.doSomething(savedEntity)

        // 保存後のエンティティを ResponseEntity に変換して返す
        return SampleEntity.toResponseEntity(savedEntity)
    }
}

その1: create()の返り値が正しいかテストする

まずはcreateメソッドの返り値が正しいものになっているか?のテストを作成する。

import io.mockk.every
import io.mockk.justRun
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.junit.jupiter.api.Assertions.*
import java.time.Instant

@SpringBootTest
class SampleServiceImplMockKTest {

    @Autowired
    lateinit var sampleServiceImpl: SampleServiceImpl

    @Autowired
    lateinit var sampleRepository: SampleRepository

    @MockkBean
    lateinit var someService: SomeService

    @Test
    fun `create should return saved ResponseEntity when create is called`() {
        // Arrange
        val requestEntity = RequestEntity(id = 1L, name = "TestEntity")
        val sampleEntity = SampleEntity(id = requestEntity.id, name = requestEntity.name)
        val savedEntity = sampleEntity.copy(createdAt = Instant.now())
        val expectedResponse = ResponseEntity(id = savedEntity.id, name = savedEntity.name)

        every { sampleRepository.save(sampleEntity) } returns savedEntity
        justRun { someService.doSomething(any()) }

        // Act
        val result = sampleServiceImpl.create(requestEntity, "TestArgument")

        // Assert
        assertEquals(expectedResponse.id, result.id)
        assertEquals(expectedResponse.name, result.name)
        verify(exactly = 1) { sampleRepository.save(sampleEntity) }
    }
}

テストの中身を見ていく

MockKを使ってテストを作成するので、
@SpringBootTestアノテーションを使用し、

テスト対象のクラス → @Autowired
repository → @Aotowired
テスト対象クラスが依存している外部のオブジェクトをモック化 → @MockkBean
のアノテーションを使用

    @Autowired
    lateinit var sampleServiceImpl: SampleServiceImpl

    @Autowired
    lateinit var sampleRepository: SampleRepository

    @MockkBean
    lateinit var someService: SomeService

Arrenge前半パートは、

  • createメソッドの引数であるRequestEntity型のデータ
  • saveメソッドの引数であるSampleEntity型のデータ
  • saveメソッドの返り値であるSampleEntity型のデータ
  • アサーション用のResponseEntity型のデータ

を準備する。

    val requestEntity = RequestEntity(id = 1L, name = "TestEntity")
    val sampleEntity = SampleEntity(id = requestEntity.id, name = requestEntity.name)
    val savedEntity = sampleEntity.copy(createdAt = Instant.now())
    val expectedResponse = ResponseEntity(id = savedEntity.id, name = savedEntity.name)

後半パートは、

  • saveメソッドが呼び出された際の返り値を固定するためのeveryを指定
  • doSomethingメソッドは戻り値がない(void)振る舞いを定義するためのjustRunを指定
    every { sampleRepository.save(sampleEntity) } returns savedEntity
    justRun { someService.doSomething(any()) }

Actはcreateメソッドを引数を与えて実行する。

    val result = sampleServiceImpl.create(requestEntity, "TestArgument")

アサーションは、

  • createの返り値が期待値と同値か?
  • 所定の回数メソッドが正しい引数をもらって実行されているか?

を確認している。

    assertEquals(expectedResponse.id, result.id)
    assertEquals(expectedResponse.name, result.name)
    verify(exactly = 1) { sampleRepository.save(sampleEntity) }

その2: someServiceのdoSomethingが正しい引数を与えられて実行しているか

今度は、someServiceのdoSomethingのテストを作成する。

    @Test
    fun `create should call someService doSomething with the saved SampleEntity`() {
        // Arrange
        val requestEntity = RequestEntity(id = 1L, name = "TestEntity")
        val sampleEntity = SampleEntity(id = requestEntity.id, name = requestEntity.name)
        val savedEntity = sampleEntity.copy(createdAt = Instant.now())

        every { sampleRepository.save(any()) } returns savedEntity
        justRun { someService.doSomething(savedEntity) }


        // Act
        sampleServiceImpl.create(requestEntity, "TestArgument")


        // Assert
        verify(exactly = 1) { someService.doSomething(savedEntity) }
    }

1個目のテストと変わってくる点は、
sampleRepository.save() はどんな引数であれ同じ値を返すようにし、
someService.doSomething() は、正しい引数が与えられた時に戻り値がない状態のjustRunを定義するので、
any()の取り扱いが変わってくる。

アサーションは返り値がないので正しい引数で所定の回数呼ばれたか?をテストする形になる。

どのアノテーションを使えばいいのか?などの部分に関しても慣れないところが多々あるので、
経験を増やして使いこなせるようになりたい。

ファクトリーメソッド

実装コードの中で、data classcommpanion object という記述がある。
この部分でSampleEntityからResponseEntityに型変換を行うメソッドを定義しており、

    companion object {
        fun toResponseEntity(sample: SampleEntity): ResponseEntity {
            return ResponseEntity(id = sample.id, name = sample.name)
        }
    }

このtoResponseEntity()が、引数の型(SampleEntity)から返り値のResponseEntity型に変換をしてくれる。

命名は、今回の例のようにtoを使用したり、fromを使用した関数名を用いることが多い。

今回のケースの実装コードを見ると、
SampleEntity.toResponseEntity() のように、英文っぽい形で大体何やろうとしているのかがわかるので、
関数の命名の考え方として取り入れながらしっかり覚えたい。

最後に

シンプルな例でコードをまとめてみたが、
Controller, Service, Repositoryの各層がどのようにつながり、
どんなアノテーションをつけながらどのようにテストを書くべきなのか場数をこなしながら覚えていきたい。

自分で簡単なAPIをTDDで作りながら練習するのが良さそうかな?

長文の記事、最後までお付き合いいただきありがとうございました。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?