1
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-05-01

はじめに

「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その2となります。
「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その1
を先にご覧いただければと思います。

作成するアプリの復習

todo管理アプリ概要

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

「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その1では、一覧とタイトルの検索まで終わっています。

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

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

全てのソースはGitHubに登録しております。
完成版のGitHubリポジトリ
Spring Boot + KotlinでサーバーサイドKotlin実践入門 その1完了版のGitHubリポジトリ
Spring Boot + KotlinでサーバーサイドKotlin実践入門 その2完了版のGitHubリポジトリ

その2では新規登録、更新を扱います。
機能ではないのですが、バリデーションも追加いたします。

新規登録機能

新規登録を作成していきます。

新規登録機能の実装

TodoService

todoRepository.save(todo)を呼び出すsaveメソッドを追加します。

    fun save(todo:Todo) :Boolean {
        return try {
            todoRepository.save(todo)
            true;
        }catch (e:Exception) {
            false;
        }
    }

EditController

新規登録時の入力画面(/inputForm)と登録処理(/save)のコントローラーを追加します。
ファイルはsrc/main/kotlin/com/example/todoList/controller/EditController.ktです。

package com.example.todoList.controller

import com.example.todoList.entity.Todo
import com.example.todoList.service.TodoService
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

@Controller
class EditController @Autowired constructor(private val todoService: TodoService) {
    /**
     * /inputFormにgetでアクセスするとinputForm.htmlが返却される。
     * @ModelAttribute todo: Todoと宣言してるのでmodelに"todo"の名前でtodoがセットされる。
     */
    @GetMapping("/inputForm")
    fun inputForm(@ModelAttribute todo: Todo): ModelAndView =
        ModelAndView("/inputForm")

    @PostMapping("/save")
    fun save(
        @ModelAttribute todo: Todo,
        model: Model
    ): String {
        return if (todoService.save(todo)) {
            "redirect:/top/list";
        } else {
            model.addAttribute("errorMessage", "登録失敗")
            "/inputForm";
        }
    }
}
class EditController @Autowired constructor(private val todoService: TodoService) {

TopControllerと同様ですが、コンストラクタインジェクションでTodoServiceをインジェクションしています。
inputFormメソッドは、新たな要素はないですので説明は不要と思います。

saveメソッドですが、todoService.saveを呼び出して成功した場合は、一覧画面(/top/list)にリダイレクト、失敗した場合はerrorMessageをmodelに追加してinput.htmlを描画しています。@ModelAttribute todo: Todoで引数を宣言しているので自動的にmodelからtodoが参照可能となります。

inputForm.html

ファイルはsrc/main/resources/templates/inputForm.htmlです。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <title>Todo Input Form</title>
</head>
<body>
<div class="container">
    <h1>create todo</h1>

    <form action="#" th:action="@{/save}" method="post" th:object="${todo}">
        <label>title:</label>
        <input type="text" th:field="*{title}" />
        <br/>

        <label>content:</label>
        <input type="text" th:field="*{content}" />
        <br/>

