はじめに
kotlin初心者の自分がバックエンド開発においてControllerのテストを初めて見た時に意味不明すぎて困惑しました。(Service,Repositoryのテストはまだ理解できた。)
その時の自分にしっかり説明できるようにまとめました。
環境
IDE: IntelliJ
開発環境はSpringInitializr←これで作成しました。(アクセスしたらそのままGENERATEできるかと思います)
build.gradle.ktsのdependenciesは以下のようになってます
要らないものも入ってると思いますが気にしないでください
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("com.ninja-squad:springmockk:3.1.1")
}
前提
今回テストのために作ろうとしている仮想のアプリの仕様はざっくり以下みたいな感じです。(適当に考えたアプリです。)
ユーザーが /api/greetingのエンドポイントにクエリパラメータで国名コードを渡すとその国の言語での挨拶が返ってくるアプリ
例1: /api/greeting/?country=JP でgetを実行 ⇨ "こんにちは"
例2: /api/greeting/?country=US でgetを実行 ⇨ "Hello"
そのアプリのバックエンド、
さらにバックエンドのcontrollerのテストを作成している想定です。
※controllerで呼び出すserviceは未実装なので単体テストを行っています。
実装
以下が今回のControllerの実装になります。
package com.example.demo
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api")
class GreetingController(val greetingService: DefaultGreetingService ) {
@GetMapping("/greeting")
fun getGreeting(@RequestParam("country") country:String?):String{
val result = greetingService.getGreeting(country)
return result
}
}
この実装コードのテストを書きたいとします。
このGreetingContorollerはGreetingServiceに依存していますがGreetingServiceは以下のようにただ"test"の文字列を返すだけの記述で未実装です。
package com.example.demo
import org.springframework.stereotype.Service
import java.util.Locale.IsoCountryCode
interface GreetingService {
fun getGreeting(country:String?):String
}
@Service
class DefaultGreetingService():GreetingService{
override fun getGreeting(country: String?): String {
return "test"
}
}
テスト
今回、GreetingControllerで以下の2つをテストしたいと仮定して書いていきます。
1. /api/greeting でgetを実行した際、ステータスコード200を返すこと。
2. /api/greeting/?country=JP でgetを実行した際、getGreetingにJPの引数を入れて実行して こんにちは が返されていること。
先にテストを書くとこんな感じです。
以下のテストを実行すると問題なく通ります。
package com.example.demo
import org.junit.jupiter.api.Test
import com.ninjasquad.springmockk.SpykBean
import io.mockk.every
import io.mockk.verify
import org.junit.jupiter.api.Assertions.*
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest
@AutoConfigureMockMvc
class GreetingControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@SpykBean
private lateinit var spyStubGreetingService: DefaultGreetingService
@Test
fun `/api/greeting を呼んだ時にステータスコード200を返す`() {
//ARRANGE
//ACT
mockMvc.perform(
get("/api/greeting")
)
//ASSERT
.andExpect(status().isOk) //isOkでステータスコード200であることを確認できる。
}
@Test
fun `クエリパラメータに"JP"を入れた時、getGreetingにJPの引数を入れて実行していること`() {
//ARRANGE
every { spyStubGreetingService.getGreeting("JP") } returns "こんにちは"
//ACT
mockMvc.perform(
get("/api/greeting?country=JP")
)
//ASSERTR
.andExpect(jsonPath("$").value("こんにちは"))
verify { spyStubGreetingService.getGreeting("JP") }
}
}
このテストの書き方を最初に教わった時、意味が分からなくて頭がバグりました。
なのでその時の自分の疑問に答える形で解説していきたいと思います。
疑問1. 実装ではステータスコードを返す記述はないけど一つ目のテストが通るのはなぜ?
⇨SpringBootが良きに計らってくれてます。正常にレスポンスを生成した場合は200、例外が発生した場合は404みたいに自動でやってくれます。
疑問2. mockMVCって何をやってるの?
⇨MockMvcは、SpringFrameworkに含まれているWebアプリケーションのコントローラテスト用のフレームワーク。
実際のwebサーバーを立ち上げることなくHTTPリクエストを模擬することができる。
疑問3. 各アノテーションの意味が分からない(アノテーションは@で記述されているやつ)
⇨各アノテーションの意味は以下の通り
・@SpringBootTest
→SpringBootの機能を用いる処理のテストを行う場合に、付与するアノテーション。DIコンテナの生成を行う。 主に、ServiceやRepositoryなどDIコンテナを用いた処理をテストする際に使用する。
※DI(Dependency Injection)コンテナとは、テスト対象のクラスが依存するオブジェクトの生成や管理を行う仕組み。
DIコンテナによりテスト対象のクラスに対してモックやスタブなどの代替オブジェクトを渡すことができる。
・@AutoConfigureMockMvc
→SpringFrameworkに含まれているアノテーションで単体テストでMockMvcを使用する際に使用する。
このアノテーションを付与することで自動的にMockMvcが構成される。
・@Autowired
→このアノテーションにより、SpringFrameworkが自動的に依存性の注入を行う。つまり記述されたフィールドやメソッドに対して、必要なオブジェクトを自動的に検索して
そのオブジェクトのインスタンスを割り当てます。勝手にインスタンスを割り当ててくれる
今回のケースだとmockMvcに、このアノテーションが付けられているのでテストクラス内でmockMvcオブジェクトを使用する際はクラスからインスタンス化しなくても
そのまま使えるようになる。
・@SpykBean
→Spring Bootのテストで、指定されたBeanをモック化するためのアノテーション。
※beanとはアプリケーション内で定義されているクラスってイメージしておけばいいと思う。正しい定義はもっと複雑ぽいけど
このテストでは、DefaultGreetingServiceというBeanをspyStubGreetingServiceという名前でモック化している。
・@Test
→テストメソッドであることを示すアノテーション。
疑問4. え、そもそもこのテストと実装の紐付けってどうやってんの?
⇨JUnitでは、クラス名を一致させることで紐付けが行われる。
例えば、ExampleClassが実装されている場合、対応するテストクラスはExampleClassTestになる。
このように、テストクラス名に Test のサフィックスを付けることで、JUnitフレームワークにテストクラスを特定させることができる。
疑問5. ' every { spyStubGreetingService.getGreeting("JP") } returns "こんにちは" ' これは何やってるの?
⇨everyはmockkのDSL(ドメイン固有言語)指定された関数が呼び出された際に、指定した返り値を返すようにモックオブジェクトを振る舞わせる。
今回のコードでは、spyStubGreetingService.getGreetingメソッドに引数 "JP" を入れてが呼び出された際の返り値は "こんにちは" になるよう設定している。
疑問6. mockであるspyStubGreetingService と 実装内のgreetingServicの紐付けってどこでやってるの?名前で紐付けしてる?
⇨DIコンテナがinterfaceを見て良きに計らってくれてます。
private lateinit var spyStubGreetingService: DefaultGreetingService
↑ここでspyStubGreetingServiceは宣言時にDefaultGreetingServiceをインターフェースとして指定しています。
このインターフェースをもとにDIコンテナが実装から置き換え対象のクラスを探します。
すると実装内では
class GreetingController(val greetingService: DefaultGreetingService ) {
ここでgreetingServiceもDefaultGreetingServiceをインターフェースをして指定しているためマッチ。
テスト内ではDIコンテナがgreetingServiceは作成したモックであるspyStubGreetingServiceに置き換えられる。ここは名前で判断していない。
DIコンテナによる依存性の解決についてのイメージはこちらの記事を確認してみてください。
疑問7. '.andExpect(jsonPath("$").value("こんにちは"))' これは何してる?
⇨返り値の確認をしている。jsonPathについてはこの記事がわかりやすかった。
疑問8. 'verify { spyStubGreetingService.getGreeting("JP") }'これは何してる?
⇨verifyは、mockKのDSLの一部で、モックオブジェクトが指定された回数以上呼び出されたかどうかを検証するために使われる。
このコードでは、spyStubGreetingService.getGreetingメソッドが "JP" を引数にして1回呼び出されたことを検証している。
まとめ
すごく勉強になりました。
これでcontlrollerのテスト方法について 完全に理解した 感じです。
次は なにもわからない フェーズに行きたいですね。
kotlin初心者ですので記事内容に誤りがありましたらご指摘ください。
参考