저는 항상 Java에서의 테스트 코드를 보고 복잡하다고 생각해왔습니다.
특히 여러 테스트를 계층화할 때 @Nested와 함께 많은 내부 클래스를 사용하다 보니 코드가 복잡해지기 쉬웠는데요.
그래서 저는 이러한 문제를 Kotlin DSL을 통해 개선하고자 했습니다.
Kotlin DSL
DSL(Domain Specific Languages)은 특정 도메인에 국한된 프로그래밍 언어입니다.
DSL을 사용하면 코드의 내부 구현을 숨겨 가독성을 좋게 만들 수 있습니다.
Given("챕터와 각각의 챕터에 속하는 퀴즈들이 존재하는 경우") { ... When("유저가 특정 퀴즈를 조회하면") { ... Then("해당 퀴즈가 조회된다.") { ... } }}
그 중 Kotlin DSL은 Kotlin의 기능을 활용하여 위 코드처럼 특정 도메인에 대해 직관적인 코드를 작성할 수 있도록 해주는 언어인데요.
일반적으로 확장 함수나 후행 람다(Trailing Lambda) 등의 Kotlin의 기능을 활용해서 DSL을 구현하게 됩니다.
Kotlin DSL 기반 테스트 코드
이 Kotlin DSL을 활용하면 기존의 테스트 코드도 직관적으로 작성할 수 있게 됩니다.
그 도구로는 주로 Kotlin 기반 테스트 프레임워크인 Kotest를 활용합니다.
Kotest를 통해서 단순히 @Test나 @Nested를 사용해서 테스트 코드를 작성하는 것 외에도 Given-When-Then 구조의 BDD(Behavior Driven Design) 또는 DCI(Describe-Context-It) 패턴을 Kotlin DSL로 구현할 수 있습니다.
또한 Kotest와 함께 자주 사용되는 모킹(Mocking) 프레임워크인 Mockk도 함께 사용합니다.build.gradle.kts
참고로 Kotest에서 @Autowired를 사용하기 위해서는 AbstractProjectConfig을 통해 SpringExtentsion을 등록해야 합니다.
Controller 테스트
ControllerTest.kt
@AutoConfigureRestDocsabstract class ControllerTest : DescribeSpec() { @Autowired private lateinit var applicationContext: ApplicationContext @Autowired private lateinit var restDocumentation: RestDocumentationContextProvider protected val webClient: WebTestClient by lazy { WebTestClient.bindToApplicationContext(applicationContext) .configureClient() .filter(WebTestClientRestDocumentation.documentationConfiguration(restDocumentation)) .build() }}
저희는 Spring WebFlux와 Spring REST Docs를 사용하고 있으므로 Spring REST Docs가 설정된 WebTestClient를 포함한 ControllerTest를 구현했습니다.
컨트롤러 계층 테스트는 DCI 패턴을 사용할 예정이므로 Kotest의 DescribeSpec을 상속받았습니다.
이후 모든 컨트롤러 테스트 클래스들은 ControllerTest를 상속받습니다.QuizControllerTest.kt
@WebFluxTest(QuizRouter::class, QuizHandler::class)class QuizControllerTest : ControllerTest() { @MockkBean private lateinit var quizService: QuizService init { describe("getQuizById()는") { context("퀴즈가 존재하는 경우") { every { quizService.getQuizById(any()) } returns Mono.just(createQuizResponse()) it("상태 코드 200과 quizResponse를 반환한다.") { webClient .get() .uri("/quiz/{id}", ID) .exchange() .expectStatus() .isOk .expectBody<QuizResponse>() } } context("퀴즈가 존재하지 않는 경우") { every { quizService.getQuizById(any()) } returns Mono.error(QuizNotFoundException()) it("상태 코드 404를 반환한다.") { webClient .get() .uri("/quiz/{id}", ID) .exchange() .expectStatus() .isNotFound .expectBody<ErrorResponse>() } } } }}
앞서 구현한 ControllerTest에서 DescribeSpec을 상속받았기 때문에 DCI 패턴을 사용할 수 있는데요.
테스트 코드는 DescribeSpec의 생성자 내부나 init 블록 내부에 작성하게 됩니다.
@MockkBeanprivate lateinit var quizService: QuizService
@MockkBean은 Mockito의 @MockBean처럼 모의 객체를 Bean으로 등록해주는 어노테이션입니다.
Kotlin에서는 lateinit을 통해 지연 초기화를 사용해야 모의 객체를 생성하고 주입받을 수 있습니다.
every { quizService.getQuizById(any()) } returns Mono.just(createQuizResponse())
every()와 return()은 Mockk에서 모의 객체의 동작을 설정합니다.
해당 스코프 내에서 quizService.getQuizById()를 호출하면 항상 Mono.just(createQuizResponse())를 반환하도록 했습니다.
검증하는 부분은 WebTestClient의 기능을 사용했습니다.
모든 API는 응답이 존재하므로 항상 상태 검증을 수행합니다.
테스트 결과는 JUnit에서 @Nested를 사용한 것처럼 계층 구조로 표시됩니다.
Service 테스트
QuizServiceTest.kt
class QuizServiceTest : BehaviorSpec() { private val quizRepository = mockk<QuizRepository>() private val quizService = QuizService( quizRepository = quizRepository ) override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf init { Given("챕터와 각각의 챕터에 속하는 퀴즈들이 존재하는 경우") { val quiz = createQuiz() .also { every { quizRepository.findById(any<String>()) } returns it } val quizResponse = QuizResponse(quiz) When("유저가 특정 퀴즈를 조회하면") { val result = StepVerifier.create(quizService.getQuizById(ID)) Then("해당 퀴즈가 조회된다.") { result.expectSubscription() .expectNext(quizResponse) .verifyComplete() } } } }}
서비스 계층 테스트는 BDD 패턴을 사용하므로 BehaviorSpec을 상속받았습니다.
컨트롤러 계층 테스트와 마찬가지로 init 블록 내에 테스트 코드를 작성합니다.
override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf
서비스 계층 테스트에서는 컨트롤러 계층 테스트와 달리 상태 검증 외에도 행위 검증이 존재합니다.
이때, 이전 테스트에서 행위 검증을 시도하는 메서드를 호출해버리면 다음 테스트의 행위 검증에도 영향이 갈 수 있습니다.
이를 방지하기 위해 isolationMode를 IsolationMode.InstancePerLeaf로 설정해 테스트들을 서로 완전히 격리합니다.