Spring Boot + Kotlin でRESTアプリ作成ハンズオン5

前回↓

https://qiita.com/tseno/items/85507567505bb0a78a5b

# 5.JUnitで単体テストを行う【Mock編】

## 5-1. Mockの種類

- Controllerクラスをテストする際には、`MockMvc`と`@MockBean`を使用し、API呼び出しをします。
- Serviceクラスのテストには、`@Mock`と`@InjectMocks`を使用し、Java/Kotlin呼び出しをします。
- ControllerクラスのテストはSpringの機能を使用し、ServiceクラスのテストはMokitoというライブラリを使用します。Springの機能を使うテストは遅いので、必要最小限にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88686/3c359f02-6825-32c7-ca2b-91af4dc0cefc.png)


### 5-1-1. Controllerクラスのテスト例

- テスト対象Controllerクラス

```kotlin
@RestController
class MyController(
    private val myService: MyService
) {
    @GetMapping("/hello")
    fun hello(): String {
        return myService.getMessage()
    }
}
```

- Controllerクラスのテスト

```kotlin
@WebMvcTest(controllers = [MyController::class])
class MyControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockBean
    private lateinit var myService: MyService

    @Test
    fun hello() {
        // モックのセットアップ
        `when`(myService.getMessage()).thenReturn("Hello, world!")

        // APIリクエストの実行と検証
        mockMvc.perform(get("/hello"))
            .andExpect(status().isOk)
            .andExpect(content().string("Hello, world!"))

        // モックの呼び出しを検証
        verify(myService).getMessage()
    }
}
```

### 5-1-2. Serviceクラスのテスト例

- テスト対象Serviceクラス

```kotlin
@Service
class MyService(
    private val myRepository: MyRepository
) {
    fun getMessage(): String {
        val name = myRepository.getName()
        return "Hello, $name!"
    }
}
```

- Serviceクラスのテスト

```kotlin
class MyServiceTest {

    @Mock
    private lateinit var myRepository: MyRepository

    @InjectMocks
    private lateinit var myService: MyService

    @BeforeEach
    fun setUp() {
        MockitoAnnotations.initMocks(this)
    }

    @Test
    fun getMessage() {
        // モックのセットアップ
        `when`(myRepository.getName()).thenReturn("John")

        // メソッドの呼び出しと検証
        val message = myService.getMessage()
        assertEquals("Hello, John!", message)

        // モックの呼び出しを検証
        verify(myRepository).getName()
    }
}
```

## 5-2. Mockのセット方法

- whenはkotlinの予約語であるため、\`when\`のようにバッククォートで囲む必要がある。
- `when(モック化されたインスタンス名.メソッド名(引数)).thenReturn(戻り値)`
- `when(モック化されたインスタンス名.メソッド名(引数)).thenThrow(例外)`

```kotlin
// sampleService.getData("test")が呼ばれたら、"Hello, test"を返却するようにセットする
`when`(sampleService.getData("test")).thenReturn("Hello, test")

// sampleService.getData("")が呼ばれたら、RuntimeExceptionを返却するようにセットする
`when`(sampleService.getData("")).thenThrow(RuntimeException("Error"))

// 引数は何でもいい場合はany()を使用する
`when`(sampleRepository.findData(any())).thenReturn("data")
```

- Mockの呼び出し回数を検証するアサーション
- `verify(モック化されたインスタンス名, times(回数)).メソッド名(引数)`

```kotlin
// メソッドが1回呼ばれたことを検証
verify(sampleService, times(1)).getData("test")

