안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
이번 포스팅은 Kotest 멀티모듈 컨트롤러 테스트에 대한 글을 작성하고자 합니다.
제가 겪은 문제는 멀티 모듈로 스프링을 구성하였을 때,
컨트롤러의 WebMvcTest의 entityManagerFactory 의존성 문제가 발생한 것이었습니다.
1. @WebMvcTest를 사용한 테스트
@ActiveProfiles("test")
@AutoConfigureMockMvc
@WebMvcTest(controllers = [ReviewGroupUpsertController::class])
class ReviewGroupUpsertControllerTestV1(
private val mockMvc: MockMvc,
) : FunSpec() {
override fun extensions(): List<Extension> = listOf(SpringExtension)
@MockkBean
private lateinit var userVerifyService: UserVerifyService
@MockkBean
private lateinit var courseGroupService: CourseGroupService
@MockkBean
private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService
@MockkBean
private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase
Description: Parameter 0 of constructor in com.content.adapter.course.CourseCommandAdapter required a bean named 'entityManagerFactory' that could not be found.
Action: Consider defining a bean named 'entityManagerFactory' in your configuration.
@WebMvcTest는 스프링 부트의 슬라이스 테스트 중 하나로, 웹 계층에 대한 테스트를 위해 사용이 됩니다.
이 어노테이션을 사용하면, 스프링 테스트 컨텍스트는 웹 계층의 빈들만 로드하여, 테스트를 가볍게 실행할 수 있습니다.
하지만, 이 어노테이션은 @Controller, @ControllerAdvice, @JsonComponent, WebMvcConfigurer 등과 같은 웹 계층 관련 구성 요소만 스캔하고, 나머지 요소는 스캔하지 않습니다.
이 문제로 인해, JPA에 사용되는 entityManagerFactory 등과 같은 의존성 모듈이 스캔되지 않는 문제가 발생하였습니다.
이에 대한 의존성을 TestConfiguration화 하여 사용할 수 있지만,
연쇄적으로 물리는 객체의 의존성으로 인해 이를 제어하는 것도 쉬운 일은 아니었습니다.
따라서, mockMvc를 활용하여 컨트롤러의 응답을 테스트하기 위해서는 컨트롤러의 생성자 주입에 필요한 클래스들만 스텁 화하여
단위테스트 형태로 작성하는 로직이 필요하였습니다.
2. 특정 컨트롤러에 대한 단위 테스트
이를 위한 해결책으로 @WebMvcTest 관련 어노테이션을 제거하고,
mockMvc에 대한 인스턴스를 만들어 사용하는 과정을 수행하였습니다.
@ActiveProfiles("test")
class ReviewGroupUpsertControllerMockMvcTest : FunSpec() {
override fun extensions(): List<Extension> = listOf(SpringExtension)
@MockkBean
private lateinit var userVerifyService: UserVerifyService
@MockkBean
private lateinit var courseGroupService: CourseGroupService
@MockkBean
private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService
@MockkBean
private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase
private lateinit var mockMvc: MockMvc
override suspend fun beforeSpec(spec: Spec) {
val reviewGroupUpsertController = ReviewGroupUpsertController(
userVerifyService = userVerifyService,
courseGroupService = courseGroupService,
possibleReviewCourseFindService = possibleReviewCourseFindService,
reviewGroupCommandUseCase = reviewGroupCommandUseCase
)
mockMvc = MockMvcBuilders.standaloneSetup(reviewGroupUpsertController).build()
}
어노테이션은 어떠한 프로파일에서 실행할 것인지를 명시하는 ActiveProfiles()를 제외하고 제거하였습니다.
각 MockkBean으로 설정한 Stub을 테스트하고자 하는 컨트롤러에 생성자 주입하여 객체를 만들고
mockMvc를 만들기 위한 빌드를 진행하였습니다.
3. 구체적인 코드 작성하기
a. FunSpec
kotest는 다양한 스타일의 테스팅을 지원합니다 (FunSpec, BehaviorSpec 등 다양한 스타일의 테스팅을 지원)
이 중 FunSpec은 scalaTest 방식을 채택하고 있습니다.
kotest를 구성할 때, 전통적인 OOP 방식으로 추상 클래스를 상속하는 방법이 있습니다.
반면 FunSpec({ /**/ }) 내부에 작성하는 DSL 스타일로 FunSpec의 생성자에 Lambda를 전달하는 방법도 있습니다.
저는 이 중 OOP 방식으로 작성하는 코드로 작성하였습니다. (두 기능 모두 정상적으로 잘 동작합니다)
class ReviewGroupUpsertControllerMockMvcTest : FunSpec() {
/* 중략 */
}
b. extentions()
Kotest에서 extentions() 함수는 테스트 실행 중에 추가적인 기능을 제공하는 데 사용됩니다.
SpringExtension은 Kotestdml Spring TestContext Framework와 통합하는 확장입니다.
이 확장을 사용하면 Spring의 DI 기능을 테스트에서 활용할 수 있습니다.
SpringExtension을 사용할 때 장점은 다음과 같습니다.
- SpringContext 통합
- 스프링 테스트 어노테이션 지원
- 캐시 된 컨텍스트 지원
- 트랜잭션 관리
저는 MockkBean을 사용하고 있기 때문에, SpringExtension을 사용하였습니다.
override fun extensions(): List<Extension> = listOf(SpringExtension)
c. @MockkBean
스프링은 MockBean을 사용할 수 있지만, ninja 라이브러리에서 제공하는 MockkBean을 사용하였습니다.
testImplementation("com.ninja-squad:springmockk:4.0.2")
@MockkBean을 사용할 때 장점은 다음과 같습니다.
- 코틀린을 위해 만들어진 mocking 라이브러리로 코틀린의 언어적인 특성을 더욱 활용할 수 있습니다
- 일반 mock과 비교하여, 확장 함수, 동반 객체 등을 Mockito보다 잘 지원합니다.
@MockkBean
private lateinit var userVerifyService: UserVerifyService
@MockkBean
private lateinit var courseGroupService: CourseGroupService
@MockkBean
private lateinit var possibleReviewCourseFindService: PossibleReviewCourseFindService
@MockkBean
private lateinit var reviewGroupCommandUseCase: ReviewGroupCommandUseCase
테스트할 컨트롤러의 생성자 주입에 필요한 객체는 스텁화하기 위해 @MockkBean으로 설정하여 지연 초기화를 수행하고 있습니다.
지연 초기화를 하는 이유는, 테스트에 필요한 의존성을 로드한 후 모킹한 클래스를 생성자 주입에 제공하기 위함입니다.
d. beforeSpec
beforeSpec을 사용하여, 테스트 이전에 필요한 스펙을 정의하였습니다.
beforeSpec은 BeforeAll과 비슷하지만, Kotest에서 제공하는 Spec 단위 설정입니다.
Junit의 BeforeAll과 마찬가지로, 각 테스트가 실행되기 전에 한 번만 호출됩니다.
이 내부에서 테스트에 필요한 ReviewGroupUpsertController를 생성하여
MockMvcBuilders.standaloneStepUp을 수행하여 mockMvc를 재정의하고 있습니다.
standaloneSetUp의 역할은 다음과 같습니다.
- Spring Web Application Context를 사용하지 않고, 컨트롤러만을 위한 경량 테스트 환경을 구축합니다
컨트롤러에 필요한 의존성만 명시적으로 사용하여 빠른 속도로 테스트를 수행할 수 있도록 합니다.
private lateinit var mockMvc: MockMvc
override suspend fun beforeSpec(spec: Spec) {
val reviewGroupUpsertController = ReviewGroupUpsertController(
userVerifyService = userVerifyService,
courseGroupService = courseGroupService,
possibleReviewCourseFindService = possibleReviewCourseFindService,
reviewGroupCommandUseCase = reviewGroupCommandUseCase
)
mockMvc = MockMvcBuilders.standaloneSetup(reviewGroupUpsertController).build()
}
e. test
FunSpec을 OOP 형태로 작성할 때, init 블록에 테스트를 작성할 수 있습니다.
Kotest 프레임워크에서는 테스트 정의를 선언적으로 수행합니다.
테스트 클래스의 인스턴스가 생성될 때, 테스트 케이스가 선언되어야 합니다.
이를 위해 init을 사용하여 테스트 클래스 생성 시점에 테스트를 작성하는 것입니다.
- 초기화 시점에 실행 보장
- 선언적 테스트 등록
- 컴파일 에러 방지
나머지는 FunSpec에서 사용하는 이용 방식과 동일합니다
context()에 테스트하고자 하는 상황을 정의합니다.
내부에 MockkBean으로 선언한 객체의 값을 정의합니다.
test() 내부에서는 MockMvcRequestBuilder로 모킹 요청을 설정합니다.
mockMvc.perform()으로 요청하고자 하는 대상을 수행하여 andExpect()로 그에 대한 응답을 테스트할 수 있습니다.
init {
context("ReviewGroup 생성에 대한 mockk 스텁 결과가 주어져요") {
val userId = 1L
every { userVerifyService.verifyNormalUserAndGet(any()) } returns User(
userId = userId,
userAccountStatus = UserAccountStatus.NORMAL,
nickName = "UserA"
)
every { courseGroupService.getCourseGroup(any(), any()) } returns CourseGroup(
userId = userId,
groupId = 1L,
groupName = "A",
cityCode = CityCode.SEOUL,
districtCode = DistrictCode.SEOUL_DOBONG,
)
every { reviewGroupCommandUseCase.createReviewGroupOrGet(any()) } returns ReviewGroup(
courseGroupId = userId,
reviewGroupId = 1L,
reviewGroupName = "ReviewA",
userId = 1L,
createdAt = LocalDateTime.now(),
modifiedAt = LocalDateTime.now(),
)
every { possibleReviewCourseFindService.verifyPossibleReviewCourseGroup(any()) } just Runs
test("올바른 json 요청으로 리뷰 그룹 생성 응답을 검증을 성공해요") {
val reviewCreateGroupApiRequest = "{\"courseGroupId\": 12345}"
val mockHttpServletRequestBuilder =
MockMvcRequestBuilders.post("https://localhost:8078/content/reviewgroups")
.contentType(MediaType.APPLICATION_JSON)
.header("UserId", userId)
.content(reviewCreateGroupApiRequest)
mockMvc.perform(mockHttpServletRequestBuilder)
.andExpect(status().isOk)
.andExpect(
jsonPath("$.reviewGroupId").value("1")
)
}
}
4. 정리하며
멀티 모듈로 컨트롤러 단위테스트를 할 때, 다양한 글을 보고 테스트를 작성하였는데 의존성 문제가 발생하였습니다.
많은 시간을 투자하고 여러 번 테스트를 반복하며 코드를 수정해 보았음에도 문제가 해결되지 않았습니다.
공식문서를 참고하여 코드를 작성하니 이 문제를 해결할 수 있었습니다.
조금 더 간편한 방법을 위해 여러 가지 코드를 어떠한 이유가 없이 복사해서 작성했던 과거를 반성할 수 있는 시간이었습니다!
읽어주셔서 감사합니다!!!
'SpringBoot' 카테고리의 다른 글
[SpringBoot] 외부 스토리지를 활용하여 첨부파일 관리하기 (0) | 2024.07.02 |
---|---|
[SpringBoot] Spring Batch Partition 단위로 병렬 처리하기 (0) | 2024.06.29 |
[SpringBoot] SpringBoot Jpa save() (1) | 2023.10.28 |
[SpringBoot] 리플리카 데이터베이스 연동하기 (0) | 2023.06.11 |
[SpringBoot] 의존성 주입과 Profile로 Filter 설정 동적 변경하기 (0) | 2023.04.01 |