Spring Boot + KotlinでサーバーサイドKotlin実践入門 その３

#はじめに
「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その３となります。
[「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その１](https://qiita.com/kawano_fumihiko/items/389f977186398f2bcbd3)
[「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その２](https://qiita.com/kawano_fumihiko/items/8cb4327a4c119f30a353)
を先にご覧いただければと思います。

#作成するアプリの復習
##todo管理アプリ概要
作成するアプリですが、とても貧弱な機能のtodo管理アプリとなります。
Viewは「Thymeleaf」と「JQuery」、DBアクセスは「Spring Data JDBC」、DBは「h2database」、モックフレームワークは「MockK」と「SpringMockK」、テストで「DBUnit」と「AssertJ」を利用する構成となります。

[「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その１](https://qiita.com/kawano_fumihiko/items/389f977186398f2bcbd3)では、一覧とタイトルの検索
[「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その２](https://qiita.com/kawano_fumihiko/items/8cb4327a4c119f30a353)では、新規登録と更新
の説明をさせていただきました。

最終形のアプリの動作イメージは以下の通りです。
![操作対象サンプルの説明11.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102138/f497448a-11af-ca9b-b6d6-54a9a1e68d15.gif)

一覧、titleの部分一致検索、新規登録、更新、CSVの一括登録、CSVダウンロード
が全機能となります。

全てのソースはGitHubに登録しております。
[完成版のGitHubリポジトリ](https://github.com/small-java-world/todoList)
[Spring Boot + KotlinでサーバーサイドKotlin実践入門 その１完了版のGitHubリポジトリ](https://github.com/small-java-world/todoList/tree/first)
[Spring Boot + KotlinでサーバーサイドKotlin実践入門 その２完了版のGitHubリポジトリ](https://github.com/small-java-world/todoList/tree/second)

その３ではCSVの一括登録、CSVダウンロードを扱います。

#CSVの一括登録機能
CSVの一括登録機能を作成していきます。

##CSVの一括登録機能
###TodoService
todoRepository.saveAll(todoList)を呼び出すsaveAllメソッドを追加します。
TodoRepositoryはCrudRepositoryを継承しているので、TodoRepositoryを変更しなくてもTodoRepository#saveAllは利用可能です。

```kotlin
    fun saveAll(todoList:List<Todo>) :Boolean {
        return try {
            todoRepository.saveAll(todoList)
            true;
        }catch (e:Exception) {
            false;
        }
    }
```

###TodoUploadForm
アップロードファイルに対応するuploadedFile : MultipartFile?を含むdataクラスです。

```kotlin
package com.example.todoList.form

import org.springframework.web.multipart.MultipartFile

data class TodoUploadForm(val uploadedFile : MultipartFile?)
```

###UploadController
CSVのアップロード画面とアップロード処理を含むコントローラーです。

```kotlin
package com.example.todoList.controller

import com.example.todoList.entity.Todo
import com.example.todoList.form.TodoUploadForm
import com.example.todoList.service.TodoService
import com.example.todoList.toTimestamp
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.servlet.ModelAndView
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets

@Controller
class UploadController @Autowired constructor(private val todoService: TodoService) {
    @GetMapping("/uploadForm")
    fun uploadForm(@ModelAttribute todoUploadForm: TodoUploadForm): ModelAndView =
        ModelAndView("/uploadForm")

    @PostMapping("/upload")
    fun upload(todoUploadForm: TodoUploadForm, model: Model): String? {
        val todoList = ArrayList<Todo>()

        try {
            BufferedReader(InputStreamReader(todoUploadForm.uploadedFile?.inputStream, StandardCharsets.UTF_8)).use {
                    bufferedReader ->

                //アップロードされたファイルを読み込んで各行のデータをTodoに変換し、todoListに追加する。
                var csvLine = bufferedReader.readLine()
                while(csvLine != null) {
                    val csvLineElement = csvLine.split(",")
                    val todo: Todo = Todo(null, csvLineElement[0], csvLineElement[1], toTimestamp(csvLineElement[2]))
                    todoList.add(todo)
                    csvLine = bufferedReader.readLine()
                }

                if(!todoService.saveAll(todoList)) {
                    model.addAttribute("errorMessage", "アップロード失敗")
                    return "/uploadForm"
                }
                return "redirect:/top/list";
            }
        } catch (e: IOException) {
            model.addAttribute("errorMessage", "アップロード失敗:ファイルが読み込めません")
            throw RuntimeException("ファイルが読み込めません", e)
        }

        return "/uploadForm"
    }
}
```

uploadFormメソッドの説明は不要と思います。

uploadメソッドは、TodoUploadFormのuploadedFileからデータを読み込んで、todoListにデータを格納、todoService.saveAll(todoList)を呼び出してDBに登録する処理となります。サンプルアプリなので分かりやすさ優先ですので、これでOKだと思いますが、全部ため込んで一括でDB登録って、プロダクトコードでこんなの書いたら殴り倒されるパターンですね・・・

###list.html
アップロードフォームへのリンクを追加します。

```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <script type="text/javascript" th:src="@{/jquery-3.6.0.min.js}"></script>
    <script type="text/javascript" th:src="@{/searchTodo.js}"></script>
    <title>Todo List</title>
</head>
<body>
<div class="container">
    <h1>todo list</h1>
    <form method="post" id=“searchForm”>
        <div class="row">
            <div class="col-md-6">
                <input type="text" id="searchCondTitle" name="searchCondTitle" size="10"><input type="button" value="検索" onclick="searchTodo();" >
            </div>
        </div>
    </form>
    <a th:href="@{/inputForm}">新規登録</a>
    <a th:href="@{/uploadForm}">アップロード</a>

    <div class="row">
        <table id="todoList">   
            <thead>
            <tr>
                <td>ID</td>
                <td>タイトル</td>
                <td>内容</td>
                <td>期限</td>
            </tr>
            </thead>
            <tbody>
                <tr th:each="todo:${todoListForm.todoList}" th:object="${todo}">
                    <td th:text="*{id}"></td>
                    <td><a th:href="@{/editForm/(id=*{id})}" th:text="*{title}"></a></td>
                    <td th:text="*{content}"></td>
                    <td th:text="${todo.limittime} ? ${#dates.format(todo.limittime, 'yyyy/MM/dd')}"></td>
                </tr>
            </tbody>
        </table>
    </div>
</div>
</body>
</html>
```
###uploadForm.html
アップロードフォームのhtmlを追加します。

```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <title>Upload Todo</title>
</head>
<body>
    <div class="container">
        <h1>upload todo</h1>
        <form action="#" th:action="@{/upload}" method="post" enctype="multipart/form-data" th:object="${todoUploadForm}">
            <input type="file" th:field="*{uploadedFile}" />
            <button type="submit">送信</button>
        </form>
</body>
</html>
```

CSVの一括登録機能はこれで完成となります。

##CSVの一括登録機能のテスト

###TodoServiceTest
TodoService#saveAllメソッドのテストを追加します。

TodoServiceTestの`test save todo`とほぼ同じですね。

```kotlin
    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun `test saveAll`(isSuccess:Boolean) {
        val saveAllTargetList = generateMockResultTotoList(true)

        //モックであるtodoRepository#saveAllの結果を指定
        if(isSuccess) {
            every { todoRepository.saveAll(saveAllTargetList) } returns saveAllTargetList
        }else {
            every { todoRepository.saveAll(saveAllTargetList) }.throws(Exception())
        }

        assertEquals(isSuccess, todoService.saveAll(saveAllTargetList))

        //todoRepository.saveAllの呼び出し回数を確認
        verify(exactly = 1) { todoRepository.saveAll(saveAllTargetList) }
    }
```

###UploadControllerTest
UploadControllerのテストクラスです。

```kotlin
package com.example.todoList.controller

import com.example.todoList.TestBase
import com.example.todoList.entity.Todo
import com.example.todoList.service.TodoService
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import java.io.File
import java.nio.charset.StandardCharsets


@ExtendWith(SpringExtension::class)
@WebMvcTest(UploadController::class)
class UploadControllerTest : TestBase() {
    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var todoService: TodoService

    @Test
    fun testUploadForm() {
        val mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/uploadForm"))
            .andExpect(view().name("/uploadForm"))
            .andExpect(status().isOk)
            .andReturn()

        assertFileEquals(
            "uploadForm.txt",
            mvcResult.response.contentAsString
        )
    }

    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun testUpload(isSuccess: Boolean) {
        //MockMultipartFileに指定するアップロードファイルのコンテンツを作成
        val csvContent =
            "uploadTodo1,uploadTodoCont1,2021/04/20" + File.separator +
                    "uploadTodo2,uploadTodoCont2,2021/04/21" + File.separator +
                    "uploadTodo3,uploadTodoCont3,2021/04/22" + File.separator

        //MockMultipartFileを作成
        val uploadedFile = MockMultipartFile(
            "uploadedFile",
            "todo.txt",
            MediaType.TEXT_PLAIN_VALUE,
            csvContent.toByteArray(StandardCharsets.UTF_8)
        )

        //todoService.saveAllの戻り値にisSuccessを設定
        every { todoService.saveAll(todoList = any<List<Todo>>()) } returns isSuccess

        if (isSuccess) {
            //todoService.saveAllが成功したときは/top/listにリダイレクトされることを確認
            mockMvc.perform(multipart("/upload").file(uploadedFile))
                .andExpect(status().is3xxRedirection)
                .andExpect(redirectedUrl("/top/list"))
        } else {
            //todoService.saveAllが失敗したときはerrorMessage="アップロード失敗"がmodelに登録され、/uploadFormに遷移することを確認
            mockMvc.perform(multipart("/upload").file(uploadedFile))
                .andExpect(status().isOk)
                .andExpect(view().name("/uploadForm"))
                .andExpect(model().attribute("errorMessage", "アップロード失敗")).andReturn()
        }

        //todoService.saveAllが一回だけ呼ばれることを確認
        verify(exactly = 1) { todoService.saveAll(todoList = any<List<Todo>>()) }
    }
}
```
基本的な部分は、TopControllerTestのような他のコントローラーのテストクラスと同様です。
testUploadFormでは、/uploadFormにgetでアクセスし、結果のレスポンスのテキストを期待値ファイルと比較しています。

```text:uploadForm.txt
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Upload Todo</title>
</head>
<body>
    <div class="container">
        <h1>upload todo</h1>
        <form action="/upload" method="post" enctype="multipart/form-data">
            <input type="file" id="uploadedFile" name="uploadedFile" />
            <button type="submit">送信</button>
        </form>
</body>
</html>
```

testUploadは、ParameterizedTestアノテーションを付与した成功と失敗の2バリエーションのテストとなります。

```kotlin
    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun testUpload(isSuccess: Boolean) {
```

ポイントとしては、

```kotlin
            mockMvc.perform(multipart("/upload").file(uploadedFile))
```
のように、/uploadに対してマルチパートのリクエストでuploadedFileを送信している点となります。

具体的な中身ですが、

```kotlin
        //MockMultipartFileに指定するアップロードファイルのコンテンツを作成
        val csvContent =
            "uploadTodo1,uploadTodoCont1,2021/04/20${File.separator}" +
                    "uploadTodo2,uploadTodoCont2,2021/04/21${File.separator}" +
                    "uploadTodo3,uploadTodoCont3,2021/04/22${File.separator}"

        //MockMultipartFileを作成
        val uploadedFile = MockMultipartFile(
            "uploadedFile",
            "todo.txt",
            MediaType.TEXT_PLAIN_VALUE,
            csvContent.toByteArray(StandardCharsets.UTF_8)
        )
```
コメントに記載した通りなのですが、mockMvc.performで送信するアップロードのデータを準備しています。

```kotlin
        if (isSuccess) {
            //todoService.saveAllが成功したときは/top/listにリダイレクトされることを確認
            mockMvc.perform(multipart("/upload").file(uploadedFile))
                .andExpect(status().is3xxRedirection)
                .andExpect(redirectedUrl("/top/list"))
```
成功するテストの場合は、/top/listにリダイレクトされることを確認しています。

```kotlin
        } else {
            //todoService.saveAllが失敗したときはerrorMessage="アップロード失敗"がmodelに登録され、/uploadFormに遷移することを確認
            mockMvc.perform(multipart("/upload").file(uploadedFile))
                .andExpect(status().isOk)
                .andExpect(view().name("/uploadForm"))
                .andExpect(model().attribute("errorMessage", "アップロード失敗")).andReturn()
        }
```
失敗するテストの場合は、modelに"errorMessage"="アップロード失敗"が登録され、/uploadFormが描画されることを確認しています。

描画って表現はなんか違いますね・・・、view().name("/uploadForm")であることを確認って書くと、ホント日本語に変換する意味ないですし・・・
viewのnameが"/uploadForm"であることを確認・・・

以上で、CSVの一括登録機能のテストも完成です。

#CSVダウンロード機能
CSVダウンロード機能を作成していきます。

##CSVダウンロード機能
###TodoService
ダウンロードするCSVの文字列を生成するメソッドを追加します。

```kotlin
    fun getDownloadCsvData(): String {
        var csvList = todoRepository.findAll()

        var stringBuilder = StringBuilder()
        csvList.forEach() {
            stringBuilder.append("${it.title},${it.content},${timestampToString(it.limittime!!)}${System.lineSeparator()}")
        }
        return stringBuilder.toString()
    }
```
KotlinのforEachは、現在の対象エレメントの名前がitになります。

todoRepository.findAll()の結果をforEachで回して、現在の対象todoのtitle,content,limittime(yyyy/MM/DD)をカンマで結合し、一つのtodoを１行にするために改行(System.lineSeparator())を最後に追加しています。

Kotlinのコレクションの使い方については、
[Kotlin のコレクション使い方メモ](https://qiita.com/opengl-8080/items/36351dca891b6d9c9687)
がとても参考になりますので、こちらをご覧ください。

###DownloadController
list.htmlの「ダウンロード」リンクに対応するアクション(/download)を含むコントローラーとなります。

```kotlin
package com.example.todoList.controller

import com.example.todoList.service.TodoService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

@Controller
class DownloadController @Autowired constructor(private val todoService: TodoService) {
    @GetMapping("/download")
    fun download(): ResponseEntity<ByteArray> {
        return ResponseEntity.ok()
            .header(
                HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename*=utf-8''" + URLEncoder.encode("TODOダウンロード.csv", StandardCharsets.UTF_8.name())
            )
            .body(todoService.getDownloadCsvData().toByteArray(StandardCharsets.UTF_8));
    }
}
```
headerのところは、ファイルダウンロードでよく利用する「あるあるな実装」ですね。こう書かないとダウンロードしたファイル名が文字化けしてしまいます。
bodyのところは、TodoService#getDownloadCsvData()の結果をUTF_8のバイト配列に変換してセットしています。

###list.html
「ダウンロード」リンクを追加します。

```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <script type="text/javascript" th:src="@{/jquery-3.6.0.min.js}"></script>
    <script type="text/javascript" th:src="@{/searchTodo.js}"></script>
    <title>Todo List</title>
</head>
<body>
<div class="container">
    <h1>todo list</h1>
    <form method="post" id=“searchForm”>
        <div class="row">
            <div class="col-md-6">
                <input type="text" id="searchCondTitle" name="searchCondTitle" size="10"><input type="button" value="検索" onclick="searchTodo();" >
            </div>
        </div>
    </form>
    <a th:href="@{/inputForm}">新規登録</a>
    <a th:href="@{/uploadForm}">アップロード</a>
    <a th:href="@{/download}">ダウンロード</a>

    <div class="row">
        <table id="todoList">
            <thead>
            <tr>
                <td>ID</td>
                <td>タイトル</td>
                <td>内容</td>
                <td>期限</td>
            </tr>
            </thead>
            <tbody>
                <tr th:each="todo:${todoListForm.todoList}" th:object="${todo}">
                    <td th:text="*{id}"></td>
                    <td><a th:href="@{/editForm/(id=*{id})}" th:text="*{title}"></a></td>
                    <td th:text="*{content}"></td>
                    <td th:text="${todo.limittime} ? ${#dates.format(todo.limittime, 'yyyy/MM/dd')}"></td>
                </tr>
            </tbody>
        </table>
    </div>
</div>
</body>
</html>
```

ダウンロード機能はこれで完成となります。

##ダウンロード機能のテスト
###TodoServiceTest
TodoService#getDownloadCsvData()のテストを追加します。

```kotlin
    @Test
    fun `tetGetDownloadCsvData`() {
        val findAllResultList = generateMockResultTotoList(true)

        every { todoRepository.findAll() } returns findAllResultList

        val result = todoService.getDownloadCsvData()

        assertEquals(
            "todo5,5content,2021/04/20${System.lineSeparator()}todo6,6content,2021/04/21${System.lineSeparator()}",
            result
        )
        verify(exactly = 1) { todoRepository.findAll() }
    }
```
新たな要素は含まれませんので、特に説明は不要と思います。
generateMockResultTotoListの引数もはや意味を成していませんね・・・

###DownloadControllerTest
DownloadControllerrのテストクラスとなります。

```kotlin
package com.example.todoList.controller

import com.example.todoList.TestBase
import com.example.todoList.service.TodoService
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.HttpHeaders
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import java.net.URLDecoder
import java.nio.charset.StandardCharsets


@ExtendWith(SpringExtension::class)
@WebMvcTest(DownloadController::class)
class DownloadControllerTest : TestBase() {
    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var todoService: TodoService

    @Test
    fun testDownload() {
        //todoService.getDownloadCsvData()で"dummyCavData"が返却されるようにする。
        every { todoService.getDownloadCsvData() } returns "dummyCavData"

        val mvcResult = mockMvc.perform(get("/download"))
            .andExpect(status().isOk)
            .andExpect(content().string("dummyCavData"))
            .andExpect(
                header().string(
                    HttpHeaders.CONTENT_DISPOSITION,
                    "attachment; filename*=utf-8''TODO%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89.csv"
                )
            )
            .andReturn()

        //HttpHeaders.CONTENT_DISPOSITIONの検証が直感的じゃないのでデコードして検証
        val contentDisposition = mvcResult.response.getHeader(HttpHeaders.CONTENT_DISPOSITION)
        assertEquals(
            "attachment; filename*=utf-8''TODOダウンロード.csv",
            URLDecoder.decode(contentDisposition, StandardCharsets.UTF_8.name())
        )

        verify(exactly = 1) { todoService.getDownloadCsvData() }
    }

}
```

```kotlin
        //todoService.getDownloadCsvData()で"dummyCavData"が返却されるようにする。
        every { todoService.getDownloadCsvData() } returns "dummyCavData"
```
でtodoService.getDownloadCsvData()の戻り値が"dummyCavData"になるようにしています。
今回のダウンロード処理は、todoService.getDownloadCsvData()の中身で分岐するような処理になっていないので、シンプルで分かりやすいデータをモックの戻り値にする方がテストの見通しが良くなります。

頑張ってCSVの複数行とかにすると、テスト実装に時間かかるし、あーー、なんか意図あってこのデータにしてるのかな！、との読んだ人の無駄な思考が発生する可能性もあります。

ダウンロード処理自体のテストですが、

```kotlin
        val mvcResult = mockMvc.perform(get("/download"))
            .andExpect(status().isOk)
            .andExpect(content().string("dummyCavData"))
            .andExpect(
                header().string(
                    HttpHeaders.CONTENT_DISPOSITION,
                    "attachment; filename*=utf-8''TODO%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89.csv"
                )
            )
            .andReturn()
```
で、/downloadにgetでアクセスし

- レスポンスの中身が"dummyCavData"と一致すること
- ヘッダーのHttpHeaders.CONTENT_DISPOSITIONが"attachment; filename*=utf-8''TODO%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89.csv"と一致すること

を検証しています。ヘッダーの中身はエンコードされているので、期待値に指定している文字列が本当に正しいのか不安ですよね！！
ってそこ喜ぶところじゃない・・・

ですので、デコードして確認しています。

```kotlin
        //HttpHeaders.CONTENT_DISPOSITIONの検証が直感的じゃないのでデコードして検証
        val contentDisposition = mvcResult.response.getHeader(HttpHeaders.CONTENT_DISPOSITION)
        assertEquals(
            "attachment; filename*=utf-8''TODOダウンロード.csv",
            URLDecoder.decode(contentDisposition, StandardCharsets.UTF_8.name())
        )
```

最後に、お決まりのやつです。

```kotlin
        verify(exactly = 1) { todoService.getDownloadCsvData() }
```

ダウンロード機能のテストもこれで完成です。


以上で「Spring Boot + KotlinでサーバーサイドKotlin実践入門 その３」は終了となります。

この時点での全ソースは
[完成版のGitHubリポジトリ](https://github.com/small-java-world/todoList)
に登録済みです。