코프링에서의 Kotest (1)

후니팍·2024년 7월 7일
0
post-thumbnail
post-custom-banner

회사에서 코틀린+스프링 조합으로 백엔드 개발을 하고 있습니다.
코틀린을 사용하긴 하지만 Junit을 이용하여 테스트 코드를 작성하고 있습니다.
Junit 보다 Kotest가 편한 부분은 Kotest로 적용시켜보고자 이에 대해 학습해보았습니다.

Kotest란?

코틀린에서 사용가능한 테스트 프레임워크입니다.
Kotest의 DSL을 이용하여 테스트 코드를 가독성 좋게 작성하도록 돕습니다.

class MyTest : StringSpec({
    "2 + 2 = 4 이다" {
        val sum = 2 + 2
        sum shouldBe 4
    }
    
    "숫자를 0으로 나누면 예외가 발생한다" {
        shouldThrow<ArithmeticException> {
            val result = 1 / 0
        }
    }
})

위의 예시와 같이 shouldBe, shouldThrowStringSpec를 사용하여 간단하고 명료하게 테스트 코드를 작성할 수 있습니다.
그리고 Kotest는 다양한 테스트 스타일을 제공하여 개발자가 선호하는 테스트 작성 방식을 선택할 수 있게 합니다.


설치

    dependencies {
        testImplementation("io.kotest:kotest-runner-junit5-jvm:${Versions.KOTEST}")
        testImplementation("io.kotest:kotest-assertions-core:${Versions.KOTEST}")
        testImplementation("io.kotest.extensions:kotest-extensions-spring:${Versions.KOTEST_SPRING}")
    }

저는 build.gradle.kts 파일에서 위와 같은 의존성을 추가했습니다. 마지막의 spring 관련 kotest는 스프링을 사용할 때 추가적으로 사용할 수 있습니다. 오늘 다룰 내용에서는 굳이 필요하지 않습니다.

gradle에 추가를 했다면 intellij 플러그인으로 Kotest를 설치해야합니다. 그렇지 않으면 테스트 실행을 할 수 없습니다.


다양한 테스트 스타일

kotest에서는 10개의 테스트 스타일을 제공하고 있습니다.

Test StyleInspired By
Fun SpecScalaTest
Describe SpecJavascript frameworks and RSpec
Should SpecA Kotest original
String SpecA Kotest original
Behavior SpecBDD frameworks
Free SpecScalaTest
Word SpecScalaTest
Feature SpecCucumber
Expect SpecA Kotest original
Annotation SpecJUnit

간단히 몇 종류의 스타일만 알아보도록 하겠습니다.

FunSpec

class MyFunSpecTest : FunSpec({
    test("2 + 2 = 4 이다") {
        val sum = 2 + 2
        sum shouldBe 4
    }

    test("숫자를 0으로 나누면 예외가 발생한다") {
        shouldThrow<ArithmeticException> {
            val result = 1 / 0
        }
    }
})

StringSpec와 큰 차이가 없어보입니다. 그저 test라는 함수 이름을 명시하여 테스트라는 것을 한눈에 보이도록 했습니다.

BehaviorSpec

class MyBehaviorSpecTest : BehaviorSpec({
    given("2가 2개 주어졌을 때") {
        `when`("두 숫자를 더하면") {
            then("4가 된다") {
                val sum = 2 + 2
                sum shouldBe 4
            }
        }
    }

    given("1과 0이 주어졌을 때") {
        `when`("1을 0으로 나누면") {
            then("예외가 발생한다") {
				shouldThrow<ArithmeticException> {
            		val result = 1 / 0
                }
            }
        }
    }
})

BDD 스타일의 테스트입니다. 많은 개발자들에게 익숙한 given-when-then 키워드를 사용하여 테스트 코드를 작성할 수 있습니다.
when의 경우 코틀린의 키워드와 겹치기 때문에 백틱을 사용하고 있습니다. 이를 보완하고자 kotest에서는 아래와 같이 대문자로도 사용할 수 있게끔 제공해줍니다.

    Given("2가 2개 주어졌을 때") {
        When("두 숫자를 더하면") {
            Then("4가 된다") {
                val sum = 2 + 2
                sum shouldBe 4
            }
        }
    }

focus and bang

focus

kotest에서 focus를 사용하면 focus 적용된 테스트만 실행됩니다.

class FocusExample : StringSpec({
    "test 1" {
     // this will be skipped
    }

    "f:test 2" {
     // this will be executed
    }

    "test 3" {
     // this will be skipped
    }
})

예시처럼 함수 설명 앞에 f: 키워드를 붙이면 focus 모드가 되고, 전체 테스트를 실행시키더라도 focus 된 테스트만 실행됩니다. 특정 테스트에 집중하게 하려는 목적으로 만든 것 같은데, 유용할지는 모르겠습니다. 그냥 단축키 이용해서 테스트 하나만 실행시키면 되지 않나... 생각이 드는 기능입니다.

bang

focus와 반대로 실행하지 않을 테스트를 정하는 기능입니다. focus처럼 함수 설명 앞에 ! 만 붙이면 됩니다. 느낌표 하나만으로 해당 테스트를 실행하지 않을 수 있습니다.
Junit의 @Ignore 어노테이션과 비슷한 역할을 합니다.

class BangExample : StringSpec({

  "!test 1" {
    // this will be ignored
  }

  "test 2" {
    // this will run
  }

  "test 3" {
    // this will run too
  }
})

Junit에 비해 간단하게 사용할 수 있지만, 한 눈에 들어오지는 않을 것 같습니다. 실행되지 않는 테스트를 확인하고 싶을 때 확인할 수 있는 방법이 로그를 확인하는 것 말고는 없을 것 같습니다.

