【问题标题】:Error in POST method with Kotlin, SpringBoot and MockkKotlin、SpringBoot 和 Mockk 的 POST 方法出错
【发布时间】:2020-07-13 19:57:32
【问题描述】:

当我使用带有 date 字段 (LocalDate) 的 data class 时,我在 kotlin 中使用 POST 类型的测试(模拟)时遇到问题。

这是我正在使用的堆栈:

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 即可)

这是我使用的类:

文件 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(这个类是问题所在)

@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") //此行在端点中

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

错误测试日志

19:27:24.840 [主要] 调试 io.mockk.impl.recording.states.AnsweringState - 投掷 io.mockk.MockKException:没有找到答案: PersonRepository(#1).saveAll([Person(id=0, name=string, 姓=字符串,昵称=字符串,hireDate=2020-01-01, endDate=2090-01-02, available=true)]) on PersonRepository(#1).saveAll([Person(id=0, name=string, 姓=字符串,昵称=字符串,hireDate=2020-01-01, endDate=2090-01-02, available=true)])

19:27:24.844 [主要] 调试 org.springframework.test.web.servlet.TestDispatcherServlet - 失败 完整的请求:io.mockk.MockKException:找不到答案: PersonRepository(#1).saveAll([Person(id=0, name=string, 姓=字符串,昵称=字符串,hireDate=2020-01-01, endDate=2090-01-02, available=true)])

org.springframework.web.util.NestedServletException: 请求 处理失败;嵌套异常是 io.mockk.MockKException: no 找到的答案:PersonRepository(#1).saveAll([Person(id=0, 姓名=字符串,姓=字符串,昵称=字符串,hireDate=2020-01-01, endDate=2090-01-02, available=true)])

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

JSON 解析错误:无法从 ... 反序列化 java.time.LocalDate 类型的值 ... 48 更多

但是在 Spring 中使用 Kotlin 序列化 LocalDate 总是同样的问题

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

【问题讨论】:

    标签: spring spring-boot kotlin junit mockk


    【解决方案1】:

    在阅读了很多关于这个问题的可能解决方案后,我找到了一些解决方法来处理这个“问题”。

    就像我写的那样,我使用 Gson,所以,我实现了 序列化 的覆盖和 LocalDates 的 反序列化,我还发现了一个 hack( ?) 覆盖 Data 类中的 ToString() 方法,更重要的是,当我尝试 反序列化 在 LocalDate 中包含空值的帖子响应时,我发现了更多问题字段,我还想说(再次),问题出在TEST NOT IN PRODUCTIVE CODE,让我们看看:

    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 方法覆盖数据类中的 ToString,endDate 为空值

    a) 修改数据类

    @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) 测试从 Data 类实现 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
        }
    

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

    【讨论】:

      猜你喜欢
      • 2021-06-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2017-11-12
      • 2020-06-04
      相关资源
      最近更新 更多