Search

Kotest 기반 테스트 환경 구축

1. DSL이란

가. 기본 개념

1) 범용 프로그래밍 언어와 영역 특화 언어의 차이점
범용 프로그래밍 언어(general-purpose programming language)는 대다수의 애플리케이션 개발에 사용되는 언어로, 예를 들면 자바, C# 등이 있습니다. 그에 비해 영역 특화 언어(domain-specific language)는 특정 개발 영역에서만 사용되는 언어로, SQL이나 정규식 등이 있습니다
DSL은 특정 영역에서만 사용되므로, 범용 프로그래밍 언어보다는 선언적입니다. 간단한 데이터를 조회한다고 가정했을 때, SQL의 질의문과 Java의 조건문 및 반복문에서 그 차이점을 알 수 있습니다. 추가로, DSL은 범용 프로그래밍 언어의 몇몇 과정을 생략할 수 있기 때문에 더 간결합니다. 다른 말로, 객체 생성이나 메소드 정의와 같은 설정 작업을 생략할 수 있습니다.
DSL의 단점은 추가적인 학습 비용과 범용 프로그래밍 언어와의 호환성을 위해 추가적인 설정을 하는 비용 등이 발생하는 것입니다.
2) 내부 DSL의 특징
내부 DSL은 범용 프로그래밍 언어의 문법을 기반으로 하면서도, DSL의 선언적이며 간결한 특징을 그대로 가져갑니다. 다시 말해, DSL의 장점은 그대로 가져가면서 단점을 상쇄합니다.

나. Kotest의 DSL 특징

1) 중위 호출 연쇄의 활용 방법
‘중위 호출 연쇄’ 문법을 활용하면 마치 영어 문장을 읽는 것처럼 가독성을 향상시킬 수 있습니다.
‘중위 호출’의 예시는 아래와 같습니다.
// 중위 호출 1 to "one" // 일반 메소드 호출 1.to("one")
Kotlin
복사
‘중위 호출 연쇄’에 대한 예시는 아래와 같습니다.
여기서 should와 with 각각의 함수를 연속적으로 중위 호출합니다. 참고로 start는 테스트 대상이 되는 객체 인스턴스입니다. 중위 호출 연쇄가 가능한 이유는 should 함수가 with 함수를 호출할 수 있는 래퍼 객체를 반환하기 때문입니다.
// 중위 호출 연쇄 "kotlin" should start with "kot" // 일반 메소드 호출 연쇄 "kotlin".should(start).with("kot")
Kotlin
복사

2. 환경 설정

가. 기본 설정

1) Kotest 기본 설정
DSL(Domain Specific Language) 기반 테스트 코드를 작성하기 위해 DSL을 지원하는 의존성이 필요합니다. DSL을 지원하는 테스트 프레임워크로 Kotest와 목 객체 생성을 지원하는 MockK에 대한 의존성 추가가 필요합니다. 그레이들 파일 안에 아래와 같이 의존성을 추가해주세요. 참고로 JUnit과 Mockito의 경우 DSL 기반으로 테스트를 실행하실 수 없습니다.
DSL에 대한 기본적인 내용은 이하 ‘4. DSL이란’ 챕터를 참고해주세요.
// build.gradle.kts val mockkVersion = "1.13.3" val kotestVersion = "5.5.4" dependencies { testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testImplementation("io.mockk:mockk:$mockkVersion") }
Kotlin
복사

나. 추가 설정

1) extension 설정
DSL 기반 테스트 환경에서 스프링 컴포넌트를 autowired 하기 위해 아래와 같이 의존성을 추가해야 합니다.
// build.gradle.kts val kotestExtensionVersion = "1.1.2" dependencies { testImplementation("io.kotest.extensions:kotest-extensions-spring:$kotestExtensionVersion") }
Kotlin
복사
테스트 코드 내부에 아래와 같은 설정이 추가적으로 필요합니다.
override fun extensions() = listOf(SpringExtension)
Kotlin
복사
테스트 코드 내 적용 예시입니다.
@DataJpaTest class OrderClassicalDslTest( @Autowired private val orderRepository: OrderRepository, @Autowired private val orderItemRepository: OrderItemRepository, @Autowired private val itemRepository: ItemRepository, ) : DescribeSpec() { override fun extensions() = listOf(SpringExtension) // .. 생략 .. }
Kotlin
복사
2) spring mockk 설정
MockK 초기화 부분을 어노테이션 기반으로 간략하게 작업하려면 아래와 같은 의존성을 추가적으로 설정해야 합니다.
// build.gradle.kts val springMockkVersion = "4.0.0" dependencies { testImplementation("com.ninja-squad:springmockk:$springMockkVersion") }
Kotlin
복사

3. 테스트 케이스 구성

가. JUnit과 Kotest의 적용 비교

