스프링에서 코틀린 스타일 테스트 코드 작성하기

Sep.30.2021 김규남

Backend

안녕하세요 저는 공통시스템개발팀에서 플랫폼 개발을 담당하고 있는 김규남이라고 합니다.

이 글은 올해 사내에서 진행한 코틀린 밋업에서 스프링에서 코틀린 스타일 테스트 코드 작성하기라는 주제로 발표한 내용을 바탕으로 작성한 글입니다.

코틀린에서 테스트를 작성하기 위한 여러 도구들 및 기능들에 대해 간단하게 소개하는 내용이라 코틀린을 잘 모르거나 막 시작하시는 분들이 가볍게 봐주시면 좋을 것 같네요.

개요

스프링 기반 프로젝트에서 코틀린을 사용하더라도 아래와 같이 기존에 사용하던 테스트 프레임워크인 Junit, Assertion, Mockito 등은 동일하게 사용할 수 있습니다.

초기 코틀린에 익숙하지 않은 상태에서는 관련 경험이 없어 위와 같은 형태로 Junit, AssertJ, Mockito를 사용해 테스트를 작성했습니다.

하지만 코틀린에 익숙해질수록 테스트 코드 내에서 코틀린 스타일로 코드를 작성할 수 없어 비즈니스 코드와 테스트 코드 간에 괴리가 느껴졌습니다.

아래에서 코틀린에서 Junit, AssertJ, Mockito를 사용해 테스트를 작성하는 경우의 문제점에 대해 자세히 다루고, 코틀린 진영에서 많이 사용되는 테스트 도구인 Kotest 및 Mockk 등에 대해 알아보겠습니다.

코틀린 DSL과 코틀린 스타일의 테스트코드

코틀린에서는 아래와 형태와 같은 DSL(Domain Specific Language) 스타일의 중괄호를 활용한 코드 스타일을 제공합니다. 코틀린 내부에서 제공하는 Standard library 대부분도 DSL을 이용해 작성된 것을 볼 수 있습니다.

기존에 사용하던 Junit과 AssertJ, Mockito를 사용하면 Mocking이나 Assertion 과정에서 코틀린 DSL 을 활용할 수 없습니다.

비즈니스 로직을 코틀린 DSL을 이용해 작성하더라도 테스트에서 예전 방식의 코드를 작성해야 하다 보니 코틀린에 익숙해질수록 테스트 작성이 어색해지게 됩니다.

KotestMockk와 같은 도구들을 사용하면 아래처럼 코틀린 DSL과 Infix를 사용해 코틀린 스타일의 테스트 코드를 작성할 수 있습니다.

코틀린 진영의 테스트 도구들

위의 문제점을 해소하고 코틀린 DSL을 활용한 코틀린 스타일 테스트 코드 작성을 돕는 도구를 몇 가지 소개하겠습니다.

Kotest

코틀린 진영에서 가장 많이 사용되는 테스트 프레임워크입니다.
코틀린 DSL을 활용해 테스트 코드를 작성할 수 있으며 아래와 같은 기능들을 포함하고 있습니다.

  • 다양한 테스트 레이아웃(String Spec, Describe Spec, Behavior Spec 등) 제공
  • Kotlin DSL 스타일의 Assertion 기능 제공

Kotest를 사용하기 위해서는 아래와 같은 설정 / 의존성 추가가 필요합니다.

test {
    useJUnitPlatform()
}

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

설정에 대한 부분은 공식 문서를 참고하시는게 좋을 것 같아 간략하게 다루고 넘어가도록 하겠습니다.

Kotest Testing Styles

Kotest는 테스트를 위한 많은 레이아웃을 제공합니다.

  • Annotation Spec
  • Behavior Spec
  • Describe Spec
  • Fun Spec

Kotest에서 익숙하고 많이 사용하는 몇 가지 스타일에 대해 살펴보겠습니다.

  • Kotest Annotation Spec

기존 Junit 방식과 가장 유사한 방식입니다. 별 다른 장점이 없는 레이아웃이지만 Junit에서 Kotest로의 마이그레이션이 필요한 상황이라면 나쁘지 않은 선택이 될 수 있습니다.

internal class CalculatorAnnotationSpec: AnnotationSpec() {
    private val sut = Calculator()

    @Test
    fun `1과 2를 더하면 3이 반환된다`() {
        val result = sut.calculate("1 + 2")

        result shouldBe 3
    }

    @Test
    fun `식을 입력하면, 해당하는 결과값이 반환된다`() {
        calculations.forAll { (expression, answer) ->
            val result = sut.calculate(expression)

            result shouldBe answer
        }
    }

