Spring/Sample Project

[Spring Boot] Testing (Kotest + Mockk)

shininghyunho 2023. 11. 7. 17:38

일반적으로 Java + Spring 에서 Test 코드 작성은 Junit + Mockito를 이용한다.

Junit 은 Java로 Test를 수행할수있게 해주는 Framework고

Mockito 는 Unit Test 수행시 Mocking 작업을 수행해주는 라이브러리다.

 

해당 프로젝트는 Kotlin으로 작업이므로

Junit -> Kotest

Mockito -> Mockk

변경하여 작업을 진행했다.
(사실 Kotlin에서도 Junit과 Mockito를 그대로 사용해도 되지만 Kotlin의 장점을 살리기 위해 대체하였다.)

 

테스트 종류

토비의 스프링 Test 파트에서는 저자는 모든 코드는 테스트 가능하다고했다.

하지만 엄격한 TDD 스타일로 테스트를 다 만들수는 없기에

나는 내가 작성한 비지니스 로직을 중점적으로 테스트 코드를 작성하려고 한다.

 

MVC 패턴에서는 크게 Controller - Service - Repository 가 존재한다.

 

Controller : Spring Framework 의 코드가 들어간다. 그래서 spring context 설정 및 api 호출이 필요하다.
해당 controller 자체의 code를 테스트 한다기보단 통합테스트의 성격을 띤다.

Service : 핵심적인 비지니스 로직이 들어간다. 그래서 다른 계층에 비해 코드 양이 많고,
다른 두 계층(View, Model)과 상호작용한다. 다른 계층에 영향없이 테스트하기 위해 Mocking 이 도입되는
단위테스트(Unit Test)를 실시한다.

Repository : Entity를 비롯하여 유저가 아닌 Spring 같은 Framework의 로직을 사용한다. (복잡한 쿼리는 직접 작성하기도한다.) 따라서 필요에 의해서 단위테스트를 실시해준다. 

단위테스트(Unit Test) + Mockk

단위테스트의 목적은 다른 계층과 독립하여 해당 계층에서의 동작이 옳바른지를 검증하는것이다.

Service 계층을 테스트한다면 Repository를 호출하는 부분은 가짜(Mocking)로 호출하여 다른 계층에서의 영향을 없앨수있다.

 

Kotlin에서 이러한 Mocking을 제공하는 라이브러리로 Mockk가 각광받고 있다.

기존 Mockito에 비해 간단한 문법과 Matcher를 제공한다.

 

다음과 같이 mockk 호출로 mock 객체를 호출해주고

테스트할 클래스의 생성자를 통한 DI를 해준다. (기존 mockito 처럼 annotation으로도 가능한다.)

val userRepository = mockk<UserRepository>() // mock 객체 생성
val userService = UserService(userRepository) // mock 객체를 주입하여 테스트 대상 객체 생성

 

every 문법을 통해 mockking 호출도 간편한다.

이제 userRepository는 실제로 동작하는것이 아닌 우리가 설정해준대로 값을 반환해준다.

When("정상 저장하면") {
    	every { userRepository.findByEmail(any()) } returns null
    	every { userRepository.findByNickname(any()) } returns null

    	val savedUser = mockk<User>()
    	val userId = 123L
    	every { userRepository.save(any()) } returns savedUser
    	every { savedUser.id } returns userId
    	val result = userService.save(request)

    	Then("유저가 저장된다") { verify(exactly = 1) { userRepository.save(any()) } }
    	Then("유저 id가 반환된다") { result shouldBe userId }
}

 

간편한 Mocking

Junit에도 있는 기능이지만 Mockk에서도 간편하게 사용이 가능하다.

 

일반적으로 아래 3개 방법을 주로 썼다.

// 일반적인 Mock 객체
val userRepository = mockk<UserRepository>()

// 디폴트 값이 반환되는 Mock 객체
val userRepository = mockk<UserRepository>(relaxed = true)
 
// stubbing 부분외에 실제 구현을 사용하는 Mock 객체
val userPository = spyk<UserRepository>(
	every { findByEmail(any()) } returns null
)

 

기본적으로는 mockk 를 통해 내가 stub으로 만들고 싶은 클래스를 만들어주면 된다.

 

이때 자잘한 부분이 많아서 내가 구현하지 않은 부분은 좀 알아서 대답했으면 좋겠다 싶을때는 relaxed 옵션을 사용한다.

알아서 대답하면 Int,Long,Float,Double은 0 또는 0.0으로 String은 ""으로 대답한다.

객체도 0과 ""을 채워서 생성된다.

 

spy는 실제 객체인데 특정 부분만 mocking이 된것이다.

위 예제처럼 findByEmail만 가짜로 대답하고 나머지는 실제로 대답하게된다.

Kotest

위 코드를 보면 When, Then 같은 문법이 나온다. 이는 Kotest의 문법 스타일이다.

기존 Java + Junit을 이용한 테스트에서는

annotation을 이용한 LifeCycle 관리와 함수 호출을 통해 Test 코드를 작성했다.

 

그에 반해 Kotest는 여러가지 스타일로 Test 작성이 가능해졌다.

다음은 Kotest가 제공하는 Test 스타일이다.

 

자세히 다루지는 않겠지만 대부분의 스타일은 하나의 함수를 호출하는것과 크게 다르지않다.

그중 Behavior Spec은 우리가 BDD 스타일로 관습적으로 사용한 Given, When, Then 문법을 이용하여
Test 코드를 작성할수있도록한다.

 

다음은 BehaviorSpec을 이용하여 BDD 스타일로 작성한 Test 코드다.

Given("유저 저장시") {
        val request = UserSaveRequest(
            email = "test@email",
            nickname = "test_nickname",
            password = "test_password"
        )
        When("정상 저장하면") {
            val savedUser = mockk<User>()
            val userId = 123L
            every { userRepository.save(any()) } returns savedUser
            every { savedUser.id } returns userId
            val result = userService.save(request)

            Then("유저가 저장된다") { verify(exactly = 1) { userRepository.save(any()) } }
            Then("유저 id가 반환된다") { result shouldBe userId }
        }
        When("이메일 중복이면") {
            every { userRepository.findByEmail(any()) } returns mockk()
            Then("이메일 중복 에러가 발생한다") {
                shouldThrow<CustomException> {
                    userService.save(request)
                }.errorCode shouldBe ErrorCode.DUPLICATED_EMAIL
            }
        }
        When("닉네임 중복이면") {
            every { userRepository.findByNickname(any()) } returns mockk()
            Then("닉네임 중복 에러가 발생한다") {
                shouldThrow<CustomException> {
                    userService.save(request)
                }.errorCode shouldBe ErrorCode.DUPLICATED_NICKNAME
            }
        }
    }

    Given("유저 반환시") {
        val id = 1L
        When("정상 반환하면") {
            every { userRepository.findById(any()).orElse(null) } returns mockk()
            userService.getEntity(id)
            Then("유저가 반환된다") {
                verify(exactly = 1) { userRepository.findById(any()) }
            }
        }
        When("유저가 없으면") {
            every { userRepository.findById(any()).orElse(null) } returns null
            Then("null을 반환한다") {
                userService.getEntity(id) shouldBe null
            }
        }
    }

 

일단 계층적인 구조로 인해 중복 코드가 상당히 줄어들었다.

 

그리고 가장 마음에 들었던 부분을 테스트를 진행했을때 결과이다.

마치 요구사항 명세서처럼 한눈에 기능을 볼 수 있다.


아래부터는 테스트 작업을 하면서 생겼던 문제점 및 해결방법이다.

테스트가 무한대기인 상태

테스트를 진행하다가 gradle을 통해 전체 test 를 진행했는데 이상하게 test가 끝나지도 않고 에러도 뱉지도 않았다.
결국 한땀한땀 찾아보다가 발견했는데, 아래와 같이 특정 테스트가 계속 대기인 상태에 걸렸던것이었다.

 

실제로 테스트가 성공/실패도 아니고 그냥 실행중 상태로 있기때문에 배포한 상황처럼 progress를 확인해보는게 아니라면
쉽게 찾기도 힘들거같다.

그렇다면 위와같은 상황을 어떻게 방지할수 있을까?

 


방법은 크게 2가지이다.

1. 개별 함수마다 timeout을 설정해준다.

2. 전역으로 timeout을 설정해준다.

 

특정함수만 오래걸릴것으로 예상된다면 1번이 맞지만,

더욱 안전하게 모든 함수가 timeout을 둔다면 예상치 못한 대기상태를 피할수 있을것이다.

전역으로 설정하는 방법은 다음과 같다.

kotest 의 AbstractProjectConfig 클래스를 상속하여 변수를 변경해주면 된다.

object ProjectConfig : AbstractProjectConfig() {
    // 테스트 마다 time out 시간 3초
    override val timeout = 3000.milliseconds
}

 

이렇게 적용하고 다시 테스트를 돌려보면 이번엔 kotest는 에러를 던지게된다.

 

위 예제에서는 timeout 만 설정했는데 실제 AbstractProjectConfig 파일에 가보면 여러가지 변수를 변경할 수 있다.

 

 


Isolation

Kotest에서는 격리 모드를 설정할 수 있다.

문제는 기본 격리모드가 Singleton이다.

그래서 테스트마다 다르게 설정값을 조정하고 Mocking을 해도 모든 값들이 같은 Instance에서 실행이된다.

 

쉽게 말해 각 테스트가 독립적으로 실행되는것이 아닌 하나의 객체로 실행되어 변수 및 상태가 공유된다는것이다.

(Junit 에서 Annotation 기반 테스트는 Test마다 Instance 가 새롭게 생성되었다.)

 

내가 마주한 상황은 relaxed 옵션을 통해 stub이 디폴트 값이 나오도록 했는데,

이전에 mocking 되었던 값이 그대로 전이 되어 예측하지 못한 값이 나오게 되었다.

 

그래서 Mockk 공식 문서를 통해 3가지 Isolation 모드를 제공한다는것을 알아냈다.

  • SingleInstance : 디폴트 설정으로, 1개의 클래스의 테스트들은 1개의 객체에서 수행된다.
  • InstancePerTest : 테스트가 분기될때마다 객체가 생성된다.
  • InstancePerLeaf : 분기의 끝 부분(검증) 단계에서 객체가 생성된다.

 

InstancePerTest, InstancePerLeaf 의 차이를 예시를 통해 보면 쉽게 알수있다.

Given {
	print("A")
    When {
    	print("B")
        Then {
        	print("C")
        }
        Then {
        	print("D")
        }
    }
}

 

이때 둘의 결과값은 다음과 같다.

  • InstancePerTest : A, AB, ABC, ABD
  • InstancePerLeaf : ABC, ABD

내가 만든 테스트에서는 Leaf 단계에서만 실제 Test 검증이 이루어지므로

글로벌 설정을 InstancePerLeaf로 설정해주었다.

 

object ProjectConfig : AbstractProjectConfig() {
    // Global timeout 설정
    override val timeout = 3000.milliseconds
    // 테스트의 Leaf 마다 새로운 인스턴스
    override val isolationMode = IsolationMode.InstancePerLeaf
}

Private Method Test

TDD 를 공부하면서 '모든 코드는 테스트 가능해야한다.' 라는 사실을 알게됐다.

그러나 TDD 권위자 켄트 벡은 Private Method는 Test 하지 말라고 한다.

Should I Test Private Methods? (켄트 벡이 트위터에 올린 페이지)

 

 

그 이유는 크게 다음과 같다.

  • 캡슐화 위반 : 내부에서만 접근해야하는데 일종의 client인 Test 가 접근하기 때문
  • 유지보수 어려움 : private method는 변경될 위험이 높아 매번 새롭게 Test를 작성해줘야함.
  • Unit Test 목적 : 외부 동작과의 상호작용이 옳바른지 확인하는것인데 private method는 그렇지 않음.

 

사실 private method는 public method에서 무조건 호출을 하기 때문에

public method의 분기만 적절히 해준다면 이미 테스트를 진행하게된다.