Junit을 사용할 때는 전체검색 cmd+shift+f@Ignore을 검색해 사용하고 있지 않는 테스트를 확인할 수 있지만, 느낌표는 코드에서 너무 많이 사용되는 키워드이기 때문에 쉽게 찾기 어려울 것 같습니다.
따라서 bang도 굳이 사용하지 않을 것 같습니다.


Isolation Modes

Kotest는 여러가지 격리 모드를 지원하여 테스트 간의 상호작용을 제어할 수 있습니다. Kotest의 격리 모드는 IsolationMode enum을 통해 설정할 수 있으며, 각 모드는 테스트 실행 방식에 영향을 미칩니다.

적용법

전역 변수의 값을 변경해주는 방식과 함수 override를 사용하는 방식, 총 2가지 방법으로 적용할 수 있습니다.

전역 변수 값 변경 방식

class MyTestClass : WordSpec({
 isolationMode = IsolationMode.SingleInstance
 // tests here
})

override 방식

class MyTestClass : WordSpec() {
  override fun isolationMode() = IsolationMode.SingleInstance
  init {
    // tests here
  }
}

override를 이용한 방식은 init 블록 내부에 테스트를 작성해야합니다. 초기화 문제 때문입니다.
만약 클래스 초기화 블록인 init 외부에 테스트를 작성하게 되면, 해당 테스트는 코틀린 객체가 초기화되기 전에 실행됩니다. 그렇다면 override가 적용되기 전에 테스트가 실행되겠죠? 따라서 init 블록 밖에서 테스트를 작성하게 되면 격리 모드가 올바르게 적용되지 않습니다.

다양한 격리 모드

  • IsolationMode.InstancePerTest
    • 각 테스트마다 새로운 테스트 인스턴스를 생성합니다.
    • 테스트 간 상태 공유가 없으며, 완전한 격리를 보장합니다.

  • IsolationMode.InstancePerLeaf
    • 각 리프(leaf) 테스트마다 새로운 인스턴스를 생성합니다.
    • 일반적으로 FunSpec과 같은 스타일에서 사용되며, 각 test 블록마다 인스턴스를 생성합니다.

  • IsolationMode.SingleInstance
    • 테스트 클래스 전체에서 하나의 인스턴스만 생성합니다.
    • 상태를 공유하는 테스트가 가능하지만, 테스트 간 상호작용이 발생할 수 있습니다.

InstancePerTest

InstancePerTest 모드는 모든 테스트 케이스에 대해 spec 인스턴스가 생성됩니다. 즉 모든 테스트 케이스가 독립적으로 실행됩니다. 아래의 테스트 코드가 있다고 가정하겠습니다.

class InstancePerTestExample : WordSpec() {

  override fun isolationMode(): IsolationMode = IsolationMode.InstancePerTest

  init {
    "a" should {
      println("Hello")
      "b" {
        println("From")
      }
      "c" {
        println("Sam")
      }
    }
  }
}

테스트를 실행시켰을 때 결과는 아래와 같습니다.

Hello
Hello
From
Hello
Sam

처음에는 "a"를 위한 spec 인스턴스를 생성하여 Hello를 출력합니다.
두번째로 "b"를 위한 spec 인스턴스를 생성하고 부모에서의 Hello와 b 내부에서의 From을 출력합니다.
세번째로 "c"를 위한 spec 인스턴스를 생성하고 부모에서의 Hello와 c 내부에서의 Sam을 출력합니다.

성능이 아쉬울 것 같아서 완전 격리가 필요한 상황이 아니라면 굳이 사용할 것 같지 않은 모드입니다.

InstancePerLeaf

InstancePerLeaf 모드는 모든 리프에 대해 spec 인스턴스가 생성됩니다. 이전의 InstancePerTest에서는 루트까지 격리했다면, InstancePerLeaf는 리프만 격리합니다.

class InstancePerLeafExample : WordSpec() {

  override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf

  init {
    "a" should {
      println("Hello")
      "b" {
        println("From")
      }
      "c" {
        println("Sam")
      }
    }
  }
}

같은 예시 테스트입니다.

Hello
From
Hello
Sam

해당 테스트에서는 부모 루트는 격리하지 않기 때문에,
처음에는 "b"를 위한 spec 인스턴스를 생성하여 Hello와 From을 출력합니다.
두번째로 "c"를 위한 spec 인스턴스를 생성하여 Hello와 Sam을 출력합니다.

SingleInstance

SingleInstance 모드는 테스트 클래스의 인스턴스가 전체 테스트 실행 동안 한 번만 생성됩니다. 이는 테스트 클래스의 모든 테스트가 동일한 인스턴스에서 실행된다는 것을 의미합니다. 따라서 상태가 공유될 수 있고, 초기화 블록도 한 번만 실행됩니다.

class SingleInstanceExample : WordSpec() {

  override fun isolationMode(): IsolationMode = IsolationMode.SingleInstance

  init {
    "a" should {
      println("Hello")
      "b" {
        println("From")
      }
      "c" {
        println("Sam")
      }
    }
  }
}
Hello
From
Sam

같은 테스트를 실행시켰을 때 결과는 위와 같습니다.
"a"가 실행되면서 Hello를 출력하고, 그 뒤에 "b"가 실행되면서 From, "c"가 실행되면서 Sam이 출력됩니다.

상태가 공유되기 때문에 공통 변수를 사용한다면 문제가 생길 여지가 있습니다.

마무리

kotest의 기본 구조와 사용법에 대해 알아보았습니다. 다음에는 이를 스프링부트에서 어떻게 활용하는지에 대해 작성할 예정입니다.

profile
영차영차
post-custom-banner

0개의 댓글