はじめに
「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その3となります。
「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その1
「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その2
を先にご覧いただければと思います。
作成するアプリの復習
todo管理アプリ概要
作成するアプリですが、とても貧弱な機能のtodo管理アプリとなります。
Viewは「Thymeleaf」と「JQuery」、DBアクセスは「Spring Data JDBC」、DBは「h2database」、モックフレームワークは「MockK」と「SpringMockK」、テストで「DBUnit」と「AssertJ」を利用する構成となります。
「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その1では、一覧とタイトルの検索
「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その2では、新規登録と更新
の説明をさせていただきました。
一覧、titleの部分一致検索、新規登録、更新、CSVの一括登録、CSVダウンロード
が全機能となります。
全てのソースはGitHubに登録しております。
完成版のGitHubリポジトリ
Spring Boot + KotlinでサーバーサイドKotlin実践入門 その1完了版のGitHubリポジトリ
Spring Boot + KotlinでサーバーサイドKotlin実践入門 その2完了版のGitHubリポジトリ
その3ではCSVの一括登録、CSVダウンロードを扱います。
CSVの一括登録機能
CSVの一括登録機能を作成していきます。
CSVの一括登録機能
TodoService
todoRepository.saveAll(todoList)を呼び出すsaveAllメソッドを追加します。
TodoRepositoryはCrudRepositoryを継承しているので、TodoRepositoryを変更しなくてもTodoRepository#saveAllは利用可能です。
fun saveAll(todoList:List<Todo>) :Boolean {
return try {
todoRepository.saveAll(todoList)
true;
}catch (e:Exception) {
false;
}
}
TodoUploadForm
アップロードファイルに対応するuploadedFile : MultipartFile?を含むdataクラスです。
package com.example.todoList.form
import org.springframework.web.multipart.MultipartFile
data class TodoUploadForm(val uploadedFile : MultipartFile?)
UploadController
CSVのアップロード画面とアップロード処理を含むコントローラーです。
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
アップロードフォームへのリンクを追加します。
<!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を追加します。
<!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
とほぼ同じですね。
@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のテストクラスです。
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でアクセスし、結果のレスポンスのテキストを期待値ファイルと比較しています。
<!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バリエーションのテストとなります。
@ParameterizedTest
@ValueSource(strings = ["true", "false"])
fun testUpload(isSuccess: Boolean) {
ポイントとしては、
mockMvc.perform(multipart("/upload").file(uploadedFile))
のように、/uploadに対してマルチパートのリクエストでuploadedFileを送信している点となります。
具体的な中身ですが、
//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で送信するアップロードのデータを準備しています。
if (isSuccess) {
//todoService.saveAllが成功したときは/top/listにリダイレクトされることを確認
mockMvc.perform(multipart("/upload").file(uploadedFile))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/top/list"))
成功するテストの場合は、/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()
}
失敗するテストの場合は、modelに"errorMessage"="アップロード失敗"が登録され、/uploadFormが描画されることを確認しています。
描画って表現はなんか違いますね・・・、view().name("/uploadForm")であることを確認って書くと、ホント日本語に変換する意味ないですし・・・
viewのnameが"/uploadForm"であることを確認・・・
以上で、CSVの一括登録機能のテストも完成です。
CSVダウンロード機能
CSVダウンロード機能を作成していきます。
CSVダウンロード機能
TodoService
ダウンロードするCSVの文字列を生成するメソッドを追加します。
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を1行にするために改行(System.lineSeparator())を最後に追加しています。
Kotlinのコレクションの使い方については、
Kotlin のコレクション使い方メモ
がとても参考になりますので、こちらをご覧ください。
DownloadController
list.htmlの「ダウンロード」リンクに対応するアクション(/download)を含むコントローラーとなります。
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
「ダウンロード」リンクを追加します。
<!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()のテストを追加します。
@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のテストクラスとなります。
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() }
}
}
//todoService.getDownloadCsvData()で"dummyCavData"が返却されるようにする。
every { todoService.getDownloadCsvData() } returns "dummyCavData"
でtodoService.getDownloadCsvData()の戻り値が"dummyCavData"になるようにしています。
今回のダウンロード処理は、todoService.getDownloadCsvData()の中身で分岐するような処理になっていないので、シンプルで分かりやすいデータをモックの戻り値にする方がテストの見通しが良くなります。
頑張ってCSVの複数行とかにすると、テスト実装に時間かかるし、あーー、なんか意図あってこのデータにしてるのかな!、との読んだ人の無駄な思考が発生する可能性もあります。
ダウンロード処理自体のテストですが、
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"と一致すること
を検証しています。ヘッダーの中身はエンコードされているので、期待値に指定している文字列が本当に正しいのか不安ですよね!!
ってそこ喜ぶところじゃない・・・
ですので、デコードして確認しています。
//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() }
ダウンロード機能のテストもこれで完成です。
以上で「Spring Boot + KotlinでサーバーサイドKotlin実践入門 その3」は終了となります。
この時点での全ソースは
完成版のGitHubリポジトリ
に登録済みです。