7 분 소요

kotlin으로 작성한 프로젝트에서 테스트 코드를 작성할 때 더 kotlin스러운 코드를 쓰고 싶거나, JUnit을 사용할 때보다 가독성 있는 코드를 쓰고 싶을 때 Kotest 도입을 고민해볼 수 있을 것 같다. 그리고 (Spring 기반 프로젝트처럼) 객체를 주입받는 경우 mocking해 단위 테스트를 효과적으로 하고 싶을때 MockK를 활용할 수 있겠다.

이전에는 java + Spring 프로젝트에서만 테스트 코드를 작성하다보니 JUnit + Mockito로만 적용해봤었는데, kotlin 프로젝트는 java와 섞어서 쓰는게 아니라면 Mockito를 고수할 필요도 없는 것 같고 코루틴이 적용된 로직을 간편하게 테스트하고 싶다면 MockK를 공부해보는게 좋겠다고 생각했다.

아래는 Kotest와 MockK를 테스트 코드 작성할 때 어떻게 활용할 수 있는지 기능 위주로 정리해보았다.

Kotest

의존성 추가

아래 내용 참고해서 의존성 추가부터 해주자. 나는 5.3.2 버전을 사용했다.

testImplementation("io.kotest:kotest-runner-junit5:$version")
testImplementation("io.kotest:kotest-assertions-core:$version")

# 5.3.2 적용
testImplementation("io.kotest:kotest-runner-junit5:5.3.2")
testImplementation("io.kotest:kotest-assertions-core:5.3.2")


테스트 버튼 클릭하려면 의존성 추가만 하면 안되고 plugin에서도 설치해줘야 한다!

?

JUnit과 비교

BDD 방식인 Given-When-Then 패턴으로 작성했다고 해보자. 아래는 JUnit으로 @Nested를 적용한 코드다.
클래스로 Given, When, Then을 구분해서 작성됐다는게 명확하지만, class, @Nested를 계속 붙여줘야해서 번거롭다.

class JUnitExample {

    @Nested
    inner class Given {

        @Nested
        inner class When {

            @Test
            fun then() {
                // 테스트 로직
            }
        }
    }
}


Kotest에서는 BDD 방식을 지원하는 Behavior Spec, Describe Spec이 있다.
아래 코드는 Behavior Spec을 적용한 모습이다. JUnit처럼 class, @Nested를 반복해서 작성해줄 필요가 없게된다.
Given안에 When, Then이 많아져도 코드가 비교적 덜 복잡해져 가독성이 좋다.

class KotestExample : BehaviorSpec ({

    Given("given") {
        When("when") {
            Then("then") {
                // 테스트 로직
            }
        }

        When("when2") {
            Then("then2") {
                // 테스트 로직2
            }
            Then("then3") {
                // 테스트 로직3
            }
        }
    }
})


JUnit에서는 mocking 해줄 때 @BeforeEach가 적용된 메소드 또는 init 블록에 적어주는 작업이 필요하다. 이건 클래스 안에서 mocking을 해주려다 보니 그런 건데, Kotest에서는 Given, When을 class로 열어서 처리하지 않으니 간편하게 mocking이 가능하다.

// JUnit
@Nested
inner class Given {

    private val testService = mockk<TestService>() // 객체 mocking

    @BeforeEach
    fun setup() {
        every { testService.complexLogic() } just runs // 객체 메소드 stubbing
    }

    @Nested
    inner class When { ... }
}
// Kotest
class KotestExample : BehaviorSpec ({

    Given("given") {
        
        val testService = mockk<TestService>() // 객체 mocking
        every { testService.complexLogic() } just runs // 객체 메소드 stubbing
        
        When("when") {
            Then("then") {
                // 테스트 로직
            }
        }
    }
})


Testing Styles

위에서 언급한 Behavior Spec, Describe Spec이 예시가 되는데, 총 10가지를 지원하고 있다.
모두 다 언급하지 않고 일부만 살펴볼 예정이다. 자세한 건 Kotest 공식 문서를 참고하자.
Behavior Spec은 위에서 살펴봤으므로 생략한다.

  • Describe Spec

Behavior Spec과 비슷하다. describe-context-it 순서로 작성하면 된다. (단계 생략 가능)

class KotestExample : DescribeSpec({

    describe("describe") {
        context("context") {
            it("it") {
                // 테스트 코드
            }
        }
    }
})
  • String Spec

String 자체를 입력해서 원하는 문구를 입력할 수 있다. 굉장히 심플하다!

class KotestExample : StringSpec({

    "잘 동작하는지 테스트" {
        // 테스트 로직
    }
})


그리고 옵션으로 여러가지를 입력해줄 수 있는데, 그 예시로 timeout = 1s를 설정한 코드를 보자.
timeout은 호출 후 설정한 시간만큼 동작한다는 의미다. 1초만큼 설정했으니 실제로 delay(1000)을 입력한다면 테스트를 실패한다.
다른 옵션들은 Kotest 공식 문서를 참고하자.