    @Test
    fun `입력값이 null 이거나 빈 공백 문자일 경우 IllegalArgumentException 예외를 던진다`() {
        blanks.forAll {
            shouldThrow<IllegalArgumentException> {
                sut.calculate(it)
            }
        }
    }

    @Test
    fun `사칙연산 기호 이외에 다른 문자가 연산자로 들어오는 경우 IllegalArgumentException 예외를 던진다 `() {
        invalidInputs.forAll {
            shouldThrow<IllegalArgumentException> {
                sut.calculate(it)
            }
        }
    }

    companion object {
        private val calculations = listOf(
            "1 + 3 * 5" to 20.0,
            "2 - 8 / 3 - 3" to -5.0,
            "1 + 2 + 3 + 4 + 5" to 15.0
        )
        private val blanks = listOf("", " ", "      ")
        private val invalidInputs = listOf("1 & 2", "1 + 5 % 1")
    }
}
  • Kotest Behavior Spec

기존 스프링 기반 프로젝트에서 작성하던 Given, When, Then 패턴을 Kotest Behavior Spec을 활용해 간결하게 정의할 수 있습니다.

internal class CalculatorBehaviorSpec : BehaviorSpec({
    val sut = Calculator()

    given("calculate") {
        val expression = "1 + 2"
        `when`("1과 2를 더하면") {
            val result = sut.calculate(expression)
            then("3이 반환된다") {
                result shouldBe 3
            }
        }

        `when`("수식을 입력하면") {
            then("해당하는 결과값이 반환된다") {
                calculations.forAll { (expression, answer) ->
                    val result = sut.calculate(expression)

                    result shouldBe answer
                }
            }
        }

        `when`("입력값이 null이거나 빈 값인 경우") {
            then("IllegalArgumentException 예외를 던진다") {
                blanks.forAll {
                    shouldThrow<IllegalArgumentException> {
                        sut.calculate(it)
                    }
                }
            }
        }

        `when`("사칙연산 기호 이외에 다른 연산자가 들어오는 경우") {
            then("IllegalArgumentException 예외를 던진다") {
                invalidInputs.forAll {
                    shouldThrow<IllegalArgumentException> {
                        sut.calculate(it)
                    }
                }
            }
        }
    }
}) {
    companion object {
        private val calculations = listOf(
            "1 + 3 * 5" to 20.0,
            "2 - 8 / 3 - 3" to -5.0,
            "1 + 2 + 3 + 4 + 5" to 15.0
        )
        private val blanks = listOf("", " ", "      ")
        private val invalidInputs = listOf("1 & 2", "1 + 5 % 1")
    }
}
  • Kotest Describe Spec

Kotest는 Describe Spec을 통해 DCI(Describe, Context, It) 패턴 형태의 레이아웃도 제공합니다.

저희 팀에서는 Describe Spec을 사용해 테스트 코드를 작성하고 있습니다.

internal class CalculatorDescribeSpec : DescribeSpec({
    val sut = Calculator()

    describe("calculate") {
        context("식이 주어지면") {
            it("해당 식에 대한 결과값이 반환된다") {
                calculations.forAll { (expression, data) ->
                    val result = sut.calculate(expression)

                    result shouldBe data
                }
            }
        }

        context("0으로 나누는 경우") {
            it("Infinity를 반환한다") {
                val result = sut.calculate("1 / 0")

                result shouldBe Double.POSITIVE_INFINITY
            }
        }

        context("입력값이 null이거나 공백인 경우") {
            it("IllegalArgumentException 예외를 던진다") {
                blanks.forAll {
                    shouldThrow<IllegalArgumentException> {
                        sut.calculate(it)
                    }
                }
            }
        }

        context("사칙연산 기호 이외에 다른 문자가 연산자로 들어오는 경우") {
            it("IllegalArgumentException 예외를 던진다") {
                invalidInputs.forAll {
                    shouldThrow<IllegalArgumentException> {
                        sut.calculate(it)
                    }
                }
            }
        }
    }
}) {
    companion object {
        val calculations = listOf(
            "1 + 3 * 5" to 20.0,
            "2 - 8 / 3 - 3" to -5.0,
            "1 + 2 + 3 + 4 + 5" to 15.0
        )
        val blanks = listOf("", " ", "      ")
        val invalidInputs = listOf("1 & 2", "1 + 5 % 1")
    }
}

위와 같은 여러 레이아웃 중 팀의 상황에 가장 잘 맞는 레이아웃을 택해 테스트를 작성해주시면 됩니다.

Kotest Assertions

