Search

코틀린의 테스트도구 Kotest에 대해 알아보자

목차

What is Kotest?(feat. Junit)

TDD도 겨우 익숙해졌는데 Junit을 이용한 테스트 도구가 더 익숙한데 왜 Kotest를 사용해야 할까?
단지 힙(Heap)해서? 아니면 코틀린 용 이라고 하니까?
사실 필자 기준에서 kotest의 가장 큰 장점은 스토리텔링이 편하게 가능하다는 점이다.
Junit을 이용해서 layered architecture의 BDD방식을 적용하려면 다음과 같이 작성을 해야 한다.
@DisplayName("updateAccount 메서드는") @Nested class Describe_updateAccount { @DisplayName("등록 된 회원의 정보를 수정 하려는 경우") @Nested class Context_with_exists_account { @DisplayName("수정 할 정보가 유효한 경우, 정보가 수정되고 회원 정보를 반환 한다.") @ParameterizedTest @ArgumentsSource(ProvideValidAccountArguments.class) void updateAccountWithValidData(String email, String name, String password) { final Account targetAccount = accounts.get(0); final AccountUpdateData updateData = new AccountUpdateData(targetAccount.getId(), email, name, password); final Account updatedAccount = accountService.updateAccount(targetAccount.getId(), updateData); assertThat(updatedAccount.getId()).isEqualTo(targetAccount.getId()); assertThat(updatedAccount.getName()).isEqualTo(name); assertThat(updatedAccount.getEmail()).isEqualTo(email); assertThat(updatedAccount.getPassword()).isEqualTo(password); } @DisplayName("수정 할 정보가 유효하지 않은 경우, 예외를 던진다.") @ParameterizedTest @ArgumentsSource(ProvideInvalidAccountArguments.class) void updateAccountWithInvalidData(String email, String name, String password) { final Account targetAccount = accounts.get(0); final AccountUpdateData updateData = new AccountUpdateData(targetAccount.getId(), email, name, password); assertThatThrownBy(() -> accountService.updateAccount(targetAccount.getId(), updateData)) .isInstanceOf(InvalidAccountArgumentException.class); } } @DisplayName("등록 안 된 회원의 정보를 수정 하려는 경우, 예외를 던진다.") @ParameterizedTest @ArgumentsSource(ProvideValidAccountArguments.class) void updateAccountWithNotExistsIdAndValidData(String email, String name, String password) { final AccountUpdateData updateData = new AccountUpdateData(Long.MAX_VALUE, email, name, password); assertThatThrownBy(() -> accountService.updateAccount(Long.MAX_VALUE, updateData)) .isInstanceOf(AccountNotFoundException.class) .hasMessage(String.format(AccountNotFoundException.DEFAULT_MESSAGE, Long.MAX_VALUE)); } }
Java
복사
회원 정보 수정 AcceptanceTest
계층 구조로 BDD 방식의 인수테스트는 완성되었다. 하지만 중복되는 코드와 애노테이션들로 인해 작성에 피로감을 느낄 수 밖에 없었고, 이는 해당 인수 테스트에 대한 작성을 꺼리게 하는 일부 요인이 될 수 밖에 없었다.
그럼 Kotest를 활용한 테스트를 확인해보자.
class RacingServiceTest : BehaviorSpec({ Given("유효한 횟수와 자동차 참가자가 제공된다.") { val info = RacingGameRequest(numberOfRound = 5, carNames = listOf("a", "b", "c", "d", "e")) And("전진 전략이 항상 전진을 반환한다.") { val racingService = RacingService { DirectionType.STRAIGHT } When("경주를 진행 했을 경우") { val actual = racingService.racing(info) Then("전달받은 자동차 대수만큼 자동차를 생성하고 결과를 반환한다.") { actual.racingHistories shouldHaveSize 5 actual.racingHistories.mapIndexed { idx, info -> info.records.forEach { it.value shouldBe Distance(idx + 1L) } } actual.winners shouldBe arrayOf(Name("a"), Name("b"), Name("c"), Name("d"), Name("e")) } } } } })
Kotlin
복사
Kotest를 활용한 자동차 경주 게임 BDD 테스트
훨씬 간결해진 것 같지 않은가?
테스트 대상이 다르긴 하지만, 딱 봐도 코드의 양이 확연하게 줄어든 걸 볼 수 있다.
이처럼 Junit을 활용할 때와 Kotest를 활용할 때의 차이점 중 가장 큰 부분은, 간결함이다.
Kotest가 강점으로 밀고 있는 대부분의 기능들은 JUnit 으로도 충분히 다 구현 가능하다. 하지만, 복잡하고 길고 지루하다. 그렇기에 Kotest는 코틀린을 사용한다면 충분히 고려 해 볼 만한 테스트 프레임워크이다.