class KotestExample : StringSpec({

    "바로 통과".config(timeout = 1.seconds) { // 성공!
        // 테스트 로직
    }

    "1초 기다리고 통과".config(timeout = 1.seconds) { // 1초보다 조금 더 걸려서 실패!
        delay(1000)
        // 테스트 로직
    }
})


Assertions

JUnit에서는 값을 비교할 때 assertEquals()를 사용하는데, Kotest는 shouldBe라는 infix로 쉽게 처리가 가능하다.

val a = 1
a shouldBe 1


shouldBeTrue, shouldThrow, shouldBeLessThan, shouldBeEmpty, shouldContain 등 굉장히 많다.
자세한건 Kotest 공식 문서를 참고하자.

MockK

의존성 추가

의존성을 추가해주자. 나는 1.12.0 버전을 사용했다.

testImplementation("io.mockk:mockk:{$MOCKK_VERSION}")

# 1.12.0 버전
testImplementation("io.mockk:mockk:1.12.0")

객체 mocking

단위 테스트의 주입받은 객체를 mocking하는 방법은 간단하다.
일단 JUnit에서는 2가지 방법으로 가능하다.

  • @ExtendWith(MockKExtension::class) → @MockK, @SpyK, @InjectMockKs

@ExtendWith(MockKExtension::class)@MockK, @SpyK, @InjectMockKs를 사용할 수 있게 해준다.
@MockK은 객체를 mocking하고 싶을때, @InjectMockKs은 mocking할 객체를 초기화할 때 mocking된 객체로 주입받고 싶을 때 사용한다. 참고로 @MockK을 적용한 객체의 경우 바로 초기화되는게 아니기 때문에 mocking할 녀석은 lateinit var로 설정해줘야 한다.

mocking한 객체는 테스트 시 호출할 모든 메소드에 대해 어떤 결과를 반환할지 설정해줘야 하는데, stubbing할 녀석은 every로 감싸서 원하는 값을 반환하도록 설정해준다. 만약 일부 메소드를 실제 객체의 메소드를 타도록 하고 싶다면 @SpyK를 적용하면 된다.
@SpyK가 적용된 객체는 바로 초기화해줘야 한다.

@ExtendWith(MockKExtension::class)
class JUnitExample {

    @MockK
    private lateinit var testService: TestService

    @SpyK
    // 바로 초기화 해줘야 함(lateinit 사용 불가능)
    private var testService2 = TestService2()

    @InjectMockKs
    // TestService3가 주입받는 객체가 이미 JUnitExample에 있어야 함
    private lateinit var testService3: TestService3
}


  • 바로 초기화

바로 초기화하는 방식이므로 val로 설정할 수 있다.

class JUnitExample {

    private val testService = mockk<TestService>()
    private val testSErvice2 = spyk<TestService2>()
}

Kotest에서는 바로 초기화하는 방법을 사용하면 되겠다.

객체 메소드 stubbing

이렇게 객체를 mocking했다면, 어떤 메소드는 반드시 원하는 값을 반환하도록 설정해줄 수 있다. 이를 stubbing이라 한다.
(테스트 복잡도도 낮출 수 있고, 불필요하게 로직을 태워서 발생하는 시간 낭비를 없앨 수 있음)

만약 객체 a의 test()라는 메소드가 Int를 반환하는 형태여서 1을 반환하도록 고정하고 싶다면,
every에는 처리할 메소드인 a.test()를, returns 뒤에는 반환값인 1을 설정해주면 된다.
returns 말고 answers로 처리해줘도 된다. 이 때는 람다식을 넣을 수 있다.

every { a.test() } returns 1
every { a.test() } answers { 1 }
  • 만약 test()가 파라미터를 받는데 모든 값에 대해 반환값을 설정해주고 싶다면 any()를 파라미터로 넣어주면 됨
  • 반환 타입이 Unit이라면 returns가 아니라 just runs를 적어주면 됨

suspend fun을 stubbing 할거라면, every로는 처리할 수가 없다! 이때는 coEvery를 적용해주면 된다.
구현 내용을 보면 왜 그런건지 쉽게 알 수 있다.

every { a.test() } returns 1 // compile error
coEvery { a.test() } returns 1

// stubbing할 함수가 suspend fun이 아님
fun <T> every(stubBlock: MockKMatcherScope.() -> T): MockKStubScope<T, T> = MockK.useImpl {
    MockKDsl.internalEvery(stubBlock)
}

// stubbing할 함수가 suspend fun임
fun <T> coEvery(stubBlock: suspend MockKMatcherScope.() -> T): MockKStubScope<T, T> = MockK.useImpl {
    MockKDsl.internalCoEvery(stubBlock)
}


(주의) Mock 객체 주입하기

FirstService에서 SecondService, ThirdService를 주입받고 있다고 하자. call()은 테스트하려는 대상이다.

@Service
class FirstService(
    private val secondService: SecondService,
    private val thirdService: ThirdService,
) {

    // 테스트 대상
    fun call() {
        println("[FirstService] call")
        println(secondService.call())
        println(thirdService.call())
    }
}