위에 작성한 예제 코드에서는 코틀린 DSL 스타일로 코드를 작성하기 위해 Kotest Assertion을 활용해 테스트를 작성했습니다.

Kotest의 Assertion은 대응되는 키워드만 알면 큰 러닝커브 없이 사용이 가능합니다.

아래에서 Kotest에서 제공하는 간단한 몇 가지 Assertion 표현식을 살펴보겠습니다. 자세한 내용은 Kotest 공식 문서에서 확인하실 수 있습니다.

  • Assertions

    • shouldBe
    name shouldBe "kyunam"
    
    // == assertThat(name).isEqualTo("kyunam")
  • Inspectors

    • forExactly
    mylist.forExactly(3) {
        it.city shouldBe "Chicago"
    }
    • forAtLeast
    val xs = listOf("sam", "gareth", "timothy", "muhammad")
    
    xs.forAtLeast(2) {
        it.shouldHaveMinLength(7)
    }
  • Exceptions

    • shouldThrow
    shouldThrow {
      // code in here that you expect to throw an IllegalAccessException
    }
    
    // == assertThrows { }
    • shouldThrowAny
    val exception = shouldThrowAny {
      // test here can throw any type of Throwable!
    }

Kotest with @SpringBootTest

@SpringBootTest와 같은 통합 테스트에서도 Kotest의 테스트 레이아웃을 사용할 수 있습니다.

사용을 위해서는 아래와 같은 spring extension 의존성의 추가가 필요합니다.

dependencies {
    testImplementation("io.kotest:kotest-extensions-spring:${Versions.KOTEST}")
}

Kotest with @SpringBootTest

@SpringBootTest
internal class CalculatorSpringBootSpec : DescribeSpec() {
    override fun extensions() = listOf(SpringExtension)

    @Autowired
    private lateinit var calculatorService: CalculatorService

    init {
        this.describe("calculate") {
            context("식이 주어지면") {
                it("해당 식에 대한 결과값이 반환된다") {
                    calculations.forAll { (expression, data) ->
                        val result = calculatorService.calculate(expression)

                        result shouldBe data
                    }
                }
            }
        }
    }

    companion object {
        private val calculations = listOf(
            "1 + 3 * 5" to 20.0,
            "2 - 8 / 3 - 3" to -5.0,
            "1 + 2 + 3 + 4 + 5" to 15.0
        )
    }
}

Kotest Isolation Mode

Kotest는 아래와 같이 테스트 간 격리에 대한 설정을 제공하고 있습니다.

Kotest에서는 테스트 간 격리 레벨에 대해 디폴트로 SingleInstance를 설정하고 있으며 이 경우 Mocking 등의 이유로 테스트 간 충돌이 발생할 수 있습니다. 따라서 테스트간 완전한 격리를 위해서는 아래와 같이 IsolationMode를 InstancePerLeaf로 지정해 사용해야 합니다.

internal class CalculatorDescribeSpec : DescribeSpec({
    isolationMode = IsolationMode.InstancePerLeaf
    // ...
})

Mockk

Mockk는 코틀린 스타일의 Mock 프레임워크입니다.
Mockito를 사용하는 경우 아래처럼 코틀린 DSL 스타일을 활용할 수 없습니다.