// メソッドが呼ばれなかったことを検証
verify(sampleRepository, never()).findData(any())
```

## 5-3. テスト範囲

- テストは、Controllerクラスが対象になります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88686/6490cb80-7d48-6da7-59f3-7ffaaf7726bb.png)

## 5-4. テスト対象コード

- 前回までに作成したクラスが対象になります。

```kotlin:TodoController.kt
package com.example.demo

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
class TodoController(
        val todoRepository: TodoRepository
) {

    @GetMapping("/todos")
    fun getTodos(): List<Todo> {
        return todoRepository.findAll()
    }

    @GetMapping("/todo/{id}")
    fun getTodoById(@PathVariable("id") id: Long): ResponseEntity<Todo> {
        return todoRepository.findById(id).map { todo ->
            ResponseEntity.ok(todo)
        }.orElse(ResponseEntity(HttpStatus.NOT_FOUND))
    }

    @PostMapping("/todo")
    fun postTodo(@RequestBody todo: Todo): ResponseEntity<Todo> {
        return todoRepository.save(todo).let {
            ResponseEntity.ok(it)
        }
    }

    @PutMapping("/todo/{id}")
    fun putTodoById(@PathVariable("id") id: Long,
                    @RequestBody todo: Todo): ResponseEntity<Todo> {
        return todoRepository.findById(id).map { oldTodo ->
            val newTodo = Todo(
                        id = id,
                        title = todo.title,
                        description = todo.description,
                        isCompleted = todo.isCompleted,
                        createdAt = oldTodo.createdAt,
                        updatedAt = oldTodo.updatedAt
                )
            ResponseEntity.ok().body(todoRepository.save(newTodo))
        }.orElse(ResponseEntity(HttpStatus.NOT_FOUND))
    }

    @DeleteMapping("/todo/{id}")
    fun deleteTodoById(@PathVariable("id") id: Long): ResponseEntity<Unit> {
        return todoRepository.findById(id).map { todo ->
            todoRepository.delete(todo)
            ResponseEntity.ok().build<Unit>()
        }.orElse(ResponseEntity(HttpStatus.NOT_FOUND))
    }
}
```

## 5-5. テストコードの雛型作成

- 前回と同様に、テストコードを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88686/9f0c1e7a-a4fe-64a8-0a4d-c627d98c10c1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88686/e3a97a51-214b-cdc4-30e5-410f190a9256.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88686/6272bb88-04e2-d181-fcca-bbb5f6f3448e.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88686/5070b908-a5d1-b03e-3262-132791758272.png)



## 5-6. Getメソッドのテストを作成


```kotlin:TodoControllerTest.kt
package com.example.demo

import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.mockito.Mockito.*
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import java.time.OffsetDateTime
import java.util.*

@WebMvcTest(TodoController::class)
class TodoControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockBean
    private lateinit var todoRepository: TodoRepository

    @Test
    fun getTodos() {
        val todos = listOf(
                Todo(
                        id = 1L,
                        title = "title1",
                        description = "description1",
                        isCompleted = false,
                        createdAt = OffsetDateTime.now(),
                        updatedAt = OffsetDateTime.now(),
                ),
                Todo(
                        id = 2L,
                        title = "title2",
                        description = "description2",
                        isCompleted = false,
                        createdAt = OffsetDateTime.now(),
                        updatedAt = OffsetDateTime.now(),
                ))

        // Mockの設定
        `when`(todoRepository.findAll()).thenReturn(todos)

        // GET /todos を呼び出して、todosが返ってくることを確認する
        mockMvc.perform(get("/todos"))
                .andExpect(status().isOk)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$[0].id").value(todos[0].id))
                .andExpect(jsonPath("$[0].title").value(todos[0].title))
                .andExpect(jsonPath("$[0].description").value(todos[0].description))
                .andExpect(jsonPath("$[0].isCompleted").value(todos[0].isCompleted))
                .andExpect(jsonPath("$[1].id").value(todos[1].id))
                .andExpect(jsonPath("$[1].title").value(todos[1].title))
                .andExpect(jsonPath("$[1].description").value(todos[1].description))
                .andExpect(jsonPath("$[1].isCompleted").value(todos[1].isCompleted))

        // todoRepository.findAll()が1回呼び出されたことを確認
        verify(todoRepository, times(1)).findAll()
    }

    @Test
    fun getTodoById() {

        val todo = Todo(
                id = 1L,
                title = "title1",
                description = "description1",
                isCompleted = false,
                createdAt = OffsetDateTime.now(),
                updatedAt = OffsetDateTime.now(),
        )

        // Mockの設定
        `when`(todoRepository.findById(1L)).thenReturn(Optional.of(todo))

        // GET /todo/5 を呼び出して、値が返却されることを確認
        mockMvc.perform(get("/todo/{id}", 1L))
                .andExpect(status().isOk)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(todo.id))
                .andExpect(jsonPath("$.title").value(todo.title))
                .andExpect(jsonPath("$.description").value(todo.description))
                .andExpect(jsonPath("$.isCompleted").value(todo.isCompleted))

        // todoRepository.findById(5)が1回呼び出されたことを確認
        verify(todoRepository, times(1)).findById(1)
    }
}
```
 
## 5-7. Postメソッドのテスト

```kotlin:TodoControllerTest.kt
    @Test
    fun testPostTodo() {
        val objectMapper = ObjectMapper()

        // 入力されたTodo（リクエストボディ）
        val inputTodo = Todo(
                id = null,
                title = "Test Title",
                description = "Test Description",
                isCompleted = false,
                createdAt = null,
                updatedAt = null
        )

        // 保存されたTodo（レスポンスボディ）
        val savedTodo = Todo(
                id = 1L,
                title = "Test Title",
                description = "Test Description",
                isCompleted = false,
                createdAt = OffsetDateTime.now(),
                updatedAt = OffsetDateTime.now()
        )

        // todoRepository.save()が呼び出されたらsavedTodoを返すように設定
        `when`(todoRepository.save(any())).thenReturn(savedTodo)

        mockMvc.perform(post("/todo", inputTodo)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(inputTodo)))
                .andExpect(status().isOk)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(savedTodo.id))
                .andExpect(jsonPath("$.title").value(savedTodo.title))
                .andExpect(jsonPath("$.description").value(savedTodo.description))
                .andExpect(jsonPath("$.isCompleted").value(savedTodo.isCompleted))

        verify(todoRepository, times(1)).save(any())
    }
