LoginSignup
7
7

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-04-30

はじめに

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」では、一覧とタイトルの検索までを扱います。

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

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

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

プロジェクトの準備

Spring InitializrでSpring Bootアプリケーションのひな型を作成

Spring Initializrにアクセスして
springinitializr.png
のように選択し、Generateをクリックします。totoList.zipが落ちてきますので、これを任意の場所に解凍します。
以降では、C:\workspace\totoListを解凍後に作成されたパスとします。

intellijでプロジェクトをオープン

intellijを起動し、[File]-[Open...]をクリックします。
selectproject1.png

C:\workspace\totoListを選択してOKクリックします。
selectproject.png

以下のダイアログが表示されますので「Trust Project」をクリックします。
confirmTrust.png

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]をクリックします。
resources作成.png
New Directoryダイアログでresourcesを入力、リターンキーを押下しディレクトリを作成します。

必要リソースの準備

まずはアプリに必要なリソースファイルを作成します。

以下のような一覧になるようにファイルを追加してください。application.propertiesはSpring Initializrが作成済みです。
リソースの一覧.png

各ファイルの中身を以下のように変更してください。

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...']をクリックします。
TodoListApplicationTest実行.png

結果が既に先ほどの画像で出ていますが
TodoListApplicationTest実行結果.png
のようになればテスト成功です。

一覧機能

いよいよ機能の作成です。第一弾として一覧機能のみを作成します。

この時点での一覧機能のイメージは以下のようになります。
一覧機能のみ.png
期限の表示形式が、どんだけーーー

一覧機能の実装

構成するファイル一覧は以下の通りです。
list2.png

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と文字列の相互変換のユーティリティクラスとなります。

一覧機能のテストの実装

構成するファイル一覧は以下の通りです。
list3.png

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() {の中身ですが

TopControllerTest
        //todoService.findTodoList()が返却さる結果を生成
        val mockResultTodoList = generateMockResultTotoList(false)

        //todoService.findTodoList()でmockResultTodoListが返却されるようにする。
        every { todoService.findTodoList() } returns mockResultTodoList
TestBase
    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)の結果を分岐するようにしています。特に意味ないですが・・・

TopControllerTest
        // /top/listをgetし、結果ステータスがOKであることを検証し、
        // andReturn()でMvcResultを取得しておく。
        val mvcResult = mockMvc.perform(get("/top/list"))
            .andExpect(status().isOk)
            .andReturn()

で/top/listにgetリクエストを送信し、結果スタータスが200になることを検証し、mvcResultを取得しています。

TopControllerTest
        //レスポンスを文字列で取り出してexpectedListResult.txtと比較する。
        //mvcResult.response.contentAsStringは途中で切られる事あるので無条件にはおすすめできないですが、
        //長さが短いレスポンスのみ期待値ファイルと比較するとのやり方は、テストの実装効率、変更への強さの両面からおすすめです。
        assertFileEquals("expectedListResult.txt",
            mvcResult.response.contentAsString)

mvcResultからresponseを取り出して文字列に変換、文字列とexpectedListResult.txtのファイルの中身を比較しています。
コメントにも記載しているのですが、mvcResult.response.contentAsStringはテストを連続して実行していると途中で切れる。というかmockMvcが長いので省略することが頻発します。どこで切れるかはバイト数などで決まっているわけではないですので、前方一致での確認も困難です。そんなこんなでテストの再現性が乏しくなりますにので注意してください。

ファイルにするとテストの期待値の管理が容易になります。また、gitの履歴の比較を行うことで、プロダクトコードの動作がどう変わったかを視覚的に見ることができるようになるので、この点もおすすめできるポイントです。テストが失敗したときの結果確認イメージは以下のようになります。
assertFileEquals.gif
このように異なる部分が直感的に理解しやすくなり、Actualがあるべき結果であれば、Actualの結果をコピーして、期待値のファイルに張り付けるだけでテストをあるべき姿に補正できます。

assertFileEqualsの実装は何の工夫もないです。

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"
    }

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が参照するオブジェクトを取り出し、その妥当性を検証するようにします。

TopControllerTest
        //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指定の完全一致での検証を行っています。

TopControllerTest
        //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)
    }
}
TodoRepositoryTest
class TodoRepositoryTest @Autowired constructor(val todoRepository: TodoRepository){

実際のTodoRepositoryを利用してのテストですので、コンストラクタインジェクションでTodoRepositoryをインジェクションしています。

TodoRepositoryTest
    @DatabaseSetup(value = ["/com/example/todoList/findAllBaseData"])

をテストメソッドに付与する事で
C:/workspace/todoList/src/test/resources/com/example/todoList/findAllBaseData
のtable-ordering.txtに記載しているテーブルに対応するCSVファイルが順番にDBにロードされます。

table-ordering.txt
todo

実際には、todoの行に対応するC:/workspace/todoList/src/test/resources/com/example/todoList/findAllBaseData/todo.csv

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()を呼び出して、期待値と比較するだけとなります。

TodoRepositoryTest
        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を返却するようにします。

Todo
@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を利用するようにします。

list.html
            <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を利用する方法

list.html
            <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]をダブルクリックしアプリを起動します。
bootrun1.png

右ペインのGradleが表示されていない場合は[View]-[Tool Windows]-[Gradle]で表示できます。
gradle.png

アプリの停止は停止ボタンを押せばOKです。
bootrun.gif

ブラウザから
http://localhost:8080/top/list
にアクセスするとtodo listが見えるはずです。

titleの検索機能

次はtitleの部分一致の検索機能の作成です。
変更後の/top/listは以下のようになります。
search.png

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で付与して呼び出す前提となります。

TodoRepository#findByTitle
    @Query("SELECT * FROM todo WHERE title LIKE :title")
    fun findByTitle(title: String): MutableIterable<Todo>

TodoService

findTodoListByTitleメソッドを追加します。

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のテストが失敗するようになっていますので修正します。
見切れてますが、イメージはつかんでいただけると思います。
差分1.png

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

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