given(userRepository.findById(1L).willReturn(expectedUser)

Mockk를 사용하면 아래와 같이 코틀린 DSL 스타일로 Mock 테스트를 작성할 수 있습니다.

every { userRepository.findById(1L) } answers { expectedUser }

Mockk의 사용을 위해서는 아래와 같은 의존성 추가가 필요합니다.

dependencies {
    testImplementation("io.mockk:mockk:1.12.0")
}

Mockk features

Mockito와 큰 차이가 없습니다. 대응되는 키워드만 잘 찾으면 러닝커브 없이 바로 사용이 가능합니다.

몇 가지 대응되는 예시를 남겨두었습니다. 자세한 내용은 Mockk 공식 문서 에서 확인할 수 있습니다.

  • Mocking
val permissionRepository = mockk<PermissionRepository>()
  • SpyK
val car = spyk(Car()) // or spyk<Car>() to call default constructor
  • Relaxed mock
val car = mockk<PermissionRepository>(relaxed = true)

// relaxed를 설정하는 경우 permissionRepository.delete에 대해 Mocking을 하지 않은 상태에서 delete 메소드가 호출되더라도 예외가 발생하지 않습니다.
// every { permissionRepository.delete(id) } just Runs
  • Answers
// answers
every { permissionRepository.save(permission) } answers { permission }

// throws
every { permissionRepository.findByIdOrNull(id) } throws EntityNotFoundException()

// just Runs
every { permissionRepository.delete(id) } just Runs

// returnsMany
every { permissionRepository.save(permission) } returnsMany listOf(firstPermission, secondPermission)

// returns
every { permissionRepository.save(permission) } returns permission
every { permissionRepository.save(permission) } returns firstPermission andThen secondPermission
  • Argument matching

    • any
    every { permissionRepository.save(any()) } retunrs permission
    • varargs
    every { obj.manyMany(5, 6, *varargAll { it == 7 }) } returns 3
    
    println(obj.manyMany(5, 6, 7)) // 3
    println(obj.manyMany(5, 6, 7, 7)) // 3
    println(obj.manyMany(5, 6, 7, 7, 7)) // 3
    
    every { obj.manyMany(5, 6, *varargAny { nArgs > 5 }, 7) } returns 5
    
    println(obj.manyMany(5, 6, 4, 5, 6, 7)) // 5
    println(obj.manyMany(5, 6, 4, 5, 6, 7, 7)) // 5
  • Verification

    • verify
    verify(atLeast = 3) { car.accelerate(allAny()) }
    verify(atMost  = 2) { car.accelerate(fromSpeed = 10, toSpeed = or(20, 30)) }
    verify(exactly = 1) { car.accelerate(fromSpeed = 10, toSpeed = 20) }
    verify(exactly = 0) { car.accelerate(fromSpeed = 30, toSpeed = 10) } // means no calls were performed
    • verifyAll
    verifyAll {
        obj.sum(1, 3)
        obj.sum(1, 2)
        obj.sum(2, 2)
    }
    • verifySequnece
    verifySequence {
        obj.sum(1, 2)
        obj.sum(1, 3)
        obj.sum(2, 2)
    }

SpringMockk

위에서 소개드렸던 Mockk에서는 @MockBean이나 @SpyBean의 기능을 제공하지 않습니다.

@MockBean이나 @SpyBean의 기능을 코틀린 DSL을 활용해 사용하고 싶다면 Ninja-Squad/springmockk 의존성을 추가해야 합니다.

testImplementation("com.ninja-squad:springmockk:3.0.1")

SpringMockk에서는 @MockkBean ,@SpykBean이라는 어노테이션 및 기능을 제공합니다.

    @MockkBean // @MockBean 대신 @MockkBean 사용 가능
    private lateinit var userRepository: UserRepository

Spek

Kotest의 Describe Spec 형태의 테스트 레이아웃을 제공합니다.

나쁘지 않지만 Kotest의 Describe Spec을 사용해 완벽히 대체가 가능하며 Spek의 업데이트가 활발하지 않습니다.

팀내에서 기존에는 Spek을 사용했었지만 업데이트가 활발하지 않고 사실상 유지만 되는 느낌이라 Kotest로 마이그레이션을 진행했습니다.

Spek을 활용하실 예정이라면 관련 내용에 대해 고민해보시는게 좋습니다.

internal class CalculatorDescribeSpec : Spek({
    val sut = Calculator()

    describe("calculate") {
        context("식이 주어지면") {
            it("해당 식에 대한 결과값이 반환된다") {
                inputs.forAll { (expression, data) ->
                    val result = sut.calculate(expression)

                    result shouldBe data
                }
            }
        }

        context("0으로 나누는 경우") {
            it("Infinity를 반환한다") {
                val result = sut.calculate("1 / 0")

                result shouldBe Double.POSITIVE_INFINITY
            }
        }

        context("입력값이 null이거나 공백인 경우") {
            it("IllegalArgumentException 예외를 던진다") {
                blanks.forAll {
                    shouldThrow<IllegalArgumentException> {
                        sut.calculate(it)
                    }
                }
            }
        }

        context("사칙연산 기호 이외에 다른 문자가 연산자로 들어오는 경우") {
            it("IllegalArgumentException 예외를 던진다") {
                invalidInputs.forAll {
                    shouldThrow<IllegalArgumentException> {
                        sut.calculate(it)
                    }
                }
            }
        }
    }
}) {
    companion object {
        private val calculations = listOf(
            "1 + 3 * 5" to 20.0,
            "2 - 8 / 3 - 3" to -5.0,
            "1 + 2 + 3 + 4 + 5" to 15.0
        )
        private val blanks = listOf("", " ", "      ")
        private val invalidInputs = listOf("1 & 2", "1 + 5 % 1")
    }
}

위의 내용 중 변경되거나 잘못된 내용이 있다면 댓글 등으로 알려주시면 감사하겠습니다.

참고