Deserializing JSON Strings in Test Code
Introduction
When testing a RestController
with MockMvc
, you often need to compare the JSON result with an expected value.
Typically, you use jsonPath
to verify all the properties of the returned object.
// when - then
mockMvc.perform(get("/api/members/" + member.getId() + "/sale")
.contextPath("/api")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding("utf-8")
.cookie(cookie)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].price").value(1000))
.andExpect(jsonPath("$.data[0].description").value(""))
.andExpect(jsonPath("$.data[0].title").value("test"))
.andExpect(jsonPath("$.data[0].location").value("Seoul"))
.andExpect(jsonPath("$.data[0].eventTime").value(now.toString()))
.andExpect(jsonPath("$.data[0].saleStart").value(now.toString()))
.andExpect(jsonPath("$.data[0].saleEnd").value(now.toString()))
.andExpect(jsonPath("$.data[0].stock").value(3))
.andExpect(jsonPath("$.data[0].remainStock").value(1))
.andExpect(jsonPath("$.data[0].category").value(""))
.andExpect(jsonPath("$.data[0].runningMinutes").value(600));
When the JSON structure is simple, manually verifying with jsonPath
is convenient.
But as the object grows and includes nested arrays, the fact that jsonPath
takes plain strings means you can’t rely on IDE assistance, which leads to missed fields or typos, making it cumbersome.
This made me wonder—TypeScript makes it easy to convert JSON strings to objects with JSON.parse
, so couldn’t we do something similar?
const jsonString = '{"name": "John", "age": 30, "city": "New York"}';
interface Person {
name: string;
age: number;
city: string;
}
const person: Person = JSON.parse(jsonString);
Main Content
In our Tiketeer project, we design API response objects using a separate wrapper class:
@Getter
@ToString
@NoArgsConstructor(force = true)
public class ApiResponse<T> {
private final T data;
private ApiResponse(T data) {
this.data = data;
}
public static <T> ApiResponse<T> wrap(T data) {
return new ApiResponse<>(data);
}
}
We use this wrapper class to produce:
- Single object DTOs
- Array DTOs (e.g.,
TicketingResponse[]
)
I’ll explain how we deserialize each.
Single Object DTO
Many developers have faced similar problems, so the popular serialization library Jackson already provides a readValue
method in ObjectMapper
.
public <T> T readValue(String content, JavaType valueType)
Unfortunately, you have to provide a JavaType
, not just a Class
.
So first, we’ll create a helper that takes a class and returns the appropriate JavaType
:
private JavaType getApiResponseType(Class<?> clazz) {
return objectMapper.getTypeFactory().constructParametricType(ApiResponse.class, clazz);
}
Combining these, you can create a deserialization method like this:
public <T> ApiResponse<T> getDeserializedApiResponse(String json, Class<T> responseType) throws
JsonProcessingException {
return objectMapper.readValue(json, getApiResponseType(responseType));
}
List Type DTO
If your response returns a list, you can write a method like this.
When returning a List<TargetType>
, your goal is to get a JavaType
describing List<TargetType>
.
Fortunately, ObjectMapper
’s TypeFactory
has overloaded constructParametricType
methods:
Using this, you can obtain the JavaType
of List<TargetType>
as long as you have the JavaType
for the list element:
private JavaType getApiResponseType(Class<?> clazz) {
return objectMapper.getTypeFactory().constructParametricType(ApiResponse.class, clazz);
}
// Overload the method used in single object deserialization
private JavaType getApiResponseType(JavaType javaType) {
return objectMapper.getTypeFactory().constructParametricType(ApiResponse.class, javaType);
}
private JavaType getListApiResponseType(Class<?> clazz) {
JavaType listType = getListType(clazz);
return getApiResponseType(listType);
}
Now you just need to get the JavaType
for the List itself:
private JavaType getListType(Class<?> clazz) {
return objectMapper.getTypeFactory().constructParametricType(List.class, clazz);
}
Putting it together, you get this:
public <T> ApiResponse<List<T>> getDeserializedListApiResponse(String json, Class<T> responseType)
throws
JsonProcessingException {
return objectMapper.readValue(json, getListApiResponseType(responseType));
}
In our project, we placed these methods inside a TestHelper
class, which also handles common repetitive tasks in tests (like database table resets).
Example usage:
// when - then
MvcResult result = mockMvc.perform(get("/api/members/" + member.getId() + "/sale")
.contextPath("/api")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding("utf-8")
.cookie(cookie)
)
.andExpect(status().isOk())
.andReturn();
String jsonResult = result.getResponse().getContentAsString();
ApiResponse<List<GetMemberTicketingSalesResponseDto>> apiResponse =
testHelper.getDeserializedListApiResponse(
jsonResult, GetMemberTicketingSalesResponseDto.class);
var dto = apiResponse.getData().getFirst();
assertThat(dto.getPrice()).isEqualTo(1000);
assertThat(dto.getDescription()).isEqualTo("");
assertThat(dto.getTitle()).isEqualTo("test");
assertThat(dto.getLocation()).isEqualTo("Seoul");
assertThat(dto.getEventTime()).isEqualToIgnoringNanos(now);
assertThat(dto.getSaleStart()).isEqualToIgnoringNanos(now);
assertThat(dto.getSaleEnd()).isEqualToIgnoringNanos(now);
assertThat(dto.getStock()).isEqualTo(3);
assertThat(dto.getRemainStock()).isEqualTo(1);
assertThat(dto.getCategory()).isEqualTo("");
assertThat(dto.getRunningMinutes()).isEqualTo(600);
Advantages
Deserializing JSON into objects this way offers another benefit:
You can use DateTime methods directly.
During serialization/deserialization, time values may lose precision or get padded with zeros at the nanosecond level.
With the jsonPath
approach, it’s difficult to account for these subtle differences when comparing strings.
But when you have objects, you can use methods like isEqualToIgnoringNanos
to write tests that are both more convenient and precise.
Conclusion
In this post, we looked at how to deserialize JSON into objects in test code.
For DTOs with many fields or time values including seconds, this approach makes it much easier to write robust tests.
Code and repository
Thank you.
댓글남기기