참고: 참고하면 좋을 아티클

Kotest가 밀고 있는 장점들

Kotest는 코틀린을 위한 테스트 프레임워크로써 다음과 같은 장점들을 근거로 사용을 권장하고 있다.

1. 유연성

: Kotest는 다양한 테스트 스타일을 지원한다.
위에서 보여준 행위 주도 테스트(Behavior Driven Development, BDD)뿐 아니라 WordSpec, FunSpec, AnnotationSpec, FreeSpec등 다양한 스타일을 지원하며 사용자가 원하는 방식으로 테스트를 할 수 있도록 도와준다.

2. 강력하고 다양한 검증 라이브러리

: Kotest 는 복잡한 표현식이나, 컬렉션, 예외 등을 검증하는데 사용할 수 있는 검증 라이브러리(assertion) 제공한다. 그래서 개발자는 다양한 검증 상황에서 적절한 검증 도구를 사용하여 기능을 검증할 수 있다.

3. 프로퍼티 기반 테스트

: Kotest는 프로퍼티 기반 테스트를 지원한다. 이는 임의의 입력값을 만들어 코드의 유효성을 검사하는 방식으로 다양한 경우의 수를 체계적으로 테스트할 수 있도록 해준다.
class MyTests : PropertySpec({ forAll { a: Int, b: Int -> (a + b) should beGreaterThan(a) (a + b) should beGreaterThan(b) } })
Kotlin
복사

4. 반복 및 중첩 테스트

: Kotest는 반복 및 중첩 테스트를 지원하기에 여러 복잡한 테스트 케이스를 더 쉽고 간결하게 관리할 수 있다.
class MyTests : FunSpec({ context("Some context") { test("Test 1") { /*...*/ } test("Test 2") { /*...*/ } } })
Kotlin
복사

Get Start

1. based JVM/Gradle

1-1. 프레임워크 설정

gradle + Groovy 사용하는 경우 (build.gradle)
test { useJUnitPlatform() }
Groovy
복사
Gradle + Kotlin 사용하는 경우 (build.gradle.kts)
tasks.withType<Test>().configureEach { useJUnitPlatform() }
Kotlin
복사
feat: 그냥 tasks에 test { useJUnitPlatfor() } 추가해도 정상 동작 하긴 한다.
dependency 추가
testImplementation 'io.kotest:kotest-runner-junit5:$version'
Kotlin
복사
작성일자 기준 필자는 5.4.0 사용중이다.

1-2. 검증 라이브러리 설정

dependency 추가
testImplementation 'io.kotest:kotest-assertions-core:$version' testImplementation 'io.kotest:kotest-property:$version'
Kotlin
복사

2. based JVM/Maven

2-1. 프레임워크 의존성 추가

플러그인 추가
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> </plugin>
XML
복사
의존성 추가
<dependency> <groupId>io.kotest</groupId> <artifactId>kotest-runner-junit5-jvm</artifactId> <version>{version}</version> <scope>test</scope> </dependency>
XML
복사

2-2. 검증 라이브러리 추가

