목차
문제의 상황
Spring REST Docs를 공부해보기 위해 이전에 작성했던 코드의 컨트롤러 테스트 코드를 MockMvc를 사용해서 작성하고 있었다.
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
class AdminControllerTest @Autowired constructor(
private val tokenProvider: TokenProvider,
private val memberRepository: MemberRepository,
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper = ObjectMapper()
) {
@Test
fun `모든 사용자 찾기`() {
val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
// given
val uuids = memberRepository.saveAll(
listOf(
Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
)
).map { it.id.toString() }
// when
val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
// then
action.andExpect { result ->
val response = result.response
val (status, message, data) = objectMapper.readValue(response.contentAsByteArray, ApiResponse::class.java)
response.status shouldBe 200
status shouldBe ApiStatus.SUCCESS
message shouldBe null
(data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
}
}
}
근데 별 문제 없이 테스트를 통과할거라는 생각과 달리 테스트가 터져버렸다.
분명히 테스트를 위해 DB에 집어넣은 이름값을 복붙해서 검증했는데 한글이 깨져버려서 테스트가 터지는 상황이 발생했다.
문제 원인
구글 크롬과 같은 주요 브라우저들이 기본으로 UTF-8 문자들을 해석하게 되어 스프링부트 2.2.0(스프링 5.2)부터 기본 인코딩에 굳이 UTF-8을 명시하지 않게 되었다고 한다.
즉, 기존에 Content type = application/json;charset=UTF-8
이었던 것이 Content type = application/json
으로 변경되었다는 것이다.
하지만 charset=UTF-8
이 빠지게 되면서 MockMvc는 응답 Body의 내용을 제대로 인코딩하지 못해 한글이 깨져버린 것이다.
해결 방안
생각보다 다양한 해결법이 있었다.
테스트 응답의 Content Type을 변경
테스트 응답에 charset=UTF-8
이 빠져서 생기는 문제니까 단순히 테스트 응답의 Content type을 application/json;charset=UTF-8
로 변경하는 것이다.
@Test
fun `모든 사용자 찾기`() {
val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
// given
val uuids = memberRepository.saveAll(
listOf(
Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
)
).map { it.id.toString() }
// when
val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
// then
action.andExpect { result ->
val response = result.response
response.contentType = MediaType.APPLICATION_JSON_UTF8_VALUE // 이 줄 추가
val (status, message, data) = objectMapper.readValue(response.contentAsString, ApiResponse::class.java)
response.status shouldBe 200
status shouldBe ApiStatus.SUCCESS
message shouldBe null
(data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
}
}
한글이 깨지는 문제 없이 테스트를 통과하지만 MediaType.APPLICATION_JSON_UTF8
이 Deprecated 되었기 때문에 뭔가 찝찝한 해결법이다.
응답 Body를 ByteArray로 받기
한글이 깨지는 문제는 응답 Body의 raw 데이터를 문자열로 변환하는 과정에서 발생하는 문제라고 생각해서 raw 데이터 그대로 받는 ByteArray로 받아보았다.
@Test
fun `모든 사용자 찾기`() {
val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
// given
val uuids = memberRepository.saveAll(
listOf(
Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
)
).map { it.id.toString() }
// when
val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
// then
action.andExpect { result ->
val response = result.response
// contentAsString -> contentAsByteArray
val (status, message, data) = objectMapper.readValue(response.contentAsByteArray, ApiResponse::class.java)
response.status shouldBe 200
status shouldBe ApiStatus.SUCCESS
message shouldBe null
(data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
}
}
response
에서 Body를 받아오는 부분만 ByteArray로 바꿨을 뿐인데 인코딩 문제 없이 테스트를 통과했다. 아무래도 Jackson 라이브러리의 ObjectMapper에서 ByteArray를 객체로 변환할 때는 인코딩 문제가 없는 듯 하다.
MockMvc에 인코딩 필터 추가
MockMvc를 커스터마이징해서 UTF-8로 인코딩하도록 필터를 추가할 수도 있다.
여기서는 커스텀 어노테이션을 사용해서 MockMvc에 커스텀 필터를 적용하도록 했다.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Import(MockMvcConfigWithEncoding.Utf8Encoding::class)
annotation class MockMvcConfigWithEncoding {
class Utf8Encoding {
@Bean
fun utf8Config() = MockMvcBuilderCustomizer { it.addFilter(CharacterEncodingFilter("UTF-8", true)).build() }
}
}
어노테이션 내부의 클래스 명과 내부 클래스의 메소드 명은 임의로 작성하고, @Import
에 작성한 내부 클래스를 넣어서 커스텀 어노테이션이 붙은 클래스는 해당 빈이 적용되도록 설정해준다.
@SpringBootTest
@MockMvcConfigWithEncoding // 작성한 커스텀 어노테이션 추가
@AutoConfigureMockMvc
@AutoConfigureRestDocs
class AdminControllerTest @Autowired constructor(
private val tokenProvider: TokenProvider,
private val memberRepository: MemberRepository,
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper = ObjectMapper()
) {
@Test
fun `모든 사용자 찾기`() {
val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
// given
val uuids = memberRepository.saveAll(
listOf(
Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
)
).map { it.id.toString() }
// when
val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
// then
action.andExpect { result ->
val response = result.response
val (status, message, data) = objectMapper.readValue(response.contentAsString, ApiResponse::class.java)
response.status shouldBe 200
status shouldBe ApiStatus.SUCCESS
message shouldBe null
(data as List<*>).map { (it as Map<*, *>)["id"] } shouldBe uuids
data.map { (it as Map<*, *>)["name"] } shouldBe listOf("콜라곰", "사이다곰", "환타곰")
data.map { (it as Map<*, *>)["type"] } shouldBe List(3) { "USER" }
}
}
}
MockMvc의 인코딩 타입을 UTF-8로 변경했기 때문에 한글 깨짐 문제 없이 테스트를 통과한다.
MockMvcBuilder 커스터마이징
위 방법과 비슷한데, 필터를 추가하지 않고 MockMvcBuilderCustomizer를 구현한 스프링 빈 클래스를 작성하여 MockMvc의 결과값 인코딩 방식을 UTF-8로 변경하는 방법이다.
@Component
class MockMvcEncodingCustomizer : MockMvcBuilderCustomizer {
override fun customize(builder: ConfigurableMockMvcBuilder<*>?) {
builder?.alwaysDo { it.response.characterEncoding = "UTF-8" }
}
}
해당 클래스만 작성해서 스프링 빈으로 등록해놓으면 @AutoConfigureMockMvc
가 달린 클래스에 자동으로 적용된다고 한다.
MockMvc 스타일의 테스트코드 작성
원래 작성했던 테스트 코드는 Kotest를 사용해서 코틀린 스타일의 테스트 코드를 작성해보려고 굳이 응답 Body를 꺼내서 Kotest의 shouldBe
로 값을 검증하고 있었다.
이렇게 검증하는 코드를 MockMvc의 andExpect()
를 사용하는 코드로 변경해봤다.
@Test
fun `모든 사용자 찾기`() {
val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
// given
val uuids = memberRepository.saveAll(
listOf(
Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
)
).map { it.id.toString() }
// when
val action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token))
// then
action.andExpect(status().isOk)
.andExpect(jsonPath("$.status").value(ApiStatus.SUCCESS.name))
.andExpect(jsonPath("$.data[0].id").value(uuids[0]))
.andExpect(jsonPath("$.data[0].name").value("콜라곰"))
.andExpect(jsonPath("$.data[0].type").value(MemberType.USER.name))
.andExpect(jsonPath("$.data[1].id").value(uuids[1]))
.andExpect(jsonPath("$.data[1].name").value("사이다곰"))
.andExpect(jsonPath("$.data[1].type").value(MemberType.USER.name))
.andExpect(jsonPath("$.data[2].id").value(uuids[2]))
.andExpect(jsonPath("$.data[2].name").value("환타곰"))
.andExpect(jsonPath("$.data[2].type").value(MemberType.USER.name))
}
이 방법도 테스트를 잘 통과한다.
andExpect()
의 인자에는 MockMvcResultMatchers 객체가 들어가게 되는데, 이 클래스를 상속하는 JsonPathResultMatchers 클래스에서 JSON 내부의 값을 가져올 때 UTF-8로 인코딩을 해서 인코딩 문제가 발생하지 않는다.
참조 링크
댓글