[Spring] Kotest + MockK 테스트 코드 작성하기
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()
}
- 다른 옵션에 대해서는 devkuma 문서를 참고
검증할 대상이 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
}
}
}
})
댓글남기기