        <label>limittime(yyyy/mm/dd):</label>
        <input type="text" id="limittime" name="limittime" th:value="${todo.limittime} ? ${#dates.format(todo.limittime, 'yyyy/MM/dd')}" /><br/>
        <button type="submit">登録</button>
    </form>
</div>
</body>
</html>

modelからtodoを取り出して各値を表示しています。limittimeはyyyy/MM/dd形式で表示しています。

その1の「Thymeleafのdates.formatを利用する方法」のところで説明させていただいた方法と同じとなりますが、todo.limittimeがnullの場合でも異常終了しないように、Thymeleafのconditional expressionsでtodo.limittimeがnot nullの場合のみフォーマットするようにしています。

新規登録機能のテストの実装

EditControllerTest

package com.example.todoList.controller

import com.example.todoList.TestBase
import com.example.todoList.entity.Todo
import com.example.todoList.service.TodoService
import com.example.todoList.toTimestamp
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.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.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.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.util.LinkedMultiValueMap


@ExtendWith(SpringExtension::class)
@WebMvcTest(EditController::class)
class EditControllerTest : TestBase() {
    companion object {
        private const val LIMIT_TIME = "2021/04/20"
    }

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var todoService: TodoService

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

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

    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun `save test`(isSuccess: Boolean) {
        val requestTodo = Todo(null, "todo5", "5content", toTimestamp(LIMIT_TIME))

        every { todoService.save(todo = any<Todo>()) } returns isSuccess

        val params = LinkedMultiValueMap<String, String>()
        params.add("title", requestTodo.title)
        params.add("content", requestTodo.content)
        params.add("limittime", LIMIT_TIME)

        if (isSuccess) {
            mockMvc.perform(post("/save").params(params))
                .andExpect(status().is3xxRedirection)
                .andExpect(redirectedUrl("/top/list"))
                .andReturn()
        } else {
            val mvcResult = mockMvc.perform(post("/save").params(params))
                .andExpect(status().isOk)
                .andExpect(view().name("/inputForm"))
                .andExpect(model().attribute("errorMessage", "登録失敗")).andReturn()

            val todo = mvcResult.modelAndView!!.modelMap["todo"] as Todo
            assertEquals(requestTodo.title, todo.title)
            assertEquals(requestTodo.content, todo.content)
            assertEquals(requestTodo.limittime, todo.limittime)
        }

        verify(exactly = 1) { todoService.save(todo = any<Todo>()) }
    }


    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun `save test using flashAttr`(isSuccess: Boolean) {
        val requestTodo = Todo(null, "todo5", "5content", toTimestamp(LIMIT_TIME))

        every { todoService.save(requestTodo) } returns isSuccess

        if (isSuccess) {
            val mvcResult = mockMvc.perform(post("/save").flashAttr("todo", requestTodo))
                .andExpect(status().is3xxRedirection)
                .andExpect(redirectedUrl("/top/list"))
                .andReturn()
        } else {
            val mvcResult = mockMvc.perform(post("/save").flashAttr("todo", requestTodo))
                .andExpect(status().isOk)
                .andExpect(view().name("/inputForm"))
                .andExpect(model().attribute("errorMessage", "登録失敗")).andReturn()

            val todo = mvcResult.modelAndView!!.modelMap["todo"] as Todo
            assertEquals(requestTodo, todo)
        }

        verify(exactly = 1) { todoService.save(requestTodo) }
    }
}

基本的なところはTopControllerTestのテストと同様になります。

    @MockkBean
    private lateinit var todoService: TodoService

@MockkBeanを付与してtodoServiceを宣言することで、mockMvcからのリクエスト処理内でtodoServiceがモック化されます。

まずはinputFormから

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

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

/inputFormにgetでアクセスして、結果のviewが"/inputForm"で、レスポンスのステータスが200であることを検証しています。
あとはassertFileEqualsでレスポンスをテキストとして期待値ファイルの中身との比較となります。
assertFileEqualsはTopControllerTestのところで説明しておりますので、「Spring Boot + KotlinでサーバーサイドKotlin実践入門」その1をご覧ください。

inputForm.txt

inputForm.txtは以下のようになります。
パスはsrc/test/resources/com/example/todoList/controller/inputForm.txtです。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Todo Input Form</title>
</head>
<body>
<div class="container">
    <h1>create todo</h1>

    <form action="/save" method="post">
        <label>title:</label>
        <input type="text" id="title" name="title" value="" />
        <br/>

        <label>content:</label>
        <input type="text" id="content" name="content" value="" />
        <br/>