dependency 추가
<dependency> <groupId>io.kotest</groupId> <artifactId>kotest-assertions-core-jvm</artifactId> <version>{version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.kotest</groupId> <artifactId>kotest-property-jvm</artifactId> <version>${version}</version> <scope>test</scope> </dependency>
XML
복사

3. Intelli J 사용시 플러그인 설치

Preference → Plugins → ‘Kotest’ 검색 → 설치

Testing Style

코틀린의 강점이 여러 유연한 테스트 스펙을 제공한다는 것이지만, 이 이야기는 곧 너무 많아서 뭘 골라야 할 지 혼동 될 수도 있다는 것이다. 그렇기에 각각의 스펙에 대해 간단하게 어떤 형태 인지만 확인해보자. 더 자세한건 공식 문서를 참고하자.
1.
StringSpec
class StringTest: StringSpec({ "strings.length should return size of string" { "hello".length shouldBe 5 } })
Kotlin
복사
문자열은 JUnit의 @DisplayName을 대체한다.
2.
FunSpec
class FunSpecTest: FunSpec ({ test("문자열 길이 테스트") { val actual = "abcdefg " actual.length shouldBe 10 } })
Kotlin
복사
3.
AnnotationSpec
class AnnotationSpecTest: AnnotationSpec () { @BeforeEach fun beforeEach() { println("start beforeEach") } @Test fun stringTest() { val actual = "abcdefg " actual.length shouldBe 10 } }
Kotlin
복사
JUnit 의 테스트 방식과 유사하다
4.
DescribeSpec
class MyTests : DescribeSpec({ describe("score") { it("start as zero") { // test here } describe("with a strike") { it("adds ten") { // test here } it("carries strike to the next frame") { // test here } } describe("for the opposite team") { it("Should negate one score") { // test here } } } })
Kotlin
복사
describe가 테스트 대상을 지칭하고 내부적으로 조건이나 상황을 설명할 때는 context 를 사용하며, 테스트 본체에는 it 을 사용해서 테스트 스토리텔링을 할 수 있다.
만약 테스트 대상 disabled를 적용하고 싶을 경우 describe가 아닌 xdescribe를 사용하면 된다.
class MyTests : DescribeSpec({ describe("this outer block is enabled") { xit("this test is disabled") { // test here } } xdescribe("this block is disabled") { it("disabled by inheritance from the parent") { // test here } } })
Kotlin
복사
5.
BehaviorSpec
class NameTest : BehaviorSpec({ Given("Name 객체를 생성할 때") { When("5글자 이내의 문자열을 전달하면") { val actual = Name("12345") Then("정상적으로 생성된다") { actual shouldBe Name("12345") } } When("5글자 이상의 문자열을 전달하면") { Then("예외를 던진다") { assertThrows<IllegalArgumentException> { Name("123456") } } } } })
Kotlin
복사
행위 주도 테스트 방식으로 JUnit의 Nested 애노테이션을 활용한 계층 구조 테스트 방식과 유사하지만 더 편하게 사용이 가능하다.
6.
그 외의 스펙들
: 이 외에도 많은 테스트 스타일이 있는데 이는 공식 문서를 참고하도록 하자.

assertion

테스트할 항목에 대해 적절한 테스트를 하기 위해서는 검증 라이브러리에서 제공하는 여러 검증 함수를 숙지하고 있을 필요가 있다.
class MatcherTest : StringSpec() { init { -----------------------String Matchers ------------------------- // 'shouldBe'는 동일함을 체크하는 Matcher "hello world" shouldBe haveLength(11) // length가 11이어야 함을 체크 "hello" should include("ll") // 파라미터가 포함되어 있는지 체크 "hello" should endWith("lo") // 파라미터가 끝의 포함되는지 체크 "hello" should match("he...") // 파라미터가 매칭되는지 체크 "hello".shouldBeLowerCase() // 소문자로 작성되었는지 체크 -----------------------Collection Matchers ------------------------- val list = emptyList<String>() val list2 = listOf("a", "b", "c") val map = mapOf<String, String>(Pair("a", "1")) list should beEmpty() // 원소가 비었는지 체크 합니다. list2 shouldBe sorted<String>() // 해당 자료형이 정렬 되었는지 체크 map should contain("a", "1") // 해당 원소가 포함되었는지 체크 map should haveKey("a") // 해당 key가 포함되었는지 체크 map should haveValue("1") // 해당 value가 포함되었는지 체크 } }
Kotlin
복사
더 다양한 검증식은 다음 문서를 참고하자