Backend/공부,개념

[Kotlin] Kotest, mockk

지수쓰 2023. 9. 3. 17:50
반응형

코틀린 스타일 테스트코드

기존에 사용하던 Junit, AssertJ, Mockito를 이용해서도 테스트코드를 작성할 수 있지만, 중괄호를 활용한 코틀린 dsl 스타일을 적용하기 쉽지 않은데 kotest mockk(코틀린에서 사용하는 mocking라이브러리)를 이용하면 Mocking이나 assertion 과정에서 Kotlin dsl이나 infix를 사용해 조금 더 코틀린스럽게 테스트코드를 작성할 수 있다.

 

 

Kotest

코틀린 진영에서 가장 많이 사용되는 테스트 프레임워크

코틀린 DSL을 활용해 테스트코드를 작성할 수 있다.

  • 다양한 테스트 레이아웃(StringSpec, FunSpec, BehaviorSpec 등 ) 제공
  • Kotlin DSL 스타일의 Assertion 기능 제공
# kotlin dsl이란
Domain Specific Language : 특정 도메인에 국한해 사용하는 언어

범용 프로그래밍 언어인 C, C++ 등을 가지고 알고리즘을 푼다.
SQL언어는 DB (특정영역, 도메인)에 초점을 맞추고 기능을 제한

특징
- 선언적 문법 ( <-> 명령적 )
- 깔끔한 코드작성이 가능하다.
  ㄴ 해당 코드가 어떤 로직을 가지고 작업할지 명확하게 이해할 수 있다.

kotlin dsl : https://w97ww.tistory.com/119

 

Kotest Testing Styles

보통 이런 모양을 가지고 있다.

class Tests: XxxSpec({
	keyword("테스트코드 설명") {
      // keyword는 Spec에 따라서 
      // test, "string", should, describe, given, when, then 등 다양하다.
    }

	xkeyword("테스트 비활성화"){
      // keyword앞에 x가 붙으면 비활성화가 된다.
    }
})

 

sample 1) FunSpec

class MyTests : FunSpec({
    test("addCalculator - 두 파라미터를 더한 값이 반환 된다.") {
    	val result = addCalculator(1, 2)
        result shouldBe 3
    }
})


fun addCalculator(a: Int, b: Int): Int = a + b

 

test 부분에 테스트의 설명을 적고, 괄호 안 람다 식에 필요한 assertion문을 넣어 테스트를 하면 된다.

보통 의존성 주입이 필요 없는 도메인 클래스나 도메인 서비스 메소드를 테스트할 때 많이 사용했다.

 

sample 2) BehaviorSpec

class MyTests : BehaviorSpec({
    given("a broomstick") { // Given 도 가능 
        and("a witch") { // And
            `when`("The witch sits on it") { // When
                and("she laughs hysterically") { // And
                    then("She should be able to fly") { // Then
                        // test code
                    }
                }
            }
        }
    }
})

BDD 스타일의 테스트코드를 작성할 때 유용하다. `when`이 예약어라서 사용할 때 \`when`으로 써주어야 해서, 키워드를 대문자로 사용하는 게 편할 수도 있다. And를 이용해서 이어 쓸 수 있고 xGiven, xwhen처럼 x를 붙이면 비활성화할 수 있다.

 

sample 3) AnnotationSpec

class AnnotationSpecExample : AnnotationSpec() {

    @BeforeEach
    fun beforeTest() {
        println("Before each test")
    }

    @Test
    fun test1() {
        1 shouldBe 1
    }

    @Test
    fun test2() {
        3 shouldBe 3
    }
}

Junit 스타일의 어노테이션 방식을 똑같이 사용할 수 있는 AnnotationSpec도 있다. 

 

수명주기 / 전후 처리

BehaviorSpec같은 중첩 테스트에서는 테스트 수명주기를 이해하고 사용하는 것이 좋다.

Given, When, Then을 만들다 보면 테스트가 중첩으로 생성되기 때문에, 이 수명주기에 따라서 전역으로 관리하거나 mockk 객체를 처리하면 스코프 안에서 얼마나 반복되어 실행되는지, 모킹의 범위가 얼마인지, 어느 시점에 mockk 객체를 초기화해야 될지 등을 알 수 있다.

 

  • beforeContainer, afterContainer
    • Given으로 묶이는 그 아래까지 포함한 영역
  • beforeEach, afterEach
    • Then영역
  • beforeTest, afterTest
  • beforeAny, afterAny
  • beforeSpec, afterSpec
    • 테스트 영역 전체를 Spec이라고 한다
    • XxxSpec을 구현하는 ({}) 영역
  • 등..

 

Assertion