```

## 5-8. Putメソッドのテスト

```kotlin
    @Test
    fun putTodoById() {
        val objectMapper = ObjectMapper()

        // 入力されたTodo（リクエストボディ）
        val updateTodo = Todo(
                id = null,
                title = "Updated Title",
                description = "Updated Description",
                isCompleted = true,
                createdAt = null,
                updatedAt = null
        )

        // 既存のTodo
        val existingTodo = Todo(
                id = 1L,
                title = "Test Title",
                description = "Test Description",
                isCompleted = false,
                createdAt = OffsetDateTime.now(),
                updatedAt = OffsetDateTime.now()
        )

        // 更新されたTodo（レスポンスボディ）
        val updatedTodo = Todo(
                id = 1L,
                title = "Updated Title",
                description = "Updated Description",
                isCompleted = true,
                createdAt = existingTodo.createdAt,
                updatedAt = OffsetDateTime.now()
        )

        // Mockの設定
        `when`(todoRepository.findById(1L)).thenReturn(Optional.of(existingTodo))
        `when`(todoRepository.save(any())).thenReturn(updatedTodo)

        // PUT /todo/1 を呼び出して、値が更新されることを確認
        mockMvc.perform(put("/todo/{id}", 1L)
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(updateTodo)))
                .andExpect(status().isOk)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(updatedTodo.id))
                .andExpect(jsonPath("$.title").value(updatedTodo.title))
                .andExpect(jsonPath("$.description").value(updatedTodo.description))
                .andExpect(jsonPath("$.isCompleted").value(updatedTodo.isCompleted))

        // todoRepository.findById(1)が1回呼び出されたことを確認
        verify(todoRepository, times(1)).findById(1L)
        // todoRepository.save(existingTodo)が1回呼び出されたことを確認
        verify(todoRepository, times(1)).save(any())
    }
```

## 5-9. Deleteメソッドのテスト

```kotlin
    @Test
    fun deleteTodoById() {
        val todo = Todo(
                id = 1L,
                title = "Test Title",
                description = "Test Description",
                isCompleted = false,
                createdAt = OffsetDateTime.now(),
                updatedAt = OffsetDateTime.now()
        )
        // findById()が返却するOptionalを設定
        `when`(todoRepository.findById(1L)).thenReturn(Optional.of(todo))
        // voidが返却されるメソッドはこう書くらしい https://stackoverflow.com/questions/24006790/mockito-when-thenreturn-for-void-method
        doNothing().`when`(todoRepository).delete(any())

        mockMvc.perform(delete("/todo/{id}", 1L))
                .andExpect(status().isOk)

        // mockのメソッドが呼び出されたことを確認
        verify(todoRepository, times(1)).findById(1L)
        verify(todoRepository, times(1)).delete(any())
    }
```

おわり