はじめに
Googleが2019年に推奨言語として、Kotlinファーストを押し出してから「Android Kotlin」は広く知られる固有名詞であると言えます。
同様にSpring Bootの2系からKotlin対応が始まったこともあり、「サーバーサイド Kotlin」も結構な浸透度だと感じています。
2021年時点でのKotlinの最大の強みは、「Javaとの完全互換になっているJVM言語」であることだと感じております。
既存のJava資産をKotlinから普通にimportして利用することが可能です。OSSなども同様ですので
"Javaでいいじゃん!"とのたまう方の説得も比較的容易かと思います。
書いたすぐでなんですが、こんなメリットは数年でほぼなくなると思います。それほどKotlinの潜在能力は高いと言えますし、旧来のJavaのレガシー臭は強いと感じています。
開発環境はIntelliJとなります。
本投稿の目的
「サーバーサイド Kotlin」の実装方法に実践的なテストの実装を交えて説明することで、「サーバーサイド Kotlin」のすばらしさの一端を感じていただくことを目的としておりますが、意図した内容に仕上がっているかいまいち自信ないので、コメントいただけると幸いです。
作成するアプリ
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 InitializrでSpring Bootアプリケーションのひな型を作成
Spring Initializrにアクセスして
のように選択し、Generateをクリックします。totoList.zipが落ちてきますので、これを任意の場所に解凍します。
以降では、C:\workspace\totoListを解凍後に作成されたパスとします。
intellijでプロジェクトをオープン
intellijを起動し、[File]-[Open...]をクリックします。
C:\workspace\totoListを選択してOKクリックします。
以下のダイアログが表示されますので「Trust Project」をクリックします。
build.gradle.ktsを編集
以下のように変更してください。
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.4.5"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
war
kotlin("jvm") version "1.4.32"
kotlin("plugin.spring") version "1.4.32"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
runtimeOnly("com.h2database:h2")
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude(module = "mockito-core")
}
implementation("org.thymeleaf.extras:thymeleaf-extras-java8time:3.0.4.RELEASE")
implementation("org.springframework.boot:spring-boot-starter-validation")
testImplementation("org.assertj", "assertj-core", "3.19.0")
testImplementation("io.mockk:mockk:1.11.0")
testImplementation("com.ninja-squad:springmockk:3.0.1")
testImplementation("commons-io:commons-io:2.8.0")
testImplementation("org.dbunit:dbunit:2.7.0")
testImplementation("com.github.springtestdbunit:spring-test-dbunit:1.3.0")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
sourceSets {
test {
//assertFileEqualsで読み込むファイルを$buildDir/classes/kotlin/testを基準パスとするために
// test/resourcesのファイルを$buildDir/classes/kotlin/testに出力するように変更
output.resourcesDir = File("$buildDir/classes/kotlin/test")
}
}
test/resourcesディレクトリの作成
testを選択後に右クリックメニューから[New]-[Directory]をクリックします。
New Directoryダイアログでresourcesを入力、リターンキーを押下しディレクトリを作成します。
必要リソースの準備
まずはアプリに必要なリソースファイルを作成します。
以下のような一覧になるようにファイルを追加してください。application.propertiesはSpring Initializrが作成済みです。
各ファイルの中身を以下のように変更してください。
application.properties
spring.datasource.initialization-mode=always
spring.datasource.continue-on-error=true
# spring.datasource.schema=classpath:schema.sql
# spring.datasource.data=classpath:data.sql
アプリ起動時のDB初期化を常に行う。初期化が失敗しても無視して継続する。
との設定となります。
Spring Bootのデフォルト設定を明確化するために、敢えて値を記載してコメントアウトしているのですが、
初期化に利用するSQLは
クラスパス上のschema.sql(C:\workspace\todoList\src\main\resources\schema.sql)
クラスパス上のdata.sql(C:\workspace\todoList\src\main\resources\data.sql)
となります。
application.yml
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false
username: sa
password:
利用するH2DBをインメモリで動かす設定となります。
-
DB_CLOSE_DELAY = -1
インメモリで動作する場合は、コネクションが切断されとDBがクリアされ、テーブルなどが消えてしまいますので
DB_CLOSE_DELAY = -1でJAVA VM起動中はDBコネクションを閉じてもテーブルが削除されないようにします。 -
DATABASE_TO_UPPER=false
schema.sqlでテーブルを作成するときにH2DBはテーブル名を大文字にしてしまうようで、小文字のテーブル名でSQLを発行すると存在するのに"Table not found"となりますので、設定しておくとトラブりにくいです。
schema.sql
CREATE TABLE todo
(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(20) NOT NULL,
content VARCHAR(100) NOT NULL,
limittime TIMESTAMP NOT NULL
);
data.sql
INSERT INTO todo(id, title, content, limittime) VALUES(1, 'todo1', 'todo1 content', '2021-04-18 14:41:05.0');
INSERT INTO todo(id, title, content, limittime) VALUES(2, 'todo2', 'todo2 content', '2021-04-19 14:41:05.0');
INSERT INTO todo(id, title, content, limittime) VALUES(3, 'todo3', 'todo3 content', '2021-04-20 14:41:05.0');
INSERT INTO todo(id, title, content, limittime) VALUES(4, 'todo4', 'todo4 content', '2021-04-20 15:41:05.0');
INSERT INTO todo(id, title, content, limittime) VALUES(5, 'todo10', 'todo10 content', '2021-04-20 16:41:05.0');
INSERT INTO todo(id, title, content, limittime) VALUES(6, 'todo11', 'todo11 content', '2021-04-21 16:41:05.0');
本来であれば、data.sqlにはマスタデータのみ格納し、トランザクションデータは格納しないのですが、アプリを起動後の動作確認の容易さのためにこのようにしております。
テストを実行
このタイミングで一度テストを実行してみます。
TodoListApplicationTestを選択し、右クリックメニューから[Run 'TodoListApplicationT...']をクリックします。
結果が既に先ほどの画像で出ていますが
のようになればテスト成功です。
一覧機能
いよいよ機能の作成です。第一弾として一覧機能のみを作成します。
この時点での一覧機能のイメージは以下のようになります。
期限の表示形式が、どんだけーーー
一覧機能の実装
TopController
http://localhost:8080/top/list
にアクセスしたときのコントローラーです。
package com.example.todoList.controller
import com.example.todoList.form.TodoListForm
import com.example.todoList.service.TodoService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.servlet.ModelAndView
@Controller
@RequestMapping("/top")
class TopController @Autowired constructor(private val todoService: TodoService) {
/**
* /top/listにgetでアクセスするとlist.htmlが返却される。
* modelAndViewのmodelMapに属性名:todoListFormでTodoListFormをセットします。
* TodoListFormのtodoListにはtodoService.findTodoList()をセットしています。
*/
@GetMapping("list")
fun list(): ModelAndView =
ModelAndView("/list").apply {
addObject(
"todoListForm",
TodoListForm(todoService.findTodoList())
)
}
}
TodoListForm
package com.example.todoList.form
import com.example.todoList.entity.Todo
data class TodoListForm(val todoList:MutableIterable<Todo>?)
TodoListFormはkotlinのデータクラスとなっています。ほんとデータクラスは便利極まりないです。継承がうまくできるようになれば最強だと思います。
データクラスの詳細につきましては
[Kotlin]クラスとデータクラスの違いを理解する(class, data class)
が参考になると思います。
Todo
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
@Table(value = "todo")
data class Todo(
@Id
@Column("id")
var id: Int?,
@Column("title")
var title: String?,
@Column("content")
var content: String?,
@Column("limittime")
var limittime: Timestamp?
)
Todoもデータクラスで、todoテーブルに対応する「Spring Data JDBC」のエンティティクラスとなります。
TodoService
package com.example.todoList.service
import com.example.todoList.entity.Todo
import com.example.todoList.repository.TodoRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
@Service
class TodoService @Autowired constructor(private val todoRepository: TodoRepository) {
fun findTodoList(): MutableIterable<Todo> {
return todoRepository.findAll()
}
}
TodoServiceはSpringのServiceクラスとなります。
コンストラクタインジェクションでTodoRepositoryをインジェクションしています。
findTodoListメソッドはTodoRepository#findAllの結果を返却するだけの実装となります。
TodoRepository
package com.example.todoList.repository
import com.example.todoList.entity.Todo
import org.springframework.data.jdbc.repository.query.Query
import org.springframework.data.repository.CrudRepository
interface TodoRepository : CrudRepository<Todo, Int> {
}
CrudRepositoryを継承しています。対象エンティティはTodoで主キーオブジェクトがIntとの宣言です。
この記述だけでtodoテーブルに対する基本的なCRUDが利用可能となります。
詳細はCrudRepositoryのJAVADOCを参照ください。
list.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<title>Todo List</title>
</head>
<body>
<div class="container">
<h1>todo list</h1>
<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 th:text="*{title}"></td>
<td th:text="*{content}"></td>
<td th:text="*{limittime}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
todoListFormのtodoListをループし、各値をそのまま表示するのみとなります。だから期限の値がTimestamp#toStringになります。
DateUtil
package com.example.todoList
import java.sql.Timestamp
import java.text.SimpleDateFormat
fun toTimestamp(timestampString: String): Timestamp {
val simpleDateFormat = SimpleDateFormat("yyyy/MM/dd")
return Timestamp(simpleDateFormat.parse(timestampString).time)
}
fun timestampToString(timestamp: Timestamp): String {
val simpleDateFormat = SimpleDateFormat("yyyy/MM/dd")
return simpleDateFormat.format(timestamp)
}
Timestampと文字列の相互変換のユーティリティクラスとなります。
一覧機能のテストの実装
TopControllerTest
TopControllerのテストは以下の通りです。
package com.example.todoList.controller
import com.example.todoList.TestBase
import com.example.todoList.form.TodoListForm
import com.example.todoList.service.TodoService
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.groups.Tuple.tuple
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.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.status
@ExtendWith(SpringExtension::class)
@WebMvcTest(TopController::class)
class TopControllerTest : TestBase() {
@Autowired
private lateinit var mockMvc: MockMvc
//todoServiceをMockkBeanとしてインジェクション
//これでTopControllerで利用されるTodoServiceがモック化されます。
@MockkBean
private lateinit var todoService: TodoService
//TopController#listのテスト
@Test
fun `default list`() {
//todoService.findTodoList()が返却さる結果を生成
val mockResultTodoList = generateMockResultTotoList(false)
//todoService.findTodoList()でmockResultTodoListが返却されるようにする。
every { todoService.findTodoList() } returns mockResultTodoList
// /top/listをgetし、結果ステータスがOKであることを検証し、
// andReturn()でMvcResultを取得しておく。
val mvcResult = mockMvc.perform(get("/top/list"))
.andExpect(status().isOk)
.andReturn()
//レスポンスを文字列で取り出してexpectedListResult.txtと比較する。
//mvcResult.response.contentAsStringは途中で切られる事あるので無条件にはおすすめできないですが、
//長さが短いレスポンスのみ期待値ファイルと比較するとのやり方は、テストの実装効率、変更への強さの両面からおすすめです。
assertFileEquals("expectedListResult.txt",
mvcResult.response.contentAsString)
//viewの中身の確認は別の方法で実施する。との方針であればmvcResultからtodoListForm.todoListを取り出して検証で良いと思います。
val todoListForm = mvcResult.modelAndView!!.modelMap["todoListForm"] as TodoListForm
//assertThat(todoListForm.todoList)のサイズを検証
assertThat(todoListForm.todoList).hasSize(2)
//todoListForm.todoListをAssertJのextractingで取り出し、containsExactlyのtuple指定で
//要素が順序も含め完全に一致することを検証しています。
assertThat(todoListForm.todoList).extracting("id", "title", "content", "limittime")
.containsExactly(
tuple(mockResultTodoList[0].id, mockResultTodoList[0].title, mockResultTodoList[0].content, mockResultTodoList[0].limittime),
tuple(mockResultTodoList[1].id, mockResultTodoList[1].title, mockResultTodoList[1].content, mockResultTodoList[1].limittime)
)
//todoService.findTodoList()が呼び出された回数(1)を検証
verify(exactly = 1) { todoService.findTodoList() }
}
}
TopControllerTestの親クラスのTestBaseは以下の通りです。
package com.example.todoList
import com.example.todoList.entity.Todo
import org.apache.commons.io.FileUtils
import org.junit.jupiter.api.Assertions.assertEquals
import java.io.File
import java.net.URL
import java.nio.charset.StandardCharsets
open class TestBase {
/**
* 指定ファイルの中身を期待値としてactualと一致するか検証します。
* ファイルのパスはcom.example.todoList.controller.TopControllerからfileName="expectedListResult.txt"
* で呼び出された場合はbuildディレクトリの配下のclasses/kotlin/test/com/example/todoList/controller/expectedListResult.txt
* となります。
*/
fun assertFileEquals(expectedResultFileName: String, actual: String) {
val expectedResult = readText(expectedResultFileName)
assertEquals(expectedResult, actual)
}
/**
* com.example.todoList.controller.TopControllerからfileName="expectedListResult.txt"
* で呼び出された場合はbuildディレクトリの配下のclasses/kotlin/test/com/example/todoList/controller/expectedListResult.txt
* を読み込んで内容の文字列を返却します。
*/
private fun readText(fileName: String): String {
val url = this.javaClass.getResource(".")
val fileFullPath = url.path + File.separator + fileName
val targetFile = File(fileFullPath)
if(targetFile.exists()) {
return FileUtils.readFileToString(targetFile, StandardCharsets.UTF_8);
}
return "not exist fileName:$fileName"
}
/**
* モックが返却するMutableList<Todo>を生成して返却します。
*/
fun generateMockResultTotoList(isSearch:Boolean): MutableList<Todo> {
return if(isSearch) {
mutableListOf(
Todo(5, "todo5", "5content", toTimestamp("2021/04/20")),
Todo(6, "todo6", "6content", toTimestamp("2021/04/21"))
)
}else {
mutableListOf(
Todo(1, "todo1", "1content", toTimestamp("2021/04/18")),
Todo(2, "todo2", "2content", toTimestamp("2021/04/19"))
)
}
}
}
TopControllerTestの説明をさせていただきます。
@WebMvcTest(TopController::class)
と記載することで、テスト対象のコントローラーがTopControllerであることをMockMvcが認識してくれます。
@Autowired
private lateinit var mockMvc: MockMvc
でMockMvcを宣言しています。これでControllerのユニットテストが行えるようになります。
@MockkBean
private lateinit var todoService: TodoService
「Springmockk」を利用しているので、@MockkBeanアノテーションを付与してtodoServiceを宣言すことで、todoServiceをモック化できます。
fun default list
() {の中身ですが
//todoService.findTodoList()が返却さる結果を生成
val mockResultTodoList = generateMockResultTotoList(false)
//todoService.findTodoList()でmockResultTodoListが返却されるようにする。
every { todoService.findTodoList() } returns mockResultTodoList
fun generateMockResultTotoList(isSearch:Boolean): MutableList<Todo> {
return if(isSearch) {
mutableListOf(
Todo(5, "todo5", "5content", toTimestamp("2021/04/20")),
Todo(6, "todo6", "6content", toTimestamp("2021/04/21"))
)
}else {
mutableListOf(
Todo(1, "todo1", "1content", toTimestamp("2021/04/18")),
Todo(2, "todo2", "2content", toTimestamp("2021/04/19"))
)
}
}
generateMockResultTotoList(false)でmockResultTodoList:MutableListを作成し、todoService.findTodoList()を呼び出した時の結果がmockResultTodoListとなるようにモックの振る舞いを定義しています。
generateMockResultTotoListですが、todoのtitleの検索機能を後で実装するので、検索条件なし(isSearch=false)と検索条件あり(isSearch=true)の結果を分岐するようにしています。特に意味ないですが・・・
// /top/listをgetし、結果ステータスがOKであることを検証し、
// andReturn()でMvcResultを取得しておく。
val mvcResult = mockMvc.perform(get("/top/list"))
.andExpect(status().isOk)
.andReturn()
で/top/listにgetリクエストを送信し、結果スタータスが200になることを検証し、mvcResultを取得しています。
//レスポンスを文字列で取り出してexpectedListResult.txtと比較する。
//mvcResult.response.contentAsStringは途中で切られる事あるので無条件にはおすすめできないですが、
//長さが短いレスポンスのみ期待値ファイルと比較するとのやり方は、テストの実装効率、変更への強さの両面からおすすめです。
assertFileEquals("expectedListResult.txt",
mvcResult.response.contentAsString)
mvcResultからresponseを取り出して文字列に変換、文字列とexpectedListResult.txtのファイルの中身を比較しています。
コメントにも記載しているのですが、mvcResult.response.contentAsStringはテストを連続して実行していると途中で切れる。というかmockMvcが長いので省略することが頻発します。どこで切れるかはバイト数などで決まっているわけではないですので、前方一致での確認も困難です。そんなこんなでテストの再現性が乏しくなりますにので注意してください。
ファイルにするとテストの期待値の管理が容易になります。また、gitの履歴の比較を行うことで、プロダクトコードの動作がどう変わったかを視覚的に見ることができるようになるので、この点もおすすめできるポイントです。テストが失敗したときの結果確認イメージは以下のようになります。
このように異なる部分が直感的に理解しやすくなり、Actualがあるべき結果であれば、Actualの結果をコピーして、期待値のファイルに張り付けるだけでテストをあるべき姿に補正できます。
assertFileEqualsの実装は何の工夫もないです。
/**
* 指定ファイルの中身を期待値としてactualと一致するか検証します。
* ファイルのパスはcom.example.todoList.controller.TopControllerからfileName="expectedListResult.txt"
* で呼び出された場合はbuildディレクトリの配下のclasses/kotlin/test/com/example/todoList/controller/expectedListResult.txt
* となります。
*/
fun assertFileEquals(expectedResultFileName: String, actual: String) {
val expectedResult = readText(expectedResultFileName)
assertEquals(expectedResult, actual)
}
/**
* com.example.todoList.controller.TopControllerからfileName="expectedListResult.txt"
* で呼び出された場合はbuildディレクトリの配下のclasses/kotlin/test/com/example/todoList/controller/expectedListResult.txt
* を読み込んで内容の文字列を返却します。
*/
private fun readText(fileName: String): String {
val url = this.javaClass.getResource(".")
val fileFullPath = url.path + File.separator + fileName
val targetFile = File(fileFullPath)
if(targetFile.exists()) {
return FileUtils.readFileToString(targetFile, StandardCharsets.UTF_8);
}
return "not exist fileName:$fileName"
}
val url = this.javaClass.getResource(".")
で取得したurlを基準に指定ファイルを読み込んでActualと比較しているだけです。コードとしてはそんな感じなのですが
build.gradle.ktsに
sourceSets {
test {
//assertFileEqualsで読み込むファイルを$buildDir/classes/kotlin/testを基準パスとするために
// test/resourcesのファイルを$buildDir/classes/kotlin/testに出力するように変更
output.resourcesDir = File("$buildDir/classes/kotlin/test")
}
}
を記載して、this.javaClass.getResource(".")のパスとtest/resources配下のファイルの出力先を同じにする必要があります。
expectedListResult.txtは以下のようになります。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Todo List</title>
</head>
<body>
<div class="container">
<h1>todo list</h1>
<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>todo1</td>
<td>1content</td>
<td>2021-04-18 00:00:00.0</td>
</tr>
<tr>
<td>2</td>
<td>todo2</td>
<td>2content</td>
<td>2021-04-19 00:00:00.0</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
assertFileEqualsではviewの結果を含めて確認したのですが、viewはSeleniumなどで確認するとの方針であれば、modelMapからviewが参照するオブジェクトを取り出し、その妥当性を検証するようにします。
//viewの中身の確認は別の方法で実施する。との方針であればmvcResultからtodoListForm.todoListを取り出して検証で良いと思います。
val todoListForm = mvcResult.modelAndView!!.modelMap["todoListForm"] as TodoListForm
//assertThat(todoListForm.todoList)のサイズを検証
assertThat(todoListForm.todoList).hasSize(2)
//todoListForm.todoListをAssertJのextractingで取り出し、containsExactlyのtuple指定で
//要素が順序も含め完全に一致することを検証しています。
assertThat(todoListForm.todoList).extracting("id", "title", "content", "limittime")
.containsExactly(
tuple(mockResultTodoList[0].id, mockResultTodoList[0].title, mockResultTodoList[0].content, mockResultTodoList[0].limittime),
tuple(mockResultTodoList[1].id, mockResultTodoList[1].title, mockResultTodoList[1].content, mockResultTodoList[1].limittime)
)
assertJほんと便利です。extractingでフィールド名指定の抽出からの、containsExactlyでtuple指定の完全一致での検証を行っています。
//todoService.findTodoList()が呼び出された回数(1)を検証
verify(exactly = 1) { todoService.findTodoList() }
最後に、todoService.findTodoList()が1回だけ呼び出されたことを確認しています。
TodoServiceTest
TodoServiceのテストは以下の通りです。
package com.example.todoList.service
import com.example.todoList.TestBase
import com.example.todoList.repository.TodoRepository
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import org.assertj.core.api.Assertions
import org.assertj.core.groups.Tuple
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.text.SimpleDateFormat
class TodoServiceTest : TestBase(){
lateinit var todoService: TodoService
@MockK
private lateinit var todoRepository: TodoRepository
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
todoService = TodoService(todoRepository)
}
@Test
fun `testFindTodoList`() {
val mockResultTodoList = generateMockResultTotoList(false)
every { todoRepository.findAll() } returns mockResultTodoList
val todoResultList = todoService.findTodoList()
Assertions.assertThat(todoResultList).extracting("id", "title", "content", "limittime")
.containsExactly(
Tuple.tuple(mockResultTodoList[0].id, mockResultTodoList[0].title, mockResultTodoList[0].content, mockResultTodoList[0].limittime),
Tuple.tuple(mockResultTodoList[1].id, mockResultTodoList[1].title, mockResultTodoList[1].content, mockResultTodoList[1].limittime)
)
verify(exactly = 1) { todoRepository.findAll() }
}
}
ポイントは
lateinit var todoService: TodoService
@MockK
private lateinit var todoRepository: TodoRepository
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
todoService = TodoService(todoRepository)
}
TodoServiceは、コンストラクタインジェクションでTodoRepositoryをセットする必要があるので、
TodoServiceのコンストラクタに対して、MockKが生成したtodoRepositoryモックを指定して呼び出す部分となります。
これでtodoServiceから見たtodoRepositoryがモック化されます。
残りの部分は、モックがTodoServiceからtodoRepositoryに変わっただけで、TopControllerTestの'default list'の後半部分と同じ内容となります。
TodoRepositoryTest
TodoRepositoryのテストは以下の通りです。
package com.example.todoList.repository
import com.example.todoList.CsvDataSetLoader
import com.github.springtestdbunit.TransactionDbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.groups.Tuple.tuple
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.support.DirtiesContextTestExecutionListener
import org.springframework.transaction.annotation.Transactional
@SpringBootTest
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader::class)
@TestExecutionListeners(
DependencyInjectionTestExecutionListener::class,
DirtiesContextTestExecutionListener::class,
TransactionDbUnitTestExecutionListener::class)
@Transactional
class TodoRepositoryTest @Autowired constructor(val todoRepository: TodoRepository){
@Test
@DatabaseSetup(value = ["/com/example/todoList/findAllBaseData"])
fun findAll() {
val todoResultList1 = todoRepository.findAll()
assertThat(todoResultList1).extracting("id", "title", "content")
.containsExactly(
tuple(100, "todo100", "todo100 content"),
tuple(200, "todo200", "todo200 content"))
}
}
DBUnitを利用して、H2DBにデータをセットアップしてテストを行っています。
DBUnitのデータ形式はCSVとなります。この処理は
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader::class)
で指定しています。
CsvDataSetLoaderは以下の通りです。
package com.example.todoList
import com.github.springtestdbunit.dataset.AbstractDataSetLoader
import org.dbunit.dataset.IDataSet
import org.dbunit.dataset.csv.CsvDataSet
import org.springframework.core.io.Resource
class CsvDataSetLoader : AbstractDataSetLoader() {
override fun createDataSet(resource: Resource?): IDataSet {
if (resource != null) {
return CsvDataSet(resource.file)
}
return CsvDataSet(null)
}
}
class TodoRepositoryTest @Autowired constructor(val todoRepository: TodoRepository){
実際のTodoRepositoryを利用してのテストですので、コンストラクタインジェクションでTodoRepositoryをインジェクションしています。
@DatabaseSetup(value = ["/com/example/todoList/findAllBaseData"])
をテストメソッドに付与する事で
C:/workspace/todoList/src/test/resources/com/example/todoList/findAllBaseData
のtable-ordering.txtに記載しているテーブルに対応するCSVファイルが順番にDBにロードされます。
todo
実際には、todoの行に対応するC:/workspace/todoList/src/test/resources/com/example/todoList/findAllBaseData/todo.csv
"id","title", "content", "limittime"
"100","todo100","todo100 content","2021-04-18 14:41:05.0"
"200","todo200","todo200 content","2021-04-19 14:41:05.0"
がDBにロードされます。
あとは、todoRepository.findAll()を呼び出して、期待値と比較するだけとなります。
val todoResultList1 = todoRepository.findAll()
assertThat(todoResultList1).extracting("id", "title", "content")
.containsExactly(
tuple(100, "todo100", "todo100 content"),
tuple(200, "todo200", "todo200 content"))
一覧機能の期限の形式をyyyy/MM/ddに変更
どう考えても期限の表示形式がいけてなさすぎですので、yyyy/MM/ddに変更します。
後ほどtitleの検索結果をjsonで返却するコントローラーを作成しますので、jsonにも効く方法から説明します。
Todoのgetterでフォーマットして返却する方法
Todoにgetterを作成し、DateUtil#timestampToStringで値を変換し、Stringを返却するようにします。
@Table(value = "todo")
data class Todo(
@Id
@Column("id")
var id: Int?,
@Column("title")
var title: String?,
@Column("content")
var content: String?,
@Column("limittime")
var limittime: Timestamp?
)
{
val limittimeText: String
get() = if (limittime != null) timestampToString(limittime!!) else ""
}
list.htmlでlimittimeTextを利用するようにします。
<tbody>
<tr th:each="todo:${todoListForm.todoList}" th:object="${todo}">
<td th:text="*{id}"></td>
<td th:text="*{title}"></td>
<td th:text="*{content}"></td>
<td th:text="*{limittimeText}"></td>
</tr>
</tbody>
Thymeleafのdates.formatを利用する方法
<tbody>
<tr th:each="todo:${todoListForm.todoList}" th:object="${todo}">
<td th:text="*{id}"></td>
<td th:text="*{title}"></td>
<td th:text="*{content}"></td>
<td th:text="${todo.limittime} ? ${#dates.format(todo.limittime, 'yyyy/MM/dd')}"></td>
</tr>
</tbody>
のようにdates.formatでyyyy/MM/dd形式に変換します。
todo.limittimeがnullの場合でも異常終了しないように、Thymeleafのconditional expressionsでtodo.limittimeがnot nullの場合のみフォーマットするようにしています。
dates.formatを利用するためにbuild.gradle.ktsに
implementation("org.thymeleaf.extras:thymeleaf-extras-java8time:3.0.4.RELEASE")
を記載する必要があります。
動かしてみる
正しく実装できてるかを確認するためにアプリを動かしてみます。
[todoList]-[Tasks]-[application]-[bootRun]をダブルクリックしアプリを起動します。
右ペインのGradleが表示されていない場合は[View]-[Tool Windows]-[Gradle]で表示できます。
ブラウザから
http://localhost:8080/top/list
にアクセスするとtodo listが見えるはずです。
titleの検索機能
次はtitleの部分一致の検索機能の作成です。
変更後の/top/listは以下のようになります。
titleの検索機能の実装
関連するファイルですが、
新規が
src/main/kotlin/com/example/todoList/controller/SearchController.kt
src/main/resources/static/jquery-3.6.0.min.js
src/main/resources/static/searchTodo.js
jquery-3.6.0.min.jsはjqueryのサイトからダウンロードしたものを上記パスに格納してください。
変更が
src/main/kotlin/com/example/todoList/form/TodoListForm
src/main/kotlin/com/example/todoList/controller/TopController.kt
src/main/kotlin/com/example/todoList/repository/TodoRepository
src/main/kotlin/com/example/todoList/service/TodoService
src/main/resources/templates/list.html
となります。
変更ソースから説明いたします。
TodoListForm
検索条件を保持するsearchCondTitle:String?を追加します。
data class TodoListForm(val todoList:MutableIterable<Todo>?, var searchCondTitle:String?)
TopController
TodoListFormにsearchCondTitle:String?を追加したので、listメソッドで呼び出しているTodoListFormのコンストラクタ呼び出しの引数を追加する必要があります。
@GetMapping("list")
fun list(): ModelAndView =
ModelAndView("/list").apply {
addObject(
"todoListForm",
TodoListForm(todoService.findTodoList(), null)
)
}
TodoRepository
findByTitleメソッドを追加します。LIKE検索で必要な%はTodoService#findTodoListByTitleで付与して呼び出す前提となります。
@Query("SELECT * FROM todo WHERE title LIKE :title")
fun findByTitle(title: String): MutableIterable<Todo>
TodoService
findTodoListByTitleメソッドを追加します。
fun findTodoListByTitle(searchCondTitle: String?): MutableIterable<Todo> {
return todoRepository.findByTitle("%$searchCondTitle%")
}
list.html
formを追加しています。追加したformの検索ボタンをクリックすると
非同期でhttp://localhost:8080/search
からjsonを取得し、tableに結果を表示します。
<!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 th:text="*{title}"></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>
ここから新規のファイルの説明となります。
searchTodo.js
/searchにformのデータをPOSTし、結果をid='todoList'のテーブルにtrタグで追加しています。
function searchTodo(){
var formData = $('form').serialize();
$.ajax({
type: "POST",
url: "/search",
data: formData
}).done(function(data, textStatus, jqXHR) {
if(!data){
return;
}
// 画面のtableタグの全てのtrタグを削除
$('#todoList').find("tr:gt(0)").remove();
let i = 0;
for(i = 0; i < data.length; i++){
let trTag = $("<tr />");
trTag.append($("<td></td>").text(decodeURI(data[i].id)));
trTag.append($("<td></td>").text(decodeURI(data[i].title)));
trTag.append($("<td></td>").text(decodeURI(data[i].content)));
trTag.append($("<td></td>").text(decodeURI(data[i].limittime)));
$('#todoList').append(trTag);
}
}).fail(function(jqXHR, textStatus, errorThrown ) {
alert("データが取得できませんでした");
});
}
SearchController
http://localhost:8080/search
に対応するコントローラーとなります。
package com.example.todoList.controller
import com.example.todoList.entity.Todo
import com.example.todoList.form.TodoListForm
import com.example.todoList.service.TodoService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class SearchController @Autowired constructor(private val todoService: TodoService) {
@PostMapping("/search")
fun search(todoListForm: TodoListForm): MutableIterable<Todo> {
return todoService.findTodoListByTitle(todoListForm.searchCondTitle)
}
}
ポイントとしては
- RestControllerとして宣言していること
- searchメソッドの戻り値がMutableIterable
<Todo
>となっていること
となります。これだけでsearchのレスポンスがjsonになります。
titleの検索機能のテスト
SearchControllerTest
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.mockk
import io.mockk.verify
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.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@ExtendWith(SpringExtension::class)
@WebMvcTest(SearchController::class)
class SearchControllerTest : TestBase() {
@Autowired
private lateinit var mockMvc: MockMvc
@MockkBean
private lateinit var todoService: TodoService
@Test
fun `search`() {
val mockResultTodoDtoList = generateMockResultTotoList(true)
val searchCondTitleValue = "searchCondTitleValue"
every { todoService.findTodoListByTitle(searchCondTitleValue) } returns mockResultTodoDtoList
mockMvc.perform(post("/search").param("searchCondTitle", searchCondTitleValue))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].id").value(mockResultTodoDtoList[0].id))
.andExpect(jsonPath("$[0].title").value(mockResultTodoDtoList[0].title))
.andExpect(jsonPath("$[0].content").value(mockResultTodoDtoList[0].content))
//.andExpect(jsonPath("$[0].limittime").value("2021/04/20"))
.andExpect(jsonPath("$[1].id").value(mockResultTodoDtoList[1].id))
.andExpect(jsonPath("$[1].title").value(mockResultTodoDtoList[1].title))
.andExpect(jsonPath("$[1].content").value(mockResultTodoDtoList[1].content))
//.andExpect(jsonPath("$[1].limittime").value("2021/04/21"))
verify(exactly = 1) { todoService.findTodoListByTitle(searchCondTitleValue) }
}
}
基本的なところはTopControllerTestと同じです。
復習となりますが、
@WebMvcTest(SearchController::class)
と記載することで、テスト対象のコントローラーがSearchControllerであることをMockMvcが認識してくれます。
違いとしては
- getがpostになっている。
- paramでsearchCondTitle=searchCondTitleValueを指定している。
- 結果のjsonPathで値を検証している。
部分であると言えます。
Timestampはそのままで検証できませんので、limittimeの検証はコメントにしております。
コメントを外してテストを成功するようにするには
- 「Todoのgetterでフォーマットして返却する方法」で説明した実装に変更し、andExpectでlimittimeTextを参照するようにする。
- application.ymlでjacksonのdateFormatを指定する。
の2つが考えられます。
application.ymlで対応する場合は、application.ymlを以下のように変更します。
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;DATABASE_TO_UPPER=false
username: sa
password:
jackson:
dateFormat: yyyy/MM/dd
time-zone: Asia/Tokyo
jacksonのdateFormatを指定することで、json変換時にTimestampやDateをyyyy/MM/dd形式の文字列に変換してくれるようになります。
当然なのですが、すべてのTimestampやDateに効くようになるので、このやり方では、実現したい仕様を充足できない場合も考えられます。
今回は後者で行きたいと思います。application.ymlを変更後に
limittimeの検証部分のコメントを解除するとテストが成功します。
@Test
fun `search`() {
val mockResultTodoDtoList = generateMockResultTotoList(true)
val searchCondTitleValue = "searchCondTitleValue"
every { todoService.findTodoListByTitle(searchCondTitleValue) } returns mockResultTodoDtoList
mockMvc.perform(post("/search").param("searchCondTitle", searchCondTitleValue))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].id").value(mockResultTodoDtoList[0].id))
.andExpect(jsonPath("$[0].title").value(mockResultTodoDtoList[0].title))
.andExpect(jsonPath("$[0].content").value(mockResultTodoDtoList[0].content))
.andExpect(jsonPath("$[0].limittime").value("2021/04/20"))
.andExpect(jsonPath("$[1].id").value(mockResultTodoDtoList[1].id))
.andExpect(jsonPath("$[1].title").value(mockResultTodoDtoList[1].title))
.andExpect(jsonPath("$[1].content").value(mockResultTodoDtoList[1].content))
.andExpect(jsonPath("$[1].limittime").value("2021/04/21"))
verify(exactly = 1) { todoService.findTodoListByTitle(searchCondTitleValue) }
}
TodoServiceTest
todoService#findTodoListByTitleのテストであるtestFindTodoListByTitleを追加します。
@Test
fun `testFindTodoListByTitle`() {
val mockResultTodoList = generateMockResultTotoList(true)
val searchCondTitle = "titleCond"
every { todoRepository.findByTitle("%$searchCondTitle%") } returns mockResultTodoList
val todoResultList = todoService.findTodoListByTitle(searchCondTitle)
Assertions.assertThat(todoResultList).extracting("id", "title", "content", "limittime")
.containsExactly(
Tuple.tuple(mockResultTodoList[0].id, mockResultTodoList[0].title, mockResultTodoList[0].content, mockResultTodoList[0].limittime),
Tuple.tuple(mockResultTodoList[1].id, mockResultTodoList[1].title, mockResultTodoList[1].content, mockResultTodoList[1].limittime)
)
verify(exactly = 1) { todoRepository.findByTitle("%$searchCondTitle%") }
}
新しい内容はないですので、説明は不要と思います。
TodoRepositoryTest
todoRepository.findByTitleのテストであるtestFindByTitleを追加します。
@Test
@DatabaseSetup(value = ["/com/example/todoList/findByTitleBaseData"])
fun testFindByTitle() {
val todoResultList1 = todoRepository.findByTitle("%odo%")
assertThat(todoResultList1).extracting("id", "title", "content")
.containsExactly(
tuple(1, "todo1", "todo1 content"),
tuple(2, "todo2", "todo2 content"),
tuple(3, "todo3", "todo3 content"),
tuple(4, "todo4", "todo4 content"),
tuple(5, "todo10", "todo10 content"))
val todoResultList2 = todoRepository.findByTitle("%odo1%")
assertThat(todoResultList2).extracting("id", "title", "content")
.containsExactly(
tuple(1, "todo1", "todo1 content"),
tuple(5, "todo10", "todo10 content"))
}
それにしても、同じようなテストですね・・・、変化が欲しい・・・
前提データは以下の通りですので作成をお願いいたします。
src/test/resources/com/example\todoList\findByTitleBaseData\table-ordering.txt
todo
src/test/resources/com/example\todoList\findByTitleBaseData\todo.csv
"id","title", "content", "limittime"
"1","todo1","todo1 content","2021-04-18 14:41:05.0"
"2","todo2","todo2 content","2021-04-19 14:41:05.0"
"3","todo3","todo3 content","2021-04-20 14:41:05.0"
"4","todo4","todo4 content","2021-04-20 15:41:05.0"
"5","todo10","todo10 content","2021-04-20 16:41:05.0"
expectedListResult.txtの修正
これでtitieの検索機能は完了なのですが、list.htmlの修正によりTopControllerTestのテストが失敗するようになっていますので修正します。
見切れてますが、イメージはつかんでいただけると思います。
src/test/resources/com/example/todoList/controller/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>
<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>todo1</td>
<td>1content</td>
<td>2021/04/18</td>
</tr>
<tr>
<td>2</td>
<td>todo2</td>
<td>2content</td>
<td>2021/04/19</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
これで全てのテストが成功するようになります。
以上で「Spring Boot + KotlinでサーバーサイドKotlin実践入門 その1」は終了となります。
import文が多かったり、テストコードのコメントが多かったりで、「サーバーサイドKotlin」の生産性の高さがいまいち強調できていない気がしますが、ホントにすごいんです。
この時点での全ソースは
Spring Boot + KotlinでサーバーサイドKotlin実践入門 その1完了版のGitHubリポジトリ
に登録済みです。