        <label>limittime(yyyy/mm/dd):</label>
        <input type="text" id="limittime" name="limittime" value="" /><br/>
        <button type="submit">登録</button>
    </form>
</div>
</body>
</html>

次は'save test'です。
ポイントとしては、ParameterizedTestとなっており、todoService.saveの振る舞いをisSuccessで指定
mockMvc.performの結果確認もisSuccessで分岐して、失敗と成功のテストを実装しているところとなります。

    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun `save test`(isSuccess: Boolean) {
        val requestTodo = Todo(null, "todo5", "5content", toTimestamp(LIMIT_TIME))

        //todoServiceのsave(todo)が呼び出されるとisSuccessの値が返却されるように振る舞いを指定
        every { todoService.save(todo = any<Todo>()) } returns isSuccess

        //リクエストパラメータを準備
        val params = LinkedMultiValueMap<String, String>()
        params.add("title", requestTodo.title)
        params.add("content", requestTodo.content)
        params.add("limittime", LIMIT_TIME)

        if (isSuccess) {
            //todoService.saveの結果がtrueの場合は/top/listへリダイレクトされることを確認
            mockMvc.perform(post("/save").params(params))
                .andExpect(status().is3xxRedirection)
                .andExpect(redirectedUrl("/top/list"))
                .andReturn()
        } else {
            //todoService.saveの結果がfalseの場合は/inputFormががviewとなり、modelにerrorMessage="登録失敗"が設定されている事を確認
            val mvcResult = mockMvc.perform(post("/save").params(params))
                .andExpect(status().isOk)
                .andExpect(view().name("/inputForm"))
                .andExpect(model().attribute("errorMessage", "登録失敗")).andReturn()

            //リクエストパラメータがtodoにマッピングされていることを確認
            val todo = mvcResult.modelAndView!!.modelMap["todo"] as Todo
            assertEquals(requestTodo.title, todo.title)
            assertEquals(requestTodo.content, todo.content)
            assertEquals(requestTodo.limittime, todo.limittime)
        }

        //todoService.saveが一度だけ呼び出されたことを確認
        verify(exactly = 1) { todoService.save(todo = any<Todo>()) }
    }

次はsave test using flashAttrです。
'save test'とリクエストパラメータの扱いが異なるところが違うだけです。
flashAttrを指定するとオブジェクトからリクエストパラメタが作成できるので変更に強くなります。

    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun `save test using flashAttr`(isSuccess: Boolean) {
        val requestTodo = Todo(null, "todo5", "5content", toTimestamp(LIMIT_TIME))

        every { todoService.save(requestTodo) } returns isSuccess

        if (isSuccess) {
            val mvcResult = mockMvc.perform(post("/save").flashAttr("todo", requestTodo))
                .andExpect(status().is3xxRedirection)
                .andExpect(redirectedUrl("/top/list"))
                .andReturn()
        } else {
            val mvcResult = mockMvc.perform(post("/save").flashAttr("todo", requestTodo))
                .andExpect(status().isOk)
                .andExpect(view().name("/inputForm"))
                .andExpect(model().attribute("errorMessage", "登録失敗")).andReturn()

            val todo = mvcResult.modelAndView!!.modelMap["todo"] as Todo
            assertEquals(requestTodo, todo)
        }

        verify(exactly = 1) { todoService.save(requestTodo) }
    }

TodoServiceTest

TodoService#saveのテストを追加します。
@ParameterizedTestのパラメタのisSuccessでtodoRepository.saveの振る舞いを分岐させて、todoService.saveの成功時と失敗時のテストを行っています。

