kyucumber
전체 글 보기

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

개요

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

kotlin with junit test

초기 코틀린에 익숙하지 않은 상태에서 관련 지식이 없어 위와 같은 형태로 Junit, AssertJ, Mockito를 사용해 테스트를 작성했었습니다. 하지만 코틀린에 익숙해질수록 코틀린 스타일의 테스트를 작성할 수 없어 아쉬움을 느꼈습니다.

이 글에서는 코틀린에서 Junit, AssertJ, Mockito를 사용해 테스트를 작성하는 경우의 문제점과 코틀린 진영에서 많이 사용되는 Testing 도구인 Kotest 및 Mockk 등에 대해 알아봅니다.

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

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

기존에 사용하던 Junit과 AssertJ, Mockito를 사용해 테스트를 진행하게 되면 위와 같은 코틀린 스타일의 테스트 코드를 작성할 수 없습니다. 비즈니스 로직을 코틀린 DSL을 이용해 잘 작성하더라도 테스트에서 예전 방식의 코드를 작성해야 하다 보니 코틀린에 익숙해질수록 테스트 작성이 어색해지게 됩니다.

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

kotlin style test code

Kotest

코틀린 진영에서 가장 많이 사용되는 테스트 프레임워크입니다. 코틀린 DSL을 통한 테스트 작성을 지원합니다. Kotest 에서는 String Spec, Annotation Spec, Describe Spec 등의 테스트 레이아웃을 제공하며 Assertion을 위한 라이브러리 기능도 제공하고 있습니다.

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

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

Kotest Test Framework

Kotest는 테스트를 위한 10가지 가량의 레이아웃을 제공합니다

kotlin test frameworks

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

기존 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) } } } 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") }

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

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 } } val calculations = listOf( "1 + 3 * 5" to 20.0, "2 - 8 / 3 - 3" to -5.0, "1 + 2 + 3 + 4 + 5" to 15.0 ) `when`("수식을 입력하면") { then("해당하는 결과값이 반환된다") { calculations.forAll { (expression, answer) -> val result = sut.calculate(expression) result shouldBe answer } } } val blanks = listOf("", " ", " ") `when`("입력값이 null이거나 빈 값인 경우") { then("IllegalArgumentException 예외를 던진다"){ blanks.forAll { shouldThrow<IllegalArgumentException> { sut.calculate(it) } } } } val invalidInputs = listOf("1 & 2", "1 + 5 % 1") `when`("사칙연산 기호 이외에 다른 연산자가 들어오는 경우") { then("IllegalArgumentException 예외를 던진다"){ invalidInputs.forAll { shouldThrow<IllegalArgumentException> { sut.calculate(it) } } } } } })

Describe, Context, It 패턴으로 테스트를 작성해보셨던 분들이라면 익숙한 스타일입니다.

kotest describe spec

internal class CalculatorDescribeSpec : DescribeSpec({ val sut = Calculator() describe("calculate") { context("식이 주어지면") { val inputs = listOf( "1 + 3 * 5" to 20.0, "2 - 8 / 3 - 3" to -5.0, "1 + 2 + 3 + 4 + 5" to 15.0 ) 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이거나 공백인 경우") { val blanks = listOf("", " ", " ") it("IllegalArgumentException 예외를 던진다") { blanks.forAll { shouldThrow<IllegalArgumentException> { sut.calculate(it) } } } } context("사칙연산 기호 이외에 다른 문자가 연산자로 들어오는 경우") { val invalidInputs = listOf("1 & 2", "1 + 5 % 1") it("IllegalArgumentException 예외를 던진다") { invalidInputs.forAll { shouldThrow<IllegalArgumentException> { sut.calculate(it) } } } } } })

Kotest Assertions

위에 작성한 예제 코드에서는 Kotest의 Assertion이 사용되었습니다. Kotest에서 제공하는 간단한 몇가지 Assertion 표현식을 살펴보겠습니다.

  • 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<IllegalAccessException> { // code in here that you expect to throw an IllegalAccessException } // == assertThrows<IllegalAccessException> { }
    • 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}") } @SpringBootTest internal class CalculatorSpringBootSpec : DescribeSpec() { override fun extensions() = listOf(SpringExtension) @Autowired private lateinit var calculatorService: CalculatorService init { this.describe("calculate") { context("식이 주어지면") { val inputs = listOf( "1 + 3 * 5" to 20.0, "2 - 8 / 3 - 3" to -5.0, "1 + 2 + 3 + 4 + 5" to 15.0 ) it("해당 식에 대한 결과값이 반환된다") { inputs.forAll { (expression, data) -> val result = calculatorService.calculate(expression) result shouldBe data } } } } } }

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:10.10.6") }

Mockk features

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

  • Mocking
val permissionRepository = mockk<PermissionRepository>()
  • SpyK
val car = spyk(Car()) // or spyk<Car>() to call default constructor
  • Constructor mocks
class MockCls { fun add(a: Int, b: Int) = a + b } mockkConstructor(MockCls::class) every { anyConstructed<MockCls>().add(1, 2) } returns 4 assertEquals(4, MockCls().add(1, 2)) // note new object is created verify { anyConstructed<MockCls>().add(1, 2) }
  • Relaxed mock
val car = mockk<PermissionRepository>(relaxed = true) // unnecessary // 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
    • retrunsMany
    every { permissionRepository.save(permission) } returnsMany listOf(firstPermission, secondPermission)
    • returns
    every { permissionRepository.save(permission) } retunrs permission every { permissionRepository.save(permission) } retunrs 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, Spek

이외에 아래와 같은 테스트 도구들도 있습니다.

  • SpringMockk

    • @MockkBean , @SpykBean 기능 제공
    @MockkBean // @MockBean 대신 @MockkBean 사용 가능 private lateinit var userRepository: UserRepository
  • Spek

    • Kotest의 Describe Spec의 기능 제공
internal class CalculatorDescribeSpec : Spek({ val sut = Calculator() describe("calculate") { context("식이 주어지면") { val inputs = listOf( "1 + 3 * 5" to 20.0, "2 - 8 / 3 - 3" to -5.0, "1 + 2 + 3 + 4 + 5" to 15.0 ) 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이거나 공백인 경우") { val blanks = listOf("", " ", " ") it("IllegalArgumentException 예외를 던진다") { blanks.forAll { shouldThrow<IllegalArgumentException> { sut.calculate(it) } } } } context("사칙연산 기호 이외에 다른 문자가 연산자로 들어오는 경우") { val invalidInputs = listOf("1 & 2", "1 + 5 % 1") it("IllegalArgumentException 예외를 던진다") { invalidInputs.forAll { shouldThrow<IllegalArgumentException> { sut.calculate(it) } } } } } })

Reference