[Day 29] Kotlin + Spring Boot : TDD in CRUD

今天主要要透過不專業的 TDD 來做 CRUD 的開發(盡量??)!

設定 H2 DB

因為我是使用 H2 DB 來 demo 這次專案, H2 可以透過在 resources 下放入,schema.sql 以及 data.sql 來初始化 H2 DB

https://ithelp.ithome.com.tw/upload/images/20201008/20129902gyDNI4YWS0.png

schema.sql

DROP TABLE IF EXISTS user;

CREATE TABLE user (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(300) NOT NULL,
    password VARCHAR(300) NOT NULL,
    email VARCHAR(100) DEFAULT NULL
)

data.sql

INSERT INTO user(password, username, email) VALUES('1234', 'tim', 'tim@gmail.com');

如果已經定義好了 entity 也可以透過 jpa.generate-ddl = true 來自動產生 db schema。

這裡我是關掉的,因為目前是透過 schema.sql 產生的

spring:
  datasource:
    ## db 資訊
    url: jdbc:h2:mem:testdb
    driverClassName: org.h2.Driver
    username: sa
    password: 1qaz2wsx
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    ## console 中 顯示 jpa 產生的 sql
    show-sql: true
    ## generate-ddl 是否透過 entity 產生 db schema
    generate-ddl: false
    ## Hibernate ddl auto (create, create-drop, update), 是否隨著 entity 更新,update 保留 data
    hibernate:
      ddl-auto: update
  ## 啟動 http://localhost:8080/h2-console  h2 db 管理介面
  h2:
    console:
      enabled: true

啟動 server 後,網址進入 http://localhost:8080/h2-console ,會看到登入畫面,輸入帳密後就可以看到 table 和資料啦~

https://ithelp.ithome.com.tw/upload/images/20201008/20129902blWxuhNtRS.png

或是透過 IntelliJ IDEA 的 database 連線也可以

https://ithelp.ithome.com.tw/upload/images/20201008/20129902qPScEz2CP9.png

Entity

這裡先定義了一個簡單版的 User.kt 的 Entity

@Entity
@Table
class User(
        var username: String,
        var password: String,
        var email: String? = null,
        @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) var id: Long? = null)

Spring Data JPA 不建議使用 data class

Entity 這塊是官方文件跟其他網路文章最大差異的地方,官方是特別建議針對 Spring Data JPA 不建議使用 data class

https://ithelp.ithome.com.tw/upload/images/20201008/201299025ANfdOGVsp.png

內文提到 JPA 並不是為了 immutable class 或 data class 產生的方法設計的,但如果是使用 Spring Data MongoDB, Spring Data JDBC 等等使用 data class 是沒問題的。

由 Kotlin Koans tutorial 的影片教學裡面是提到說,因為像是 null 其實在 JPA 也代表很重要的 trasient 狀態,代表資料還未跟 db 映射,所以不推薦使用 data class,不過...好像大多數狀況下,是沒啥問題啦...XD

關於 JPA 的狀態可以參考這篇: https://jstobigdata.com/jpa/different-states-of-an-object-in-jpa/

TDD

db 和 Entity 建立好後,遵循 TDD 的開發方式,當然是先來寫測試,本人也是剛入門 TDD 有錯請指教阿~。

這裡開始就會做 client 端的測試,比較偏向整合測試,所以這裡是使用 TestRestTemplate 來測試 RESTful API。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class UserRestControllerTest(@Autowired val restTemplate: TestRestTemplate) {

    @Test
    internal fun `test findByUsername `() {
        val user = restTemplate.getForEntity<List<User>>("/api/user/tim")

        // 測試 200 ok
        assertEquals(HttpStatus.OK, user.statusCode)

        // 測試 header MediaType.APPLICATION_JSON
        user.headers.contentType?.let {
            assertTrue(it == MediaType.APPLICATION_JSON)
        } ?: fail("Content type header is not application/json")

        // 測試名字
        assertEquals("tim", user.body?.first()?.username)
    }
}

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 使用了任意的 port 來測試,避免跟已經開啟的 server port 衝突,這樣就寫好了第一個測試。

紅燈

當然來跑一下,是不會通過測試的

https://ithelp.ithome.com.tw/upload/images/20201008/20129902ecGmgrSqUr.png

程式實作

Repository

首先利用 JPA gen code 的方式,來實作 UserRepository 的內容,特別注意,這裡不需要加上 @Repository 的 annotaion 了!其實我之前都還一直傻傻的加上XD

這篇有解釋:https://stackoverflow.com/questions/51918181/why-isnt-necessary-repository-for-this-spring-boot-web-app

interface UserRepository : JpaRepository<User, Long> {

    fun findByUsername(username: String): List<User>

}

Service