fun SecondService.call() = "[SecondService] call"
fun ThirdService.call() = "[ThirdService] call"
  • FirstService.call()이 호출되면 다음 내용이 호출돼야 함
    [FirstService] call
    [SecondService] call
    [ThirdService] call
    


그리고 SecondService, ThridService를 @MockK로 설정해줬다고 하자.

@MockK
private lateinit var secondService: SecondService // mock 객체

@MockK
private lateinit var thirdService: ThirdService // mock 객체

이때 FirstService@Autowired로 주입받으면 SecondService, ThirdService는 mock 객체가 아닌 정상적인 객체가
주입된다. 그래서 FirstService직접 초기화해줘야 한다!

@MockK
private lateinit var secondService: SecondService

@MockK
private lateinit var thirdService: ThirdService

private lateinit var firstService: FirstService

@BeforeEach
fun setup() {
    firstService = FirstService(secondService, thirdService) // mock 객체를 FirstService에 직접 주입
}


만약 secondService.call(), thirdService.call()을 stubbing 해줬음에도 위처럼 mock 객체를 직접 주입한게
아니라면(@Autowired를 적용해 주입받았다면), 정상적인 객체를 주입받은 것이랑 같으므로 stubbing이 안먹힌다!
그래서 firstService.call()을 호출하면 아래처럼 출력될 것이다.

[FirstService] call
[SecondService] call
[ThirdService] call


mock 객체를 FirstService에 직접 주입하고 stubbing을 아래처럼 해준다면, 출력되는 내용도 달라질 것이다.

/**
 * 출력
 * [FirstService] call
 * [SecondServiceMock] call
 * [ThirdServiceMock] call
 */
@Test
fun test() {
    every { secondService.call() } returns "[SecondServiceMock] call"
    every { thirdService.call() } returns "[ThirdServiceMock] call"
    firstService.call()
}


verify : 메소드 호출 여부 or 호출한 횟수 확인

verify 람다식에 메소드를 적어두면 테스트를 진행하는 동안 해당 메소드가 호출됐는지 여부를 확인할 수 있다.
여기서 exactly 옵션을 줘서 몇 번 호출됐는지도 확인이 가능하고, 최대/최소 호출 횟수는 atMost, atLeast로 확인할 수 있다.

// test()가 3번 호출됐는지 확인
verify(exactly = 3) {
    testService.complexLogic()
}

// 모두 호출됐는지 확인
verifyAll {
    testService.complexLogic()
    testService.complexLogic2()
    testService.complexLogic3()
}

검증할 대상이 suspend fun이라면 coVerify를 사용해야 한다.

// test()가 3번 호출됐는지 확인
coVerify(exactly = 3) {
    testService.complexLogic()
}

// 모두 호출됐는지 확인
coVerifyAll {
    testService.complexLogic()
    testService.complexLogic2()
    testService.complexLogic3()
}


relaxed, relaxUnitFun : 기본값 처리

객체를 mocking할 때 해당 객체의 메소드를 일일이 every, coEvery로 stubbing해주지 않고 기본값을 반환하도록 하고 싶으면 relaxed = true라는 옵션을 적용하면 편리하게 해결할 수 있다. 만약 반환 타입이 Unit이 아닌 메소드는 직접 stubbing 하고 싶고 반환 타입이 Unit인 메소드만 stubbing 하고 싶다면 relaxUnitFun = true라는 옵션을 적용하면 효과를 볼 수 있다.

val testService = mockk<TestService>(relaxed = true)
val testService = mockk<TestService>(relaxUnitFun = true)
  • 여기서 기본값은 primitive type의 기본값인 0, false, ““를 말함
  • 만약 반환 타입이 클래스인 경우, 해당 클래스가 갖는 모든 필드를 기본값으로 설정함

recordPrivateCalls : private 메소드도 stubbing

만약 객체를 spyK로 mocking했고 특정 메소드는 실제 메소드를 호출하도록 했다고 해보자.
그러면 그 메소드 내에서 private 메소드를 호출하고 있을 때, 이 private 메소드도 stubbing 하고 싶을 수 있다.
이때는 spyK 옵션에 recordPrivateCalls = true를 설정하고, every로 어떻게 stubbing할지 정해주면 된다.

class TestService {

    fun plus(a: Int, b: Int): Int {
        return foo(a, b)
    }

    private fun foo(a: Int, b: Int): Int {
        return a + b
    }
}

class KotestExample : BehaviorSpec ({

    Given("given") {

        val testService = spyk<TestService>(recordPrivateCalls = true) // private 메소드도 stubbing
        every { testService["foo"](1, 2) } returns 30 // 어떻게 stubbing 할건지 설정

        When("when") {
            Then("then") {
                // 원래라면 3이지만, stubbing 결과인 30을 반환
                testService.plus(1, 2) shouldBe 30
            }
        }
    }
})


References

카테고리:

업데이트:

댓글남기기