0

I have a problem with a test (mock) of type POST in kotlin, when i use a data class with a date field (LocalDate).

This is the Stack im using:

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'

When i execute the POST method in the app, all is ok, i have the response and the data is saved correctly in the db:

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

But when i try to make the test of the POST Method, i cant (only with POST method, with GET is ok)

This are the classes that i use:

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

File 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)
    }
}

File 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)
    }
}

And finally

PersonApiShould.kt (This class is the problem)

@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 have some issues when deserialize dates, this is a parser (hack), it works for my GET method

File 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)
    }
}

I see that the error is in the MOCKK Library, because from test i can reach the endpoint and print correctly the value

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

Error test log

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

Errors varies, depending the fix, also i got

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

But always is the same problem with serialization of LocalDate in Spring with Kotlin

Any assistance you can provide would be greatly appreciated.

Fernando
  • 7
  • 3

1 Answers1

0

After read a lot of posible solutions to this problem, i found some workarounds to handle this "issue".

Like i wrote, im using Gson, so, i´ve implemented an overrride for the serialization and another for the deserialization of LocalDates, also i found a hack(?) that override ToString() method in Data class, and more important, i found more issues when i tried to deserialize a post response with nulls in a LocalDate field, also i would like to say (again), that the problem were in the TEST NOT IN PRODUCTIVE CODE, let´s see:

1) Simple Get method, with not nulls

    @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 method overriding ToString in data class with null values in endDate

a) Modify Data 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) Test implementing toString() from Data class

@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) Recommended way: Using Gson overrride Serialize method and format 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"])
    }

Finally, Serialization, Deserialization and formatToLocalDate:

a) First, we have to set the configurations:

@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) And the Methods

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

I hope this workaround could be useful.

Fernando
  • 7
  • 3