    @ParameterizedTest
    @ValueSource(strings = ["true", "false"])
    fun `test save todo`(isSuccess: Boolean) {
        val todo = Todo(1, "title", "content", toTimestamp("2021/03/20"))

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

        assertEquals(isSuccess, todoService.save(todo))

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

更新機能

更新機能を作成していきます。

更新機能の実装

TodoService

findByIdメソッドを追加します。TodoRepositoryはorg.springframework.data.repository.CrudRepositoryを継承しているので、TodoRepositoryの変更は不要です。

    fun findById(id: Int): Optional<Todo> {
        return todoRepository.findById(id)
    }

EditController

EditController#inputFormを以下のように変更します。

    @GetMapping("/inputForm")
    fun inputForm(@ModelAttribute todo: Todo, model: Model): String {
        if (todo.id != null) {
            //パラメータのidで検索
            val resultTodo = todoService.findById(todo.id!!)
            if (resultTodo != null) {
                //検索結果が存在する場合はmodelに"todo"の属性名でセット
                model.addAttribute("todo", resultTodo.get())
            }
        }
        return "/inputForm"
    }

新規登録時にはtodo.idがnullになります。更新時はtodo.id != nullになるので、todo.idでDBを検索し、結果をmodelに追加しています。

list.htm

list.htmlに/inputFormへの遷移のリンクを追加します。

<!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>

    <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="@{/inputForm/(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>

html全部では変更点が分かりにくいので、diffも記載いたします。

list.htmの変更点
- <td th:text="*{title}"></td>
+ <td><a th:href="@{/inputForm/(id=*{id})}" th:text="*{title}"></a></td>

これで一覧のタイトルから/inputFormへのリンクが張られます。
inputFormlink.png

inputForm.htmlは変更しないでOKで、保存処理も既存のEditController#saveで充足可能です。
以上で更新機能の追加は完了です。

ってそんなわけねーーーー、更新時のidの扱いが漏れていますが、このまま進めます。

更新機能のテストの実装

TodoServiceTest

TodoService#findByIdのテストを追加します。TodoService#saveメソッドは変更ありませんので修正は不要です。

    @Test
    fun `testFindById`() {
        val id = 1111
        val todo = Optional.of(Todo(id, "todo100", "100content", toTimestamp(
            "2021/03/20"
        )))

        every { todoRepository.findById(id) } returns todo

        val findByIdResult = todoService.findById(id)

        assertEquals(todo,findByIdResult )
        verify(exactly = 1) { todoRepository.findById(id) }
    }

新たな要素としては、todoRepository.findByIdの結果がTodoのOptionalでないといけないぐらいでしょうか。

EditControllerTest

EditController#inputFormのid指定時のテストを追加します。

    @Test
    fun `inputForm edit test`() {
        val requestTodo = Todo(100, null, null, null)
        val findByIdResult = Optional.of(Todo(100, "todo100", "100content", toTimestamp(LIMIT_TIME)))

        every { todoService.findById(requestTodo.id!!) } returns findByIdResult

        val mvcResult = mockMvc.perform(get("/inputForm").flashAttr("todo", requestTodo))
            .andExpect(view().name("/inputForm"))
            .andExpect(status().isOk)
            .andReturn()

        val todo = mvcResult.modelAndView!!.modelMap["todo"] as Todo
        assertEquals(findByIdResult.get(), todo)

        verify(exactly = 1) { todoService.findById(requestTodo.id!!) }

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

パラメータで飛んできたidで検索して、検索結果がmodelMapに登録されていることを確認しています。
assertFileEqualsでmvcResult.response.contentAsStringと期待値ファイルの比較も行っています。

editForm.txt

パスはC:/workspace/todoList/src/test/resources/com/example/todoList/controller/editForm.txtとなります。
editForm.txtは以下の通りです。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Todo Input Form</title>
</head>
<body>
<div class="container">
    <h1>create todo</h1>

    <form action="/save" method="post">
        <label>title:</label>
        <input type="text" id="title" name="title" value="todo100" />
        <br/>

        <label>content:</label>
        <input type="text" id="content" name="content" value="100content" />
        <br/>

        <label>limittime(yyyy/mm/dd):</label>
        <input type="text" id="limittime" name="limittime" value="2021/04/20" /><br/>
        <button type="submit">登録</button>
    </form>
</div>
</body>
</html>

うんうん、findByIdResultの値が反映されていますね!

更新機能を動かして確認

gradleタスクのbootRunからアプリを起動して確認してみます。

更新がおかしい.gif
更新なのに「create todo」となっているのはまあ置いといて・・・
期待通りに更新ではなく、新規登録されていますね!、ってどこが期待通りじゃーーー!!

更新ではなく新規登録される点の修正

修正方法案としては

  • inputForm.htmlにidをhiddenで埋め込むようにする。
  • 更新対象のtodoをセッションに保存する。

の2つが考えられます。

今回は後者で対応いたします。セッションを利用するとブラウザの戻るボタン対応も考慮しないといけませんので、inputForm.htmlに「戻る」ボタンを追加します。
(いまいち日本語の流れおかしいですが・・・)

EditController

更新対象のtodoをセッションに保存します。
inputFormメソッドは新規登録のエントリーポイントとし、editFormを追加し、更新処理のエントリーポイントとします。

package com.example.todoList.controller

import com.example.todoList.entity.Todo
import com.example.todoList.service.TodoService
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.bind.annotation.SessionAttributes
import org.springframework.web.bind.support.SessionStatus


@Controller
@SessionAttributes("todo")
class EditController @Autowired constructor(private val todoService: TodoService) {
    @ModelAttribute("todo")
    fun setupTodo(): Todo? {
        return Todo(null, null, null, null)
    }

    @GetMapping("/inputForm")
    fun inputForm(@ModelAttribute todo: Todo, model: Model): String {
        //更新画面->ブラウザの戻るボタン->新規登録で更新対象のtodoが表示されるのでクリア
        todo.id == null
        todo.title = null
        todo.content = null
        todo.limittime = null
        return "/inputForm"
    }

    @GetMapping("/editForm")
    fun editForm(@ModelAttribute todo: Todo, model: Model): String {
        if (todo.id != null) {
            //パラメータのidで検索
            val resultTodo = todoService.findById(todo.id!!)
            if (resultTodo != null) {
                //検索結果が存在する場合はmodelに"todo"の属性名でセット
                model.addAttribute("todo", resultTodo.get())
            }
        } else {
            //パラメータのidが飛んで来なかった場合はtodoの値をクリア、
            todo.id == null
            todo.title = null
            todo.content = null
            todo.limittime = null
        }
        return "/inputForm"
    }

    @PostMapping("/save")
    fun save(
        @ModelAttribute todo: Todo,
        model: Model, sessionStatus: SessionStatus
    ): String {
        return if (todoService.save(todo)) {
            //@SessionAttributes("todo")をクリアして/top/listにリダイレクト
            sessionStatus.setComplete();
            "redirect:/top/list";
        } else {
            model.addAttribute("errorMessage", if (todo.id == null) "登録失敗" else "更新失敗")
            "/inputForm";
        }
    }

    @GetMapping("/backToList")
    fun backToList(sessionStatus: SessionStatus): String {
        //@SessionAttributes("todo")をクリアして/top/listにリダイレクト
        sessionStatus.setComplete();
        return "redirect:/top/list";
    }
}
@SessionAttributes("todo")
class EditController @Autowired constructor(private val todoService: TodoService) {
    @ModelAttribute("todo")
    fun setupTodo(): Todo? {
        return Todo(null, null, null, null)
    }

でtodoがセッションに保持されるようになります。厳密にはEditControllerだけの限定ですが

inputFormメソッドを新規登録画面のエントリーポイントに変更したので、セッションに保持されているtodoの値をクリアします。

            //パラメータのidが飛んで来なかった場合はtodoの値をクリア、更新画面->ブラウザの戻るボタン->新規登録で更新対象のtodoが表示されるので
            todo.id == null
            todo.title = null
            todo.content = null
            todo.limittime = null

コメントに記載している通りなのですが、いったん任意のtodoを更新画面で開いたあと、ブラウザの戻るボタンで戻ってから新規登録画面に行くと、更新画面で開いていたtodoオブジェクトがセッションに残っているので、todoをクリアする必要があります。

追加したeditFormメソッドの説明をさせていただきます。

    @GetMapping("/editForm")
    fun editForm(@ModelAttribute todo: Todo, model: Model): String {
        if (todo.id != null) {
            //パラメータのidで検索
            val resultTodo = todoService.findById(todo.id!!)
            if (resultTodo != null) {
                //検索結果が存在する場合はmodelに"todo"の属性名でセット
                model.addAttribute("todo", resultTodo.get())
            }
        } else {
            //パラメータのidが飛んで来なかった場合はtodoの値をクリア、
            todo.id == null
            todo.title = null
            todo.content = null
            todo.limittime = null
        }
        return "/inputForm"
    }

更新処理のエントリーポイントですので、todo.idは飛んでくる想定ですが、idがnullのときはクリアしています。
現状はidしか飛んでこないのでほぼ意味のない分岐ですが・・・

次にsaveメソッドの説明となります。

    @PostMapping("/save")
    fun save(
        @ModelAttribute todo: Todo,
        model: Model, sessionStatus: SessionStatus
    ): String {
        return if (todoService.save(todo)) {
            //@SessionAttributes("todo")をクリアして/top/listにリダイレクト
            sessionStatus.setComplete();
            "redirect:/top/list";
        } else {
            model.addAttribute("errorMessage", if (todo.id == null) "登録失敗" else "更新失敗")
            "/inputForm";
        }
    }

引数にsessionStatus: SessionStatusを追加して、保存が成功した場合は、sessionStatus.setComplete()を呼び出し、セッションに保持されたtodoをクリアしています。登録失敗時は/inputFormでtodoの値を表示する必要があるのでクリアしていません。

リダイレクト先の/top/listは別コントローラーなので、セッションに保持されたtodoは関係ないのですが、このタイミングでクリアしないと整合性(ブラウザの戻るボタンの処理は除く)が取れなくなります。

最後に、inputForm.htmlに追加する「戻る」ボタンのアクションとなります。

    @GetMapping("/backToList")
    fun backToList(sessionStatus: SessionStatus): String {
        //@SessionAttributes("todo")をクリアして/top/listにリダイレクト
        sessionStatus.setComplete();
        return "redirect:/top/list";
    }

「戻る」ボタンのクリックの処理でいきなり/top/listを開いてしまうと、セッションに保持されたtodoが残りますので、いったんEditController#backToListで受けて、sessionStatus.setComplete()した後、/top/listにリダイレクトしています。

inputForm.html

「戻る」ボタンを追加しています。
新規登録時は"create todo"と更新時は"update todo"が表示されるように変更しています。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <title>Todo Input Form</title>
</head>
<body>
<div class="container">
    <h1 th:if="${todo.id == null}">create todo</h1>
    <h1 th:if="${todo.id != null}">update todo</h1>

    <form action="#" th:action="@{/save}" method="post" th:object="${todo}">
        <label>title:</label>
        <input type="text" th:field="*{title}" />
        <br/>

        <label>content:</label>
        <input type="text" th:field="*{content}" />
        <br/>

        <label>limittime(yyyy/mm/dd):</label>
        <input type="text" id="limittime" name="limittime" th:value="${todo.limittime} ? ${#dates.format(todo.limittime, 'yyyy/MM/dd')}" /><br/>
        <button type="submit">登録</button>
        <input type="button" onclick="location.href='./../backToList'" value="戻る">
    </form>
</div>
</body>
</html>

list.html

更新画面へのリンクのURLをinputFormからeditFormに変更しています。

list.htmの変更点
- <td><a th:href="@{/inputForm/(id=*{id})}" th:text="*{title}"></a></td>
+ <td><a th:href="@{/editForm/(id=*{id})}" th:text="*{title}"></a></td>

更新ではなく新規登録される点の修正のテスト

とはいえ、これでは足りないんですよね・・・、本質的な点がテストで検証できていません。まあサンプルアプリなので・・・

EditControllerTest

EditControllerTestのinputForm edit testeditForm testに変更し、getで指定しているパスを/editFormから/inputFormに変更します。

    @Test
    fun `editForm test`() {
        val requestTodo = Todo(100, null, null, null)
        val findByIdResult = Optional.of(Todo(100, "todo100", "100content", toTimestamp(LIMIT_TIME)))

        every { todoService.findById(requestTodo.id!!) } returns findByIdResult

        val mvcResult = mockMvc.perform(get("/editForm").flashAttr("todo", requestTodo))
            .andExpect(view().name("/inputForm"))
            .andExpect(status().isOk)
            .andReturn()

        val todo = mvcResult.modelAndView!!.modelMap["todo"] as Todo
        assertEquals(findByIdResult.get(), todo)

        verify(exactly = 1) { todoService.findById(requestTodo.id!!) }

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

htmlを変更しているので、コントローラーのテスト内のassertFileEqualsの検証が失敗するようになっていますので、期待値ファイルを修正します。
修正するときにIntelliJのエディタから修正すると自動でフォーマットされ、何度修正してもテストが通らない。との現象に遭遇してしまいますので、外部エディタで修正ください。

editForm.txt

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Todo Input Form</title>
</head>
<body>
<div class="container">

    <h1>update todo</h1>

    <form action="/save" method="post">
        <label>title:</label>
        <input type="text" id="title" name="title" value="todo100" />
        <br/>

        <label>content:</label>
        <input type="text" id="content" name="content" value="100content" />
        <br/>

        <label>limittime(yyyy/mm/dd):</label>
        <input type="text" id="limittime" name="limittime" value="2021/04/20" /><br/>
        <button type="submit">登録</button>
        <input type="button" onclick="location.href='./../backToList'" value="戻る">
    </form>
</div>
</body>
</html>

expectedListResult.txt

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <script type="text/javascript" src="/jquery-3.6.0.min.js"></script>
    <script type="text/javascript" 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 href="/inputForm">新規登録</a>

    <div class="row">
        <table id="todoList">
            <thead>
            <tr>
                <td>ID</td>
                <td>タイトル</td>
                <td>内容</td>
                <td>期限</td>
            </tr>
            </thead>
            <tbody>
            <tr>
                <td>1</td>
                <td><a href="/editForm/?id=1">todo1</a></td>
                <td>1content</td>
                <td>2021/04/18</td>
            </tr>
            <tr>
                <td>2</td>
                <td><a href="/editForm/?id=2">todo2</a></td>
                <td>2content</td>
                <td>2021/04/19</td>
            </tr>
            </tbody>
        </table>
    </div>
</div>
</body>
</html>

inputForm.txt

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Todo Input Form</title>
</head>
<body>
<div class="container">
    <h1>create todo</h1>


    <form action="/save" method="post">
        <label>title:</label>
        <input type="text" id="title" name="title" value="" />
        <br/>

        <label>content:</label>
        <input type="text" id="content" name="content" value="" />
        <br/>

        <label>limittime(yyyy/mm/dd):</label>
        <input type="text" id="limittime" name="limittime" value="" /><br/>
        <button type="submit">登録</button>
        <input type="button" onclick="location.href='./../backToList'" value="戻る">
    </form>
</div>
</body>
</html>

これでtodoの更新も正しく動作し、テストも通るようになるはずです。

バリデーション

バリデーションを追加していきます。

バリデーションの追加

Todo

titleにNotBlankとSize(max = 10)
contentにSize(max = 20)
を設定します。

package com.example.todoList.entity

import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.sql.Timestamp
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size

@Table(value = "todo")
    data class Todo(
    @Id
    @Column("id")
    var id: Int?,
    @field:NotBlank
    @field: Size(max = 10)
    @Column("title")
    var title: String?,
    @field: Size(max = 20)
    @Column("content")
    var content: String?,
    @Column("limittime")
    var limittime: Timestamp?
)

EditController

EditController#saveメソッドでTodoのバリデーションを有効にします。

    @PostMapping("/save")
    fun save(
        @ModelAttribute @Valid todo: Todo, bindingResult: BindingResult,
        model: Model, sessionStatus: SessionStatus
    ): String {
        if (bindingResult.hasErrors()) {
            return "/inputForm";
        }

        return if (todoService.save(todo)) {
            //@SessionAttributes("todo")をクリアして/top/listにリダイレクト
            sessionStatus.setComplete();
            "redirect:/top/list";
        } else {
            model.addAttribute("errorMessage", if (todo.id == null) "登録失敗" else "更新失敗")
            "/inputForm";
        }
    }
        @ModelAttribute @Valid todo: Todo, bindingResult: BindingResult,

todoに@Validを付与し、todoの後にbindingResult: BindingResultを追加しています。
BindingResultは、バリデーション対象の引数の直後でないと動作しないので注意が必要です。

        if (bindingResult.hasErrors()) {
            return "/inputForm";
        }

バリデーションで問題があれば、bindingResult.hasErrors()がtrueになるので、エラーとなった時点で"/inputForm"を返却するように変更しております。

inputForm.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <title>Todo Input Form</title>
</head>
<body>
<div class="container">
    <h1 th:if="${todo.id == null}">create todo</h1>
    <h1 th:if="${todo.id != null}">update todo</h1>

    <form action="#" th:action="@{/save}" method="post" th:object="${todo}">
        <label>title:</label>
        <input type="text" th:field="*{title}" />
        <span th:if="${#fields.hasErrors('title')}" th:errors="*{title}"/>
        <br/>

        <label>content:</label>
        <input type="text" th:field="*{content}" />
        <span th:if="${#fields.hasErrors('content')}" th:errors="*{content}"/>
        <br/>

        <label>limittime(yyyy/mm/dd):</label>
        <input type="text" id="limittime" name="limittime" th:value="${todo.limittime} ? ${#dates.format(todo.limittime, 'yyyy/MM/dd')}" /><br/>
        <button type="submit">登録</button>
        <input type="button" onclick="location.href='./../backToList'" value="戻る">
    </form>
</div>
</body>
</html>

titleのインプットの後に

<span th:if="${#fields.hasErrors('title')}" th:errors="*{title}"/>

contentのインプットの後に

<span th:if="${#fields.hasErrors('content')}" th:errors="*{content}"/>

をそれぞれ追加しています。これでバリデーション失敗時のエラーメッセージが表示されるようになります。

バリデーションのテスト追加

ValitationResultTestUtil

新規となります。バリデーションのテストで利用するユーティリティクラスです。

package com.example.todoList

import javax.validation.ConstraintViolation

//expectedErrorMessageがnot nullの場合は、validationResultにpropertyPath=expectedPropertyPathでmessage=expectedErrorMessageの結果が含まれる場合true
//expectedErrorMessageがnullの場合は、validationResultにpropertyPath=expectedPropertyPathの結果が含まれない場合true
//それ以外はfalseを返却します。
fun <T> includePropertyPathMessage(
    expectedPropertyPath: String,
    expectedErrorMessage: String?,
    validationResult: MutableSet<ConstraintViolation<T>>?
): Boolean {
    if (validationResult != null) {
        return if (expectedErrorMessage == null) {
            !validationResult.stream().anyMatch { it -> it.propertyPath.toString() == expectedPropertyPath }
        } else {
            validationResult.stream()
                .anyMatch { it -> it.propertyPath.toString() == expectedPropertyPath && it.message == expectedErrorMessage }
        }
    }
    return false;
}

TodoTest

新規となります。Todoのテストクラスです。

package com.example.todoList.entity

import com.example.todoList.includePropertyPathMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.boot.test.context.SpringBootTest
import java.util.stream.Stream
import javax.validation.Validation
import javax.validation.Validator

@SpringBootTest
class TodoTest {
    companion object {
        private var validator: Validator? = null

        @JvmStatic
        @BeforeAll
        fun beforeAll() {
            validator = Validation.buildDefaultValidatorFactory().validator
        }

        @JvmStatic
        fun validateTodoTitleSource(): Stream<Arguments>? {
            val blankErrorMessage = "空白は許可されていません"
            return Stream.of(
                Arguments.of("", blankErrorMessage),
                Arguments.of(" ", blankErrorMessage),
                Arguments.of("12345678901", "0 から 10 の間のサイズにしてください"),
                Arguments.of("1234567890", null),
                Arguments.of("title", null)
            );
        }

        @JvmStatic
        fun validateTodoContentSource(): Stream<Arguments>? {
            return Stream.of(
                Arguments.of("123456789012345678901", "0 から 20 の間のサイズにしてください"),
                Arguments.of("12345678901234567890", null),
                Arguments.of("", null),
                Arguments.of(" ", null)
            );
        }
    }

    @ParameterizedTest
    @MethodSource("validateTodoTitleSource")
    fun `validate todo title`(inputTitle: String, expectedErrorMessage: String?) {
        val todo = Todo(null, inputTitle, "", null)
        var violationResult = validator?.validate(todo)

        //expectedErrorMessageがnullはバリデーションチェックにひっかからないのでsize=0、それ以外はsize=1が期待値
        assertEquals(if (expectedErrorMessage != null) 1 else 0, violationResult!!.size)
        assertTrue(includePropertyPathMessage("title", expectedErrorMessage, violationResult))
    }

    @ParameterizedTest
    @MethodSource("validateTodoContentSource")
    fun `validate todo content`(inputContent: String, expectedErrorMessage: String?) {
        val todo = Todo(null, "title", inputContent, null)
        var violationResult = validator?.validate(todo)

        //expectedErrorMessageがnullはバリデーションチェックにひっかからないのでsize=0、それ以外はsize=1が期待値
        assertEquals(if (expectedErrorMessage != null) 1 else 0, violationResult!!.size)
        assertTrue(includePropertyPathMessage("content", expectedErrorMessage, violationResult))
    }
}
    companion object {
        private var validator: Validator? = null

        @JvmStatic
        @BeforeAll
        fun beforeAll() {
            validator = Validation.buildDefaultValidatorFactory().validator
        }

で静的メンバとしてvalidatorを宣言し、BeforeAllでValidation.buildDefaultValidatorFactory().validatorで初期化しています。
これでvalidatorを利用することで、バリデーションのテストが行えるようになります。

validate todo titlevalidate todo contentともにParameterizedTestのMethodSource指定で、バリデーションのバリエーションテストを実現しています。

Todo.titleはNotBlankかつSize(max = 10)ですので

        fun validateTodoTitleSource(): Stream<Arguments>? {
            val blankErrorMessage = "空白は許可されていません"
            return Stream.of(
                Arguments.of("", blankErrorMessage),
                Arguments.of(" ", blankErrorMessage),
                Arguments.of("12345678901", "0 から 10 の間のサイズにしてください"),
                Arguments.of("1234567890", null),
                Arguments.of("title", null)
            );
        }

とのバリエーションとなります。

Todo.contentはSize(max = 20)ですので

        fun validateTodoContentSource(): Stream<Arguments>? {
            return Stream.of(
                Arguments.of("123456789012345678901", "0 から 20 の間のサイズにしてください"),
                Arguments.of("12345678901234567890", null),
                Arguments.of("", null),
                Arguments.of(" ", null)
            );
        }

とのバリエーションとなります。

validate todo titleの説明をもう少しさせていただきます。

    @ParameterizedTest
    @MethodSource("validateTodoTitleSource")
    fun `validate todo title`(inputTitle: String, expectedErrorMessage: String?) {
        val todo = Todo(null, inputTitle, "", null)
        var violationResult = validator?.validate(todo)

        //expectedErrorMessageがnullはバリデーションチェックにひっかからないのでsize=0、それ以外はsize=1が期待値
        assertEquals(if (expectedErrorMessage != null) 1 else 0, violationResult!!.size)
        assertTrue(includePropertyPathMessage("title", expectedErrorMessage, violationResult))
    }

具体的な動作例として、inputTitle="12345678901", expectedErrorMessage="0 から 10 の間のサイズにしてください"
の呼び出し時は

assertEquals(1, violationResult!!.size)

の検証が行われます。

同様に

includePropertyPathMessage("title", "0 から 10 の間のサイズにしてください", violationResult)

の結果がtrueになるので

assertTrue(includePropertyPathMessage("title", expectedErrorMessage, violationResult))

は成功します。

EditControllerTest

EditControllerTestにtodoがバリデートされている事を確認するだけを追加します。
テストの命名方法がグダグダすぎで性格が推し量れて怖いですね・・・

    @Test
    fun `todoがバリデトされている事を確認するだけ`() {
        //TodoTestでバリデーションのテストのバリエーションを考慮しているのでエラーになる事だけ確認
        val requestTodo = Todo(null, "", "5content", toTimestamp(LIMIT_TIME))
        val resultActions = mockMvc.perform(post("/save").flashAttr("todo", requestTodo))
        resultActions.andExpect(model().errorCount(1));
    }

TodoTestでテストのバリエーションは網羅(カバレッジ見てないのでほんとかどうかは不明)していますので、ここでは

    @PostMapping("/save")
    fun save(
        @ModelAttribute @Valid todo: Todo, bindingResult: BindingResult,
        model: Model, sessionStatus: SessionStatus
    ): String {

の書き方が妥当で、バリデーション失敗時にbindingResultに結果が正しく反映されることのみ確認しています。

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

この時点での全ソースは
Spring Boot + KotlinでサーバーサイドKotlin実践入門 その2完了版のGitHubリポジトリ
に登録済みです。

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