LoginSignup
1
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-05-02

はじめに

「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では、新規登録と更新
の説明をさせていただきました。

最終形のアプリの動作イメージは以下の通りです。
操作対象サンプルの説明11.gif

一覧、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でアクセスし、結果のレスポンスのテキストを期待値ファイルと比較しています。

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バリエーションのテストとなります。

    @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リポジトリ
に登録済みです。

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