기본적으로 스프링부트 테스트에는 @SpringBootTest
와 @WebMvcTest
를 통해 통합테스트 또는 단위테스트를 진행한다.
@WebMvcTest
는 Web Layer의 빈만 로드하므로 Web Layer 아래의 Service나 Repository의 테스트는 불가능하기 때문에 해당하는 빈은 @MockBean
으로 대체하여 단순히 요청을 잘 받는지의 여부만 테스트한다.
하지만 만약 요청을 받아 Service나 Repository의 동작까지 테스트하고 싶다면 통합 테스트로 @Service
, @Repository
빈을 로드해야 한다.
통합 테스트는 내장 톰캣을 사용하지 않고 MockMvc를 사용하는 방법과 실제 내장 톰캣을 로드해 테스트하는 두가지 방법이 있다.
테스트할 컨트롤러, 도메인, 리포지토리, 서비스, Dto는 다음과 같다.
// domain
@Entity
class Post(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(length = 500, nullable = false)
var title: String,
@Column(columnDefinition = "TEXT", nullable = false)
var content: String,
val author: String? = null
)
// data jpa repository
interface PostRepository : JpaRepository<Post, Long>{
}
// service
@Service
class PostService(
private val postRepository: PostRepository
) {
@Transactional
fun save(requestDto: PostSaveRequestDto): Long? = postRepository.save(requestDto.toEntity()).id
@Transactional
fun update(id: Long, requestDto: PostUpdateRequestDto): Long? {
val post = postRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("id = $id, 해당하는 게시글이 없습니다.")
post.title = requestDto.title
post.content = requestDto.content
return id
}
@Transactional
fun findPostById(id: Long): PostResponseDto {
val post = postRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("id = $id, 해당하는 게시글이 없습니다.")
return PostResponseDto(post)
}
}
// controller
@RestController
class PostApiController(
private val postService: PostService
) {
@GetMapping("/api/v1/post/{id}")
fun findPostById(@PathVariable id: Long): PostResponseDto = postService.findPostById(id)
@PostMapping("/api/v1/post")
fun save(@RequestBody requestDto: PostSaveRequestDto): Long? = postService.save(requestDto)
@PutMapping("/api/v1/post/{id}")
fun update(@PathVariable id: Long, @RequestBody requestDto: PostUpdateRequestDto): Long? =
postService.update(id, requestDto)
}
// dtos
data class PostResponseDto(
val id: Long,
val title: String,
val content: String,
val author: String? = null
){
constructor(post: Post) : this(
id = post.id!!,
title = post.title,
content = post.content,
author = post.author
)
}
data class PostSaveRequestDto(
val title: String,
val content: String,
val author: String? = null
) {
fun toEntity(): Post = Post(
title = title,
content = content,
author = author
)
}
data class PostUpdateRequestDto(
val title: String = "",
val content: String = "",
)
1. Data Jpa 메소드를 사용할 때
Data Jpa 메소드는 리턴 타입이 자바의 Optional 타입이다. 코틀린은 Optional 타입을 자동으로 Nullable 타입으로 변환해주지 않으므로 리턴된 Optional을 처리해야한다. 하지만 코틀린에서 Optional 타입을 사용하는 것은 코틀린을 사용할 이유가 없어지는 것이라고 생각한다.
가장 쉬운 해결책은 코틀린에서 제공하는 Data Jpa 확장 함수를 사용하는 것이다. findByIdOrNull( ) 과 같이 OrNull이 붙은 확장 함수를 사용하면 코틀린의 Nullable 타입을 리턴한다. Nullable 타입을 코틀린스럽게 엘비스 연산자를 사용하던지 let 함수를 사용하던지 처리하면 된다.
2. Dto를 정의할 때
코틀린에 익숙하지 않아서 한 실수였지만 이 문제 때문에 거의 하루를 날렸다. 코틀린에서 클래스 프로퍼티를 정의할 때 private 키워드를 사용하면 프로퍼티를 private한 변수로 만드는 것이 아니라 자동으로 생성해주는 게터와 세터를 만들어주지 않는다. 스프링부트에서 생성자 기반 주입을 사용할 때 주입된 의존성은 클래스 밖에서 접근하지 못하도록 private 키워드를 붙여 게터와 세터를 만들지 않도록 했는데 습관적으로 Dto를 정의할 때 private 키워드를 붙여 정의하였다. Dto에는 절대 프로퍼티에 private 키워드를 붙이지 말아야 한다. 이유는 아래서 문제가 된 부분을 다루면서 설명한다.
3. Entity를 정의할 때
Kotlin in Action에서 var은 가급적 사용하지 말고 val로 정의했다가 문제가 생기면 var로 바꾸라고 했는데 Entity에서는 변할수 있는 값, 즉 업데이트 쿼리로 바뀔 가능성이 있는 프로퍼티(컬럼)만 var로 선언하면 된다.
예를들면 auto-increment 설정인 id 컬럼은 바뀔일이 없기 때문에 val로 선언하면 된다. 예제 코드의 Post 엔티티에서 title과 content는 바뀔수 있는 컬럼이기 때문에 Jpa를 통해 update 하기 위해서 var로 선언해야 한다.
먼저 MockMvc를 사용하기 위해서는 @AutoConfigureMockMvc
어노테이션이 필요하다.
@SprinBootTest // 통합테스트를 위한 어노테이션
@AutoConfigureMockMvc // MockMvc를 사용하기 위한 어노테이션
class PostApiControllerTests { ... }
MockMvc는 서블릿 컨테이너의 구동 없이, 시뮬레이션된 MVC 환경에 모의 HTTP 서블릿 요청을 전송하는 기능을 제공하는 유틸리티 클래스다. MockMvc.perform()
메소드를 통해 http 요청을 보낸다.
@SpringBootTest
@AutoConfigureMockMvc
class PostApiControllerTests(
@Autowired private val mvc: MockMvc,
@Autowired private val postRepository: PostRepository,
@Autowired private val gson: Gson
) {
@AfterEach
fun tearDown(){
postRepository.deleteAll()
}
@Test
fun savePost_shouldSuccess(){
// given
val title = "title"
val content = "content"
val requestDto = PostSaveRequestDto(
title = title,
content = content,
)
mvc.perform(post("/api/v1/post")
.contentType(MediaType.APPLICATION_JSON)
.content(gson.toJson(requestDto)))
.andExpectAll(
status().isOk,
content().contentType(MediaType.APPLICATION_JSON),
content().string("1")
)
val post = postRepository.findAll().first()
assertThat(post.id).isEqualTo(1L)
assertThat(post.title).isEqualTo(title)
assertThat(post.content).isEqualTo(content)
assertThat(post.author).isNull()
}
}
이전 포스트에서 언급했듯이 생성자 기반 주입을 테스트에서 사용하려면 자바와 코틀린 모드 @Autowired
를 명시해야한다.
MockMvcServletRequestBuilder 메소드(여기서는 post()
)를 통해 요청 메소드를(GET, POST, PUT, DELETE 등) 지정하고 요청할 url 지정한다. contentType
, content
과 같은 메소드를 통해 헤더 설정과 요청 몸체를 설정할 수 있다.
이후 반환되는 ResultActions 객체를 통해 요청 결과를 확인할 수 있다. andExpectAll(ResultMachers...)
로 한번에 결과를 테스트하거나 andExpect(ResultMahaters)
로 각각 테스트할 수 있다.
객체와 메소드의 자세한 내용은 스프링 공식 문서를 참조하자.
TestRestTemplate을 사용한 테스트는 내장 톰캣을 구동하여 실제 서블릿 컨테이너가 구동되는 테스트이다.
테스트 환경에서 내장 톰캣으로 실제 서블릿 컨테이너를 구동하기 위해서는 @SpringBootTest
의 webEnvironment 옵션에서 RANDOM_PORT 또는 DEFINED_PORT를 사용해야 한다. 여기서는 RANDOM_PORT를 사용한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PostApiControllerTests(
@Autowired private val postRepository: PostRepository,
@Autowired private val restTemplate: TestRestTemplate,
@LocalServerPort private val port: Int
) {
val title = "title"
val content = "content"
@BeforeEach
fun tearDown() {
postRepository.deleteAll()
}
@Test
fun savePost_shouldSuccess() {
// given
val requestDto = PostSaveRequestDto(
title = title,
content = content
)
val url = "http://localhost:${port}/api/v1/post"
// when
val responseEntity: ResponseEntity<Long?> = restTemplate
.postForEntity(url, requestDto, Long::class.java)
// then
assertThat(responseEntity.statusCode).isEqualTo(HttpStatus.OK)
assertThat(responseEntity.headers.contentType).isEqualTo(MediaType.APPLICATION_JSON)
val post = postRepository.findAll().first()
assertThat(post.title).isEqualTo(title)
assertThat(post.content).isEqualTo(content)
}
}
RANDOM_PORT 옵션을 사용했으므로 @LocalServerPort
어노테이션을 통해 포트 번호를 생성한다.
TestRestTemplate은 내부적으로 RestTemplate을 사용한다. RestTemplate은 노드의 axios 같이 스프링에서 사용하는 HTTP 요청 클라이언트다.
요청 메소드는 크게 두가지로 exchange와 xxxxForEntity 가 있다. 두 메소드는 결과로 responseEntity를 반환한다. 자세한 내용은 스프링 공식문서를 참조하자.
여기서 Dto의 프로퍼티에 private를 붙이면 안되는 이유가 나온다. 처음 테스트를 진행했을때 테스트가 실패한 것이 아니라 런타임 에러가 발생했다.
JSON parse error: Instantiation of [simple type, class com.ex.aws_springboot.web.dto.PostSaveRequestDto] value failed for JSON property title due to missing (therefore NULL) value for creator parameter title which is a non-nullable type; nested exception is com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [simple type, class com.ex.aws_springboot.web.dto.PostSaveRequestDto] value failed for JSON property title due to missing (therefore NULL) value for creator parameter title which is a non-nullable type<EOL> at [Source: (PushbackInputStream); line: 1, column: 35] (through reference chain: com.ex.aws_springboot.web.dto.PostSaveRequestDto["title"])]
에러 메세지를 살펴보면 val로 선언된 title 프로퍼티에 null이 들어왔고 위 에러메세지 외에 다른 에러메세지에서는 JSON 문자열의 requestDto로의 역직렬화가 실패했다는 내용이었다.
이유를 찾기 위해 스프링부트에 기본 탑재되는 Jackson 라이브러리를 사용해 직접 dto를 JSON 문자열로 파싱해봤지만 역시나 작동하지 않았다. 코틀린기반 스프링부트에서 Jackson의 문제가 있나 싶어 Gson을 이용해 파싱해봤더니 또 잘 되었다.
Jackson 자체에 문제가 있나 싶어 구글링을 하던 도중 @RequestBody
, @ResponseBody
어노테이션을 사용했을때 JSON 데이터의 파싱을 스프링부트에서는 Jackson 라이브러리를 사용한다는 사실을 알았다. 테스트가 아닌 실제 Postman 테스트에서는 문제 없이 기능들이 작동하였기에 @RequestBody
, @ResponseBody
에서는 Jackson이 문제 없이 작동한다는 것이었다.
Jackson이 문제가 아니라 RestTemplate이 코틀린에서 문제가 있나 생각이 들 정도로 점점 문제가 산으로 가는 와중에 Jackson 라이브러리의 객체와 JSON 간의 직렬화에 객체의 게터가 필요하다는 사실을 알게 되었다. Dto 클래스를 자바로 디컴파일 해보니 게터가 존재하지 않았다. 이는 당연하다. 프로퍼티를 private 키워드로 선언했기 때문이다. private 키워드를 지워주니 테스트가 성공하고 Jackson 라이브러리도 정상적으로 작동했다.
사실 두 테스트는 다른 점이 거의 없다. 서블릿 컨테이너의 사용 여부 정도만 다르고 요청을 보내고 컨트롤러에서 서비스를 호출하고 서비스에서 리포지토리를 호출하는 과정은 동일하다.
테스트 내용 자체의 차이는 없되 두 방식의 차이는 관점의 차이다. MockMvc는 서버의 관점에서 비즈니스 로직이 잘 수행되는지 테스트 했다면 TestRestTemplate은 클라이언트의 관점에서 서버에 요청하고 비즈니스 로직이 잘 수행되는지를 테스트한 것이다.
어떤 관점에서 테스트를 하는지에 따라 방식을 선택하면 될 것 같다.