1) JUnit 적용 테스트 코드 vs Kotest 적용 테스트 코드
JUnit 적용 코드
@Nested internal inner class `order 메소드는` { @Nested internal inner class `만약 주문금액이 5만원 이상이라면` { @BeforeEach fun setUp() { orderQuantity = 2 } @Test fun `주문금액에 배송료를 포함하지 않고 반환합니다`() { orderData.add(OrderData(itemId, orderQuantity)) val order = service.order(orderData) val actualPrice = order.price val expectedPrice = itemPrice.multiply(BigDecimal.valueOf(orderQuantity.toLong())) val actualStockQuantity = itemRepository.findByIdInLock(itemId).stockQuantity val expectedStockQuantity = stockQuantity - orderQuantity Assertions.assertThat(actualPrice).isEqualTo(expectedPrice) Assertions.assertThat(actualStockQuantity).isEqualTo(expectedStockQuantity) } } }
Kotlin
복사
Kotest 적용 코드
describe("order 메소드는") { context("만약 주문금액이 5만원 이상이라면") { beforeTest { orderQuantity = 2 } it("주문금액에 배송료를 포함하지 않고 반환합니다") { orderData.add(OrderData(itemId, orderQuantity)) val order = service.order(orderData) val actualPrice = order.price val expectedPrice = itemPrice.multiply(BigDecimal.valueOf(orderQuantity.toLong())) val actualStockQuantity = itemRepository.findByIdInLock(itemId).stockQuantity val expectedStockQuantity = stockQuantity - orderQuantity actualPrice shouldBe expectedPrice actualStockQuantity shouldBe expectedStockQuantity } } }
Kotlin
복사

나. Mockito와 MockK의 적용 비교

1) Mockito 적용 테스트 코드 vs MockK 적용 테스트 코드
Mockito 적용 코드
@BeforeEach fun setUp() { BDDMockito.given(itemRepository.findByIdInLock(itemId)).willReturn(item) } @Nested internal inner class `order 메소드는` { @Nested internal inner class `만약 주문금액이 5만원 이상이라면` { @BeforeEach fun setUp() { orderQuantity = 2 } @Test fun `주문금액에 배송료를 포함하지 않고 반환합니다`() { orderData.add(OrderData(itemId, orderQuantity)) val order = service.order(orderData) val actualPrice = order.price val expectedPrice = itemPrice.multiply(BigDecimal.valueOf(orderQuantity.toLong())) Assertions.assertThat(actualPrice).isEqualTo(expectedPrice) Mockito.verify(itemRepository, times(1)).findByIdInLock(itemId) } } }
Kotlin
복사
MockK 적용 코드
beforeTest { every { itemRepository.findByIdInLock(itemId) } returns item } describe("order 메소드는") { context("만약 주문금액이 5만원 이상이라면") { beforeTest { orderQuantity = 2 } it("주문금액에 배송료를 포함하지 않고 반환합니다") { orderData.add(OrderData(itemId, orderQuantity)) val order = service.order(orderData) val actualPrice = order.price val expectedPrice = itemPrice.multiply(BigDecimal.valueOf(orderQuantity.toLong())) actualPrice shouldBe expectedPrice verify(exactly = 1) { itemRepository.findByIdInLock(itemId) } } } }
Kotlin
복사

4. 이슈 대응

가. 테스트 실행버튼 비활성화 이슈

1) 현상
IntelliJ CE에서 DSL 기반의 테스트 코드를 실행하는 초록색 실행 버튼이 활성화되지 않습니다. 아래 이미지를 참고하시면, 왼쪽의 비 DSL 테스트의 경우 초록색의 테스트 실행 버튼이 활성화되어 있습니다. 그러나 오른쪽의 DSL 기반의 테스트 코드에는 해당 버튼이 비활성화되어 있습니다.
참고) 이미지
2-1) 해결
Kotest 플러그인을 설치하면 테스트 실행 버튼이 추가됩니다.
2-2) 해결
‘DslTest’로 끝나는 모든 테스트를 대상으로 실행하도록 테스트 실행 단위에 대한 작업을 추가하였습니다. ‘구성 편집’ - ‘실행/디버그 구성’ - ‘실행’의 순서로 테스트 구성 작업을 진행하실 수 있습니다.
참고) 이미지

나. 테스트 케이스 간 영향

1) 현상
Kotest로 구성한 여러 테스트 케이스 간에 간섭이 발생하여 테스트 실행마다 결과가 달라집니다.
2) 원인
기본적인 격리 단계가 싱글톤으로 설정되어 있기 때문에 테스트 케이스 실행 간에 공통의 인스턴스를 활용하고 있습니다.
3) 대응
아래와 같은 설정을 테스트 코드에 추가하여, 테스트 케이스별로 별도의 인스턴스를 사용하도록 설정합니다.
isolationMode = IsolationMode.InstancePerLeaf
Kotlin
복사
적용 예시는 아래와 같습니다.
internal class OrderLondonDslTest : DescribeSpec({ isolationMode = IsolationMode.InstancePerLeaf // .. 생략 .. )}
Kotlin
복사

참고자료

Kotest
기본 설정, 공식문서 참고
extensions 추가 설정, 공식문서 참고
Kotlin Style Test Code, 우형블로그 참고
Kotlin IN ACTION - 드미트리 제메로프, 스베트라나 이사코바