Kotest(코테스트)란?
코틀린에서 사용 가능한 언어의 특징을 이용함으로써, Kotest는 더욱 강력하지만 간단한 테스트 정의 방법을 제공한다.
KoTest에서 테스트는 테스트 로직을 담고 있는 단순한 함수일 뿐이며, 테스트 메서드는 번거롭게 수동으로 정의하는 것이 아니라 Kotest의 DSL을 사용해서 정의한다.
class TestClass : FunSpec({
test("Hello") {
2 + 2 shouldBe 4
}
})
위 같이 코틀린에서는 test를 여러 개 중첩적으로 사용할 수 있는 여러개의 Spec들이 있다. Java에서는 여러 가지의 테스트들을 모두 정의하여 해야 했지만, 코테스트를 사용하면 쉽게 테스트 환경을 구축할 수 있다.
공식 문서에서 여러 가지의 Spec들을 확인할 수 있다.
Testing Styles | Kotest
Kotest offers 10 different styles of test layout. Some are inspired from other popular test frameworks to make you feel right at home.
kotest.io
위 중 자주 사용되는 Spec들을 위주로 살펴 볼 것이고, 실제 서비스에서 어떻게 사용하는지 고민해보고 구성해볼 것이다.
FunSpce의 경우 자바의 간소화 버전처럼 사용할 수 있다. 여기서 제공하는 것은 test, xtest, context를 사용할 수 있다.
각각 context를 지정해서 사용하는 방법도 있으며 xtest와 xcontext를 통해 테스트에서 제외시키는 방법도 있다.
class MyTests : FunSpec({
test("String length should return the length of the string") {
"sammy".length shouldBe 5
"".length shouldBe 0
}
xtest("String length should return the length of the string") {
"sammy".length shouldBe 5
"".length shouldBe 0
}
})
ShouldSpec도 FunSpec과 매우 유사한 구조를 가조 있다.
class MyTests : ShouldSpec({
should("return the length of the string") {
"sammy".length shouldBe 5
"".length shouldBe 0
}
})
Behavior Spec은 given, when, then의 형식으로 어쩌면 일반적으로 사용하는 테스트 기법과 가장 유사한 방법을 가진 Spec일 수 있다. 이는 BDD 기반 테스트를 하는 사람에게는 굉장히 친숙하게 느껴질 수 있다.
class MyTests : BehaviorSpec({
context("a broomstick should be able to be fly and come back on it's own") {
given("a broomstick") {
`when`("I sit on it") {
then("I should be able to fly") {
// test code
}
}
`when`("I throw it away") {
then("it should come back") {
// test code
}
}
}
}
})
Annotation Spec은 JUnit과 가장 유사하게 생겼으며 어노테이션을 지정하여 테스트를 구성하는 것이다.
class AnnotationSpecExample : AnnotationSpec() {
@BeforeEach
fun beforeTest() {
println("Before each test")
}
@Test
fun test1() {
1 shouldBe 1
}
@Test
fun test2() {
3 shouldBe 3
}
}
Kotest 코드가 간결한 이유
Kotest는 위 처럼 여러 가지 Spec들 중 하나를 선택하여 테스트를 진행하면 된다. 그리고 이는 위에서 언급한 것처럼 간결하고 반복적인 코드를 구성하지 않아도 테스트의 구성을 명확하게 표현할 수 있다. 다음 예시를 확인하자
// 1번
class CalBehaviorSpec : BehaviorSpec({
val stub = Calculator()
Given("calculator") {
// before Each 라고 생각 하기
val expression = "1 + 2"
When("1과 2를 더한면") {
val result = stub.calculate(expression)
Then("3이 반환 된다") {
result shouldBe 3
}
}
When("1 + 2 결과 와 같은 String 입력시 동일한 결과가 나온다") {
val result = stub.calculate(expression)
Then("해당 하는 결과값이 반환된다") {
result shouldBe stub.calculate("1 + 2")
}
}
}
})
// 2번
class CalBehaviorSpec {
Calculator stub = Calculator();
String expression;
@BeforeEach() {
this.expression = "1 + 2";
}
void calculator_test() {
// given
// when
int result = stub.calculate(expression);
// then
assertThat(result).isEqualTo(3);
}
void addOneTowResultStringSame_test() {
// given
// when
int result = stub.calculate(expression);
// then
assertThat(result).isEqualTo(stub.calculate("1 + 2"));
}
}
위는 Kotest의 BehaviorSpec을 이용해서 하나의 Given을 통해 여러 가지의 When과 Then을 구성하여 테스트를 구성한것이다.
- Junit인 경우 @BeforeEach를 통해서 모든 test code의 중복로직을 실행 할 수 있다.
- 만약 특정 test code에서 @BeforeEach가 달라지는 경우 Test Class를 분리 하거나, @Nested class를 정의하고 @BeforeEach 를 추가 정의 해야 하는 한계가 존재한다.
- kotest인 경우 Given("") 하위에 작성한 Code는 Given 하위에 존재하는 When Then 모두 사용할 수 있으므로 @BeforeEach와 동일한 결과를 가져다 주고, 위와 같이 간결하게 구현할 수 있다.
결과적으로 어떤게 더 명확하고 간결한가?라고 묻는 다면 1번을 고를 것이다.
Kotest Assertions
kotest에서 제공하는 Assertions를 이용해서 쉽고 간편하게 이를 검증할 수 있다.
// 기본형
name shouldBe "jjlee" // assertThat(name).isEqualTo("jjlee")
name shouldNotBe null // assertThat(name).isNull()
// 여러 조건을 chaining 할 수 있습니다
name.shouldHaveExtension("lee").shouldStartWith("jj").shouldBeLowerCase()
// Exceptions
shouldThrow {
assertThrows { }
// code in here that you expect to throw an IllegalAccessException
}
그럼 Kotest를 활용한 실제 유닛 테스트는?
여기서 실제 서비스 로직을 테스트 하고 이를 작성할 때 필요한 것이 존재한다. 바로 Mocking이다. 이를 코틀린에서 쉽게 지원하는 것이 바로 MockK라는 테스트 라이브러리이다.
MockK
Provides DSL to mock behavior. Built from zero to fit Kotlin language. Supports named parameters, object mocks, coroutines and extension function mocking
mockk.io
장점은 다음과 같다.
- Kotlin과의 호환성
- MockK는 Kotlin과 완벽하게 호환되어 Kotlin 특징을 완전히 활용할 수 있다. 예를 들어, Null-Safety, Extension Function, Coroutine, Data class 등 Kotlin에서 사용되는 다양한 기능을 지원한다.
- 간결성
- 코드를 간결하게 작성할 수 있도록 도와준다. 예를 들어, Stubbing 및 Verification을 한 줄로 작성할 수 있으며, DSL (Domain Specific Language)을 제공하여 가독성을 높여준다.
- 직관성
- MockK는 명확하고 직관적인 API를 제공하여 테스트 코드를 작성하는 데 도움을 준다. Mockito와 같은 다른 모킹 라이브러리보다 더욱 직관적이며, MockK를 처음 사용하는 개발자들도 쉽게 접근할 수 있다.
- 성능
- MockK는 Kotlin의 Inline Function 및 Inline Class 기능을 사용하여 런타임 오버헤드를 최소화하며, 빠른 속도로 테스트를 실행할 수 있다.
예제를 확인하며 어떻게 실제 서비스 로직에서 사용하는지 확인하자. (필자는 BehaviorSpec을 사용하였다.)
class CarServiceTest : BehaviorSpec({
val carRepository = mockk<CarRepository>() // mocking
val carService = CarService(carRepository)
beforeTest {
clearAllMocks()
}
given("새로운 자동차를 생성할 때") {
`when`("올바른 정보를 입력하면") {
val brand = "Hyundai"
val model = "Sonata"
val year = 2024
val expectedCar = Car(brand = brand, model = model, year = year)
every { carRepository.save(any()) } returns expectedCar
then("자동차가 성공적으로 생성된다") {
val result = carService.createCar(brand, model, year)
result shouldBe expectedCar
verify(exactly = 1) { carRepository.save(any()) }
}
}
`when`("잘못된 연도를 입력하면") {
val invalidYear = 1899
then("예외가 발생한다") {
shouldThrow<IllegalArgumentException> {
carService.createCar("Hyundai", "Sonata", invalidYear)
}
verify(exactly = 0) { carRepository.save(any()) }
}
}
}
given("자동차를 대여할 때") {
val carId = 1L
`when`("사용 가능한 자동차를 대여하면") {
val availableCar = Car(id = carId, brand = "Hyundai", model = "Sonata", year = 2024)
val rentedCar = availableCar.copy(status = CarStatus.RENTED)
every { carRepository.findById(carId) } returns availableCar
every { carRepository.update(any()) } returns rentedCar
then("대여 상태로 변경된다") {
val result = carService.rentCar(carId)
result.status shouldBe CarStatus.RENTED
verify(exactly = 1) {
carRepository.findById(carId)
carRepository.update(any())
}
}
}
`when`("이미 대여중인 자동차를 대여하려 하면") {
val rentedCar = Car(
id = carId,
brand = "Hyundai",
model = "Sonata",
year = 2024,
status = CarStatus.RENTED
)
every { carRepository.findById(carId) } returns rentedCar
then("예외가 발생한다") {
shouldThrow<IllegalArgumentException> {
carService.rentCar(carId)
}
verify(exactly = 1) { carRepository.findById(carId) }
verify(exactly = 0) { carRepository.update(any()) }
}
}
}
given("자동차를 반납할 때") {
val carId = 1L
`when`("대여중인 자동차를 반납하면") {
val rentedCar = Car(
id = carId,
brand = "Hyundai",
model = "Sonata",
year = 2024,
status = CarStatus.RENTED
)
val returnedCar = rentedCar.copy(status = CarStatus.AVAILABLE)
every { carRepository.findById(carId) } returns rentedCar
every { carRepository.update(any()) } returns returnedCar
then("사용 가능 상태로 변경된다") {
val result = carService.returnCar(carId)
result.status shouldBe CarStatus.AVAILABLE
verify(exactly = 1) {
carRepository.findById(carId)
carRepository.update(any())
}
}
}
}
})
그럼 Mockito는 사용이 불가한가?
정확히 말하자면 사용이 불가능한 것은 아니다. 하지만 코틀린을 사용하여 코드를 구성한 뒤, Mockito를 기반으로 테스트 할 때 문제가 발생할 가능성은 있다.
Mocking an extension function with Mockito · Issue #1481 · mockito/mockito
Mocking an extension function with Mockito · Issue #1481 · mockito/mockito
How can I mock an extension function in Kotlin with Mockito? It doesn't seem to work nicely. This is my extension function fun <T> CrudRepository<T, String>.findOneById(id: String): T? { val o = fi...
github.com
코틀린에서는 확장 함수를 정의할 때, 해당 함수를 정적 메서드로 변환 해주고, Mockito 라이브러리는 정적 메서드를 mocking 하는 것을 지원하지 않고 있다.
그래서 해당 Issues에서도 mockk 사용을 권장하고 있다.
또한, 코틀린에서는 간편하게 싱글턴 객체를 사용할 수 있는 Object class를 제공한다. 이는 주로 유틸 클래스를 구성하거나 싱글턴으로 관리해야할 객체들이 존재할 때 많이 사용되며, Mockito를 사용하면 MockedStatic을 사용하는데 이를 사용해서 테스트를 하면 실패한다.
이는 mockk의 mockObject로 손쉽게 해결 가능하다.
결론
결과적으로 우리 팀에서는 kotest를 기반으로 mockk와 함께 테스트 코드를 작성한다. 이는 간결함과 효율성을 통해 테스트 코드 작성에 대한 거부감을 생각보다 많이 줄이는 효과를 보였다. 또한, 공용 테스트 클래스를 만들어서 이를 직접적으로 가져다 쓰기만하면 되는 클래스 헬퍼도 만들 수 있다.
더하여, 공용으로 테스트 클래스에서 사용할 도메인 객체들을 제공하는 헬퍼로 만들어 더욱 효율성을 높일 수 있다.
이처럼 코틀린을 사용하는 환경에서는 kotest를 기반으로 테스트 환경을 구성하는 것이 개발자 입장에서는 굉장히 좋은 선택지가 될 수 있다고 느꼈다.
'서버 개발(생각과 구현)' 카테고리의 다른 글
Spring Retry(재시도)가 필요한 경우 (0) | 2024.12.20 |
---|---|
외부 API 호출을 위한 OpenFeign, RestClient, WebClient들의 차이점 (0) | 2024.12.20 |
Kotlin-jdsl과 Querydls의 차이점과 선택 과정 (0) | 2024.12.19 |
코틀린을 활용한 JPA 엔티티 전략 고민 과정 (0) | 2024.12.19 |
Spring Eureka를 사용해서 MSA 체험기 (2) (0) | 2024.12.19 |