[Kotlin] Kotest, mockk
코틀린 스타일 테스트코드
기존에 사용하던 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
- https://kotest.io/docs/framework/testing-styles.html
- 다양한 Spec이 존재해서 상황에 따라 사용하면 될 듯하다.
- 나는 보통 가볍게 테스트할 때 FunSpec, BehaviorSpec(BDD형태) 를 많이 쓴다.
보통 이런 모양을 가지고 있다.
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
- https://kotest.io/docs/assertions/assertions.html
- 보통
shouldXX
형태의 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 해두고 호출되었는지 정도만 검증하면 다른 클래스에 독립적이게 원하는 기능만 테스트할 수 있어서 좋았던 것 같다.
참고할 링크
출처
- kotest https://techblog.woowahan.com/5825/
- 수명주기 관련 https://velog.io/@effirin/Kotest%EC%99%80-BDD-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1%ED%95%98%EA%B8%B0-%EC%A0%84-%EC%9D%BD%EC%96%B4%EB%91%90%EB%A9%B4-%EC%A2%8B%EC%9D%84-%EC%9E%90%EB%A3%8C%EB%93%A4
- mock vs stub https://azderica.github.io/00-test-mock-and-stub/