io.kotest.matchers 패키지에 종류별로 아주 많이 있다.

 

  • shouldBe, shouldBeNot  
  • shouldHaveSize, shouldContain, ShouldBeGreaterThan ,,
  • shouldThrow<>
  • 등등

딱히 정해진건 없으니 패키지를 찾아보거나 많이 사용해 보면서 적절한 assertions을 쓰면 될 것 같다. 

 

예를 들면 list.size() shouldBe 2 보단 list shouldHaveSize 2 를 쓴다거나, 리스트의 아이템을 직접 조회하며 assert문을 쓰지 않고 shouldBeSortedWith(Comparator) 를 쓴다거나,, 

예외처리에 대한 테스트를 할 때 shouldThrow로 검증을 할 수도 있고, runCatching으로 예외를 잡아서 그 결괏값을 다른 assert문으로 검증하는 것 도 괜찮은 것 같다. 


Mockk

kotests와 같이 쓸 수 있는 유용한 모킹 라이브러리이다. jUnit을 사용할 때는 mockito를 주로 썼다면, 코틀린을 쓸 때는 mockk를 쓰면 보통 쓰는 편이다. 

거의 비슷해서 각자 장/단점을 비교해서 취향껏 선택할 수 있을 정도의 차이인 것 같지만, kotlin dsl 형태로 되어있는 것이나 좀 더 코틀린스러운 테스트코드를 짤 수 있는 느낌이다. 더 자세한 특징은.. 생략 ㅎㅎ

 

1. 객체 생성

  • val aService = mockk<AService>()
  • val aService: AService = mockk()

2. Matcher 사용

참고 링크 : https://mockk.io/#matchers

every { aService.something() } returns SomeObject()

every { aService.something() } throws Exception()

justRuns { aService.something() } // 결과값이 없는 메소드 단순 호출만 mocking

 

 

3. 검증

검증은 보통 kotest의 assertion으로 하거나, verify 메소드를 사용해서 mocking 한 클래스의 특정 메소드가 호출되었는지 동작만을 검증한다.

 

이때 shouldBe 같은 assert문이 아닌 verify로 메서드가 호출되는지를 검증할 때, 모든 테스트에 걸쳐서 호출 횟수를 세기 때문에 테스트코드의 호출 순서에 따라서 verify문이 실패할 수 있다. (테스트끼리 독립적이지 않다)

그래서 테스트마다 mock을 clear 하고 다시 mocking을 해줘야 하는데 beforeEach, afterTest 등을 이용해 수명주기에 따라서 해당 동작을 전역적으로 관리할 수 있다.

 

이 부분 때문에 kotest의 수명주기를 잘 알아두어야 테스트코드를 더 잘 작성할 수 있을 듯하다.

 

 

mock vs stub

Test Double
- 테스트 목적으로 실제 객체 대신 사용되는 모든 종류의 가상 객체

mock vs stub
아주 살짝 비교를 해본다면 
- stub은 실제로 인스턴스화하여 실제로 동작하는 것처럼 만드는 객체
- mock은 호출에 대한 기대를 명세하고, 그 내용에 따라 동작하도록 프로그래밍된 객체. 

 

상태 검증 vs 행위 검증
mock과 stub을 비교할 때 상태 검증, 행위 검증이라는 말을 쓰는데 stub은 메서드가 수행된 뒤에 결과 값의 상태를 확인한다면, mock은 

실제로 동작을 수행했는지 행위만 검증한다. 

 

예제 코드

class AServiceTests : BehaviorSpec({
    val dBService: DBService = mockk()
    

    val aService = AService(
            dBService
    )

    Given("id가 주어지고") {
        val id = "1"
        When("data가 존재하면") {
            every { dBService.get(id)} returns Data("1", "content")
            aService.request(RequestDto("1", "new content"))

            Then("modify가 호출된다.") {
                verify(exactly = 1) { dBService.modify(any()) }
                verify(exactly = 0) { dBService.save(any()) }
            }
        }

        When("data가 존재하지 않으면") {
            every { dBService.get(id)} returns null
            aService.request(RequestDto("1", "content"))

            Then("save가 호출된다.") {
                verify(exactly = 0) { dBService.modify(any()) }
                verify(exactly = 1) { dBService.save(any()) }
            }
        }
    }

    // 전역적으로 clear, mocking할 만한 메소드들은 수명주기 메소드와 함께 설정
    beforeTest{
        justRun { dBService.modify(any())}
        justRun { dBService.save(any()) }
    }

    afterTest {
        clearMocks(dBService)
    }
})

요즘에 헥사고날 패턴의 장점을 느낀 건 특정 port를 mocking 해두고 호출되었는지 정도만 검증하면 다른 클래스에 독립적이게 원하는 기능만 테스트할 수 있어서 좋았던 것 같다. 

 

참고할 링크

출처