はじめに
「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では、一覧とタイトルの検索まで終わっています。
一覧、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も記載いたします。
- <td th:text="*{title}"></td>
+ <td><a th:href="@{/inputForm/(id=*{id})}" th:text="*{title}"></a></td>
これで一覧のタイトルから/inputFormへのリンクが張られます。
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からアプリを起動して確認してみます。
更新なのに「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に変更しています。
- <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 test
をeditForm 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 title
、validate 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リポジトリ
に登録済みです。