UserService 要標註 @Service,透過 constructor 注入 UserRepository。

這裡只是簡單了呼叫 UserRepository 的方法,但將來還有更多的商業邏輯會寫,所以還是保留 service class。

@Service
class UserService(val userRepository: UserRepository) {

    fun findByUsername(username: String): List<User> = userRepository.findByUsername(username)

UserRestController

UserRestController 要標註 @RestController,透過 constructor 注入 UserService。

這裡採用了 @GetMapping 提供 get 方法, 參數透過 url 帶入,所以使用 @PathVariable 帶入這個參數後,在程式內呼叫 service.findByUsername(username)

這樣就完成了!

@RestController
@RequestMapping("/api/user")
class UserRestController(val service: UserService) {

    @GetMapping("/{username}")
    fun findByUsername(@PathVariable username: String): ResponseEntity<List<User>> =
            ResponseEntity.ok(service.findByUsername(username))按下測試

測試失敗!

按下 internal fun test findByUsername() 左邊的綠色按鈕測試,對...我發現測試失敗,所以一樣按左邊的綠色按鈕的 debug 按鈕和下中斷點在測試 200 ok 那行,觀察一下拿到的 user 會長怎樣

https://ithelp.ithome.com.tw/upload/images/20201008/20129902Dz5Ri60mlF.png

https://ithelp.ithome.com.tw/upload/images/20201008/20129902JIzV0nd78B.png

會一直出現這個錯誤

class java.util.LinkedHashMap cannot be cast to class com.tim.kotlinspring.entity.User (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; com.tim.kotlinspring.entity.User is in unnamed module of loader 'app')
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.tim.kotlinspring.entity.User (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; com.tim.kotlinspring.entity.User is in unnamed module of loader 'app')
at com.tim.kotlinspring.controllers.UserRestControllerTest.test findByUsername $kotlin_spring(UserRestControllerTest.kt:41)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at .........

debug 後我是把程式修改成這樣了

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class UserRestControllerTest(@Autowired val restTemplate: TestRestTemplate): Logging {

    private val objectMapper = ObjectMapper()

    @Test
    internal fun `test findByUsername `() {
        val response = restTemplate.getForEntity<List<User>>("/api/user/tim")

        // 測試 200 ok
        assertEquals(HttpStatus.OK, response.statusCode)

        // 測試 header MediaType.APPLICATION_JSON
        response.headers.contentType?.let {
            assertTrue(it == MediaType.APPLICATION_JSON)
        } ?: fail("Content type header is not application/json")

        // 測試名字
        val userList: List<User>? = objectMapper.convertValue(
                response.body,
                object : TypeReference<List<User>>() {}
        )
        log().info(userList.toString())

        assertEquals("tim", userList?.getOrNull(0)?.username)
    }
}

因為在測試名字這裡只寫這樣 val userList: List<User>? = response.body 的話,Jackson 會無法順利轉型,所以 List 會反序列化成 LinkedHashMap (如 debug 裡面顯示的型態),所以後來多加了 private val objectMapper = ObjectMapper() 指定型態來轉型,也就是這段

        // 測試名字
        val userList: List<User>? = objectMapper.convertValue(
                response.body,
                object : TypeReference<List<User>>() {}
        )
        log().info(userList.toString())

這問題是參考這裡解決的

https://stackoverflow.com/questions/28821715/java-lang-classcastexception-java-util-linkedhashmap-cannot-be-cast-to-com-test

https://ithelp.ithome.com.tw/upload/images/20201008/20129902M7MvN5qszl.png

logging in Spring Kotin

另外因為 debug 的過程中,讓我覺得資訊不夠,所以我還手動加了 log。

以往在 Java 會使用 lombok 提供的 @Slf4j 或 @Log4j2,但在 Kotlin 中因為不能用 lombok 了,所以就要自己加。

參考這篇的話 https://www.baeldung.com/kotlin-logging

目前最漂亮的解法就是做一個 logging 的 interface,其他 class 去繼承,就可以順順的使用了!

import org.slf4j.LoggerFactory.getLogger

interface Logging {

    fun <T : Logging> T.log() = getLogger(javaClass)
}

像是這樣使用,繼承了 : Logging {

使用是這樣log().info(userList.toString())

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class UserRestControllerTest(@Autowired val restTemplate: TestRestTemplate): Logging {

    private val objectMapper = ObjectMapper()

    @Test
    internal fun `test findByUsername `() {
			....
        
				log().info(userList.toString())
			
			....
    }
}

綠燈

修正完後,就測試成功啦!

https://ithelp.ithome.com.tw/upload/images/20201008/20129902jMOio35KNS.png

我有把其他的方法都完成,但沒有寫測試

今天就先講到這!我們明天見!

今日程式碼在這