Kotlin、SpringBoot 和 Mockk 的 POST 方法出错

Error in POST method with Kotlin, SpringBoot and Mockk

当我使用 data class日期字段(LocalDate).

这是我正在使用的堆栈:

springBoot      : v2.1.7.RELEASE
Java            : jdk-11.0.4
kotlinVersion   : '1.3.70'
junitVersion    : '5.6.0'
junit4Version   : '4.13'
mockitoVersion  : '3.2.4'
springmockk     : '1.1.3'

当我在应用程序中执行 POST 方法时,一切正常,我有响应并且数据正确保存在数据库中:

curl -X POST "http://127.0.1.1:8080/v1/person/create" -H  "accept: */*" -H  "Content-Type: application/json" -d "[  {    \"available\": true,    \"endDate\": \"2090-01-02\",    \"hireDate\": \"2020-01-01\",    \"id\": 0,    \"lastName\": \"stringTest\",    \"name\": \"stringTest\",    \"nickName\": \"stringTest\"  }]"

但是当我尝试对 POST 方法进行测试时,我不能(仅使用 POST 方法,使用 GET 就可以)

这是我使用的 classes:

文件Person.kt

@Entity
data class Person(
            @Id @Column(name = "id") @GeneratedValue(strategy = GenerationType.AUTO)
            var id: Long,

            var name: String,
            var lastName: String,
            var nickName: String,
            @JsonFormat(pattern = "yyyy-MM-dd")
            var hireDate: LocalDate,
            @JsonFormat(pattern = "yyyy-MM-dd")
            var endDate: LocalDate,
            var available: Boolean
            ) {
            constructor()  : this(0L, "Name example",
                    "LastName example",
                    "Nick example",
                    LocalDate.of(2020,1,1),
                    LocalDate.of(2090,1,1),
                    true)

文件PersonService.kt

@Service
class PersonService(private val personRepository: PersonRepository) {

    fun findAll(): List<Person> {
        return personRepository.findAll()
    }

    fun saveAll(personList: List<Person>): MutableList<person>? {
        return personRepository.saveAll(personList)
    }
}

文件PersonApi.kt

@RestController
@RequestMapping("/v1/person/")
class PersonApi(private val personRepository: PersonRepository) {

    @Autowired
    private var personService = PersonService(personRepository)

    @PostMapping("create")
    fun createPerson(@Valid
                     @RequestBody person: List<Person>): ResponseEntity<MutableList<Person>?> {

        print("person: $person") //this is only for debug purpose only
        return ResponseEntity(personService.saveAll(person), HttpStatus.CREATED)
    }
}

最后

PersonApiShould.kt(这个class就是问题所在)

@EnableAutoConfiguration
@AutoConfigureMockMvc
@ExtendWith(MockKExtension::class)
internal class PersonApiShould {

    private lateinit var gsonBuilder: GsonBuilder
    private lateinit var gson: Gson
    lateinit var mockMvc: MockMvc

    @MockkBean
    lateinit var personService: PersonService

    @BeforeEach
    fun setUp() {
        val repository = mockk<PersonRepository>()
        personService = PersonService(repository)
        mockMvc = standaloneSetup(PersonApi(repository)).build()

        gson = GsonBuilder()
                .registerTypeAdapter(Person::class.java, PersonDeserializer())
                .create()
        gsonBuilder = GsonBuilder()
    }

    @AfterEach
    fun clear() {
        clearAllMocks()}

    @Test
    fun `create person`() {

         val newPerson = Person(1L, 
                "string",    //name
                "string",    //lastName   
                "string",    //nickname
                LocalDate.of(2020, 1, 1),    //hireDate
                LocalDate.of(2090, 1, 2),    //endDate
                true)    //available
        val contentList = mutableListOf<Person>()
        contentList.add(newPerson)

//        also tried with
//        every { personService.findAll() }.returns(listOf<Person>())
//        every { personService.saveAll(mutableListOf<Person>())}.returns(Person())

        every { personService.findAll() }.returns(contentList)
        every { personService.saveAll(any()) }.returns(contentList)


/*    didn't work either
       val personJson = gsonBuilder.registerTypeAdapter(Date::class.java, DateDeserializer())
                .create().toJson(newPerson)
*/

        val content = "[\n" +
                "  {\n" +
                "    \"available\": true,\n" +
                "    \"endDate\": \"2090-01-02\",\n" +
                "    \"hireDate\": \"2020-01-01\",\n" +
                "    \"id\": 0,\n" +
                "    \"lastName\": \"string\",\n" +
                "    \"name\": \"string\",\n" +
                "    \"nickName\": \"string\"\n" +
                "  }\n" +
                "]"

        val httpResponse = mockMvc.perform(post("/v1/resto/person/create")
                .content(content)  //also tried with .content(contentList)
                .contentType(MediaType.APPLICATION_JSON))
                .andReturn()

        // error, because, httpResponse is always empty
        val personCreated: List<Person> = gson.fromJson(httpResponse.response.contentAsString,
                object : TypeToken<List<Person>>() {}.type)

        assertEquals(newPerson.name, personCreated.get(0).name)
    }

Gson 在反序列化日期时有一些问题,这是一个解析器(hack),它适用于我的 GET 方法

文件PersonDeserializer.kt

class PersonDeserializer : JsonDeserializer<Person> {

    override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Person {
        json as JsonObject

        val name = json.get("name").asString
        val lastName = json.get("lastName").asString
        val nickName = json.get("nickName").asString
        val available = json.get("available").asBoolean

        val hireDate = LocalDate.of((json.get("hireDate") as JsonArray).get(0).asInt,
                (json.get("hireDate") as JsonArray).get(1).asInt,
                (json.get("hireDate") as JsonArray).get(2).asInt)

        val endDate = LocalDate.of((json.get("endDate") as JsonArray).get(0).asInt,
                (json.get("endDate") as JsonArray).get(1).asInt,
                (json.get("endDate") as JsonArray).get(2).asInt)

        return Person(1L, name, lastName, nickName, hireDate, endDate, available)
    }
}

我看到错误在 MOCKK 库中,因为从测试我可以到达端点并正确打印值

print from endpoint: print("person: $person") //this line is in the endpoint

Person: [Person(id=0, name=string, lastName=string, nickName=string, hireDate=2020-01-01, endDate=2090-01-02, available=true)]

错误测试日志

19:27:24.840 [main] DEBUG io.mockk.impl.recording.states.AnsweringState - Throwing io.mockk.MockKException: no answer found for: PersonRepository(#1).saveAll([Person(id=0, name=string, lastName=string, nickName=string, hireDate=2020-01-01, endDate=2090-01-02, available=true)]) on PersonRepository(#1).saveAll([Person(id=0, name=string, lastName=string, nickName=string, hireDate=2020-01-01, endDate=2090-01-02, available=true)])

19:27:24.844 [main] DEBUG org.springframework.test.web.servlet.TestDispatcherServlet - Failed to complete request: io.mockk.MockKException: no answer found for: PersonRepository(#1).saveAll([Person(id=0, name=string, lastName=string, nickName=string, hireDate=2020-01-01, endDate=2090-01-02, available=true)])

org.springframework.web.util.NestedServletException: Request processing failed; nested exception is io.mockk.MockKException: no answer found for: PersonRepository(#1).saveAll([Person(id=0, name=string, lastName=string, nickName=string, hireDate=2020-01-01, endDate=2090-01-02, available=true)])

错误因修复而异,我也得到了

JSON parse error: Cannot deserialize value of type java.time.LocalDate from ... ... 48 more

但是在 Spring 中使用 Kotlin

序列化 LocalDate 总是同样的问题

如能提供任何帮助,我们将不胜感激。

在阅读了很多针对此问题的可能解决方案之后,我找到了一些解决此问题的方法 "issue"。

就像我写的那样,我使用的是 Gson,所以,我已经为 序列化 实现了一个重写,另一个为 反序列化 实现了重写LocalDates,我还发现了一个 hack(?) 覆盖了 Data class 中的 ToString() 方法,更重要的是,当我尝试 deserialize a post LocalDate 字段中有空值的响应,我还想(再次)说,问题出在 不在生产代码中测试,让我们看看:

1) 简单的 Get 方法,没有空值

    @Test
    fun `return all non active persons`() {
        val personList = givenAListOfpersons()

        val activepersonsCount: Int = personList.filter { person ->
            person.available==false }.size //2

        every { personservice.findActivePersons() } returns personList

        val httpResponse = mockMvc.perform(get("/v1/resto/person/list?available={available}", "false")
                .param("available", "false")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk)
                .andExpect(jsonPath("$", hasSize<Any>(activepersonsCount)))
                .andReturn()

// Note: Simple deserialization: explain later

        val response: List<person> = gsonDeserializer.fromJson(httpResponse.response.contentAsString,
                object : TypeToken<List<person>>() {}.type)


        assertEquals(personList.get(0).name, response.get(0).name)
        assertEquals(personList.get(0).lastName, response.get(0).lastName)
        assertEquals(personList.get(0).nickName, response.get(0).nickName)
        assertEquals(personList.get(0).hireDate, response.get(0).hireDate)
        assertEquals(personList.get(0).available, response.get(0).available)
    }

2) Post 方法用 endDate

中的空值覆盖数据 class 中的 ToString

a) 修改数据class

@Entity
data class person(
        @Id @Column(name = "id") @GeneratedValue(strategy = GenerationType.AUTO)
        var id: Long,

        var name: String,
        var lastName: String,
        var nickName: String,
        @JsonFormat(pattern = "yyyy-MM-dd")
        var hireDate: LocalDate,

        @JsonFormat(pattern = "yyyy-MM-dd")
        var endDate: LocalDate?, //note this

        var available: Boolean
        ) {
        constructor()  : this(0L, "xx",
                "xx",
                "xx",
                LocalDate.of(2020,1,1),
                null,
                true)

        //here
        override fun toString(): String {
                return  "["+"{"+
                        '\"' +"id"+'\"'+":" + id +
                        ","+ '\"' +"name"+'\"'+":"+ '\"' + name + '\"' +
                        ","+ '\"' +"lastName"+'\"'+":"+ '\"' + lastName + '\"' +
                        ","+ '\"' +"nickName"+'\"'+":"+ '\"' + nickName + '\"' +
                        ","+ '\"' +"hireDate"+'\"'+":"+ '\"' + hireDate + '\"' +
                        ","+ '\"' +"endDate"+'\"'+":"+ '\"' + endDate + '\"' +
                        ","+ '\"' +"available"+'\"'+":" + available +
                        "}"+"]";
        }
}

b) 测试从数据 class

实现 toString()
@Test
    fun `create person`() {

        val personList = givenAListOfpersons() as MutableList<person>


        every { personService.saveAll(any()) }.returns(personList)

        val httpPostResponse = mockMvc.perform(post("/v1/resto/person/create")
                .contentType(MediaType.APPLICATION_JSON)
                .content(personTest.toString()))  //THIS
                .andDo(print())
                .andExpect(status().isCreated) //It´s works!!
                .andReturn()

        // Note the gsonDeserializer, explain later
        val personDeserializerToList = gsonDeserializer.fromJson<List<person>>(httpPostResponse.response.contentAsString,
                object : TypeToken<List<person>>() {}.rawType).get(0) as LinkedTreeMap<String, Object>

        assertEquals(personList.get(0).name, personDeserializerToList["name"])
        assertEquals(personList.get(0).lastName, personDeserializerToList["lastName"])
        assertEquals(personList.get(0).nickName, personDeserializerToList["nickName"])
        assertEquals(personList.get(0).hireDate, personDeserializerToList["hireDate"]))

        assertNull(personDeserializerToList["endDate"]))

        assertEquals(personList.get(0).available, personDeserializerToList["available"])
    }

3) 推荐方式:使用Gson重写Serialize方法并格式化LocalDates:

    @Test
    fun `create person`() {

        val personList = givenAListOfPersons() as MutableList<Person

        // It´s work´s
        val personSerializerToString = gsonSerializer.toJson(personList, object : TypeToken<List<person>>() {}.type)

        every { personService.saveAll(any()) }.returns(personList)

        val httpPostResponse = mockMvc.perform(post("/v1/resto/person/create")
                .contentType(MediaType.APPLICATION_JSON)
                .content(personSerializerToString))
                .andDo(print())
                .andExpect(status().isCreated) //It´s Work´s!
                .andReturn()

// Deserialization problem: endDate is null, and we cant parse a null in Gson
// that´s why i use **rawType**
        val personDeserializerToList = gsonDeserializer.fromJson<List<person>>(httpPostResponse.response.contentAsString,
                object : TypeToken<List<person>>() {}.rawType).get(0) as LinkedTreeMap<String, Object>

        assertEquals(personList.get(0).name, personDeserializerToList["name"])
        assertEquals(personList.get(0).lastName, personDeserializerToList["lastName"])
        assertEquals(personList.get(0).nickName, personDeserializerToList["nickName"])

// Note formatToLocalDate method: The date i receive from post is 
// in this format ==>  **[2020.0,1.0,1.0]** so i must to parse this 
// format to LocalDate

        assertEquals(personList.get(0).hireDate, formatToLocalDate(personDeserializerToList["hireDate"])) 

        assertNull(personDeserializerToList["endDate"])

        assertEquals(personList.get(0).available, personDeserializerToList["available"])
    }

最后,序列化、反序列化和formatToLocalDate:

a) 首先,我们必须设置配置:

@ExtendWith(MockKExtension::class)
@EnableAutoConfiguration
@AutoConfigureMockMvc
internal class PersonApiShould {

    private lateinit var gsonSerializer: Gson
    private lateinit var gsonDeserializer: Gson

    lateinit var mockMvc: MockMvc

    @MockkBean
    lateinit var personService: PersonService

    @BeforeEach
    fun setUp() {
        val repository = mockk<PersonRepository>()
        personService = PersonService(repository)
        mockMvc = standaloneSetup(PersonApi(repository)).build()


        // Note this
        gsonDeserializer = GsonBuilder()
                .registerTypeAdapter(Person::class.java, PersonDeserializer())
                .create()

        gsonSerializer = GsonBuilder()
                .registerTypeAdapter(Person::class.java, PersonSerializer())
                .create()
    }

    @AfterEach
    fun clear() {
        clearAllMocks()
    }
tests ...

b) 和方法

// This is because i receive [2020.0,1.0,1.0]
private fun formatToLocalDate(dates: Object?): LocalDate? {
    return LocalDate.of(
            ((dates as ArrayList<Object>).get(0) as Double).toInt(),
            ((dates as ArrayList<Object>).get(1) as Double).toInt(),
            ((dates as ArrayList<Object>).get(2) as Double).toInt())
}
//Gson have some issues when deserialize dates, this is a parser (hack)
// This parser have some troubles handling null values, that´s why i use rawType instead, 
//otherwise use this method

//Context: If we try to cast nulls in this class, we are going to receive this kind 
// of errors 
// ERROR with nulls:
//java.lang.ClassCastException: class com.google.gson.JsonNull cannot be cast to 
//class 
//com.google.gson.JsonArray (com.google.gson.JsonNull and 
//com.google.gson.JsonArray are in unnamed module of loader 'app')


class PersonDeserializer : JsonDeserializer<Person?> {

    override fun deserialize(jsonPersonResponse: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Person? {
        jsonPersonResponse as JsonObject

        val name = jsonPersonResponse.get("name").asString
        val lastName = jsonPersonResponse.get("lastName").asString
        val nickName = jsonPersonResponse.get("nickName").asString
        val available = jsonPersonResponse.get("available").asBoolean

        val hireDate = LocalDate.of((jsonPersonResponse.get("hireDate") as JsonArray).get(0).asInt,
                (jsonPersonResponse.get("hireDate") as JsonArray).get(1).asInt,
                (jsonPersonResponse.get("hireDate") as JsonArray).get(2).asInt)

        // remember, this Gson, cant handle null values and endDate is usually null 
        val endDate = LocalDate.of((jsonPersonResponse.get("endDate") as JsonArray).get(0).asInt,
                (jsonPersonResponse.get("endDate") as JsonArray).get(1).asInt,
                (jsonPersonResponse.get("endDate") as JsonArray).get(2).asInt)

        return Person(1L, name, lastName, nickName, hireDate, endDate, available)
    }
}
//Gson have some issues when serializing dates, this is a parser (hack)
class PersonSerializer : JsonSerializer<Person> {
    override fun serialize(src: Person, typeOfSrc: Type?, context: JsonSerializationContext): JsonObject {
        val PersonJson = JsonObject()
        PersonJson.addProperty("id", src.id.toInt())
        PersonJson.addProperty("name", src.name)
        PersonJson.addProperty("lastName", src.lastName)
        PersonJson.addProperty("nickName", src.nickName)
        PersonJson.addProperty("hireDate", src.hireDate.toString())

        if (src.endDate != null) {
            PersonJson.addProperty("endDate", src.endDate.toString())
        } else {
            PersonJson.addProperty("endDate", "".toShortOrNull())
        }

        PersonJson.addProperty("available", src.available)
        return PersonJson
    }

我希望这个解决方法有用。