프로젝트를 진행함에 있어서, 테스트는 코드의 무결성을 보장해주는 최소한의 안전 장치이다.
특정 기능을 아무리 견고하게 작성했더라도, 코드를 변경하게 되면 개발자는 큰 불안감을 느낀다.
이때 근간이 되는 요구 사항을 테스트 코드로 작성해놓았다면, 변경된 기능의 안정성을 신속하고 신뢰성 있게 검증할 수 있다.
그럼 더 나아가, 구현에 앞서 테스트 케이스를 작성한다면 더 좋지 않을까?
요구 사항에 대한 테스트 케이스를 먼저 작성하고, 그에 맞추어가며 코드를 작성해가는 개념을 TDD1라고 부른다.
자, 그러면 Kotlin + Spring Boot 프로젝트 구성에서 TDD를 하기 위한 방법에는 뭐가 있을까?
뭐... 여러가지 방법이 있겠지만, 나는 Kotest와 MockK을 조합해서 사용하기로 했다.
Spring Boot를 테스트 할 수 있는 프레임워크에는 여러가지가 있었다.
가장 많이 사용되는 JUnit, BDD2 개념을 받아들인 Spock와 그 Kotlin 파생형인 Spek이 대표적.
하지만 나는 아래와 같은 이유로 Kotest를 선택했다.
@Annotation
을 떡칠하지 않고 예쁘게 테스트 코드를 작성 할 수 있다4.'이래서 Kotest를 사용한다' 보다는 '모든게 다 되는 Kotest를 사용하지 않을 이유가 없다'에 가까운 느낌이었다.
모킹 프레임워크를 고를 때에는 큰 고민이 없었다.
이전까지 Java/Kotlin 프로젝트에서 모킹을 할 때에는 Mockito를 사용했었다.
큰 이유는 없었고, 제일 레퍼런스도 많고 주변에서 얘기도 많이 해서 관성에 따라 사용했었다.
그런데 테스트 프레임워크를 Kotest로 바꾸고 나니까, '모킹 프레임워크도 더 좋은게 있는거 아니야?'라고 생각이 들었고.
마침 그때쯤 해서, 같이 사이드 프로젝트를 진행하던 팀원이 '저는 MockK 써서 모킹하는데요!'라는 얘기를 해 주었다.
찾아보니까, 'Mockito에서 지원하는 모든 기능에 몇몇 추가 기능을 더한, Kotlin을 위한 모킹 프레임워크'라고 나오더라.
'한번 써볼까?' 싶었다.
테스트에 필요한 프레임워크들도 전부 모았으니, Spring Boot 프로젝트를 어떻게 테스트할지 고민해보았다.
일단, 내가 생각하는 Clean Architecture 분류에 따라 테스트 대상을 나누어 보았다.
자, 그러면 각 부분에 맞는 테스트 방법을 고민해보자.
WebTestClient
등으로 Request/Response를, Service는 Mockk로 모킹해서 Kotest 테스트 진행.Use Cases에 해당하는 Service 클래스들은 'Given-When-Then' 패턴으로 테스트하기로 결정.
주어진 상황이 있고 (Given)
/ 무엇을 할 때에 (When)
/ 이런 결과가 나온다 (Then)
특정 행동에 따라, 특정 결과가 나올 것이 명확하게 구분되는 Service 로직의 테스트에 적합하다고 생각됐다.
Given("저장된 작업이 1개 있는 상황에서") {
every { taskRepository.getAllTasks() } returns singleTaskFlux.log()
When("모든 작업 리스트를 요청하면") {
val result = StepVerifier.create(service.getAllTasks())
Then("결과 리스트를 조회해야 한다") {
verify(exactly = 1) {
taskRepository.getAllTasks()
}
}
Then("조회된 결과 리스트에 1개의 작업이 존재해야 한다") {
result
.expectSubscription()
.expectNext(*singleGetAllTasksDataList.toTypedArray())
.expectComplete()
.verify()
}
}
HTTP Request/Response를 직접 처리하는 Controller 클래스는, 'Describe-Context-It' 패턴으로 테스트하기로 결정.
특정 기능을 (Describe)
/ 사용자가 어떠한 식으로 사용하면 (Context)
/ Controller는 이렇게 반응한다 (It)
사용자와 기능의 상호 관계와, 그 동작 결과에 집중하는 특성은 Controller 로직의 테스트에 적합하다고 생각했다.
describe("getAllTasks를") {
val performRequest = { webTestClient.get().uri("/task").exchange() }
context("저장된 데이터가 없는 상황에서 요청한 경우") {
every { taskService.getAllTasks() } returns emptyGetAllTasksDataFlux.log()
val response = performRequest()
it("서비스를 통해 데이터를 조회한다") {
verify(exactly = 1) {
taskService.getAllTasks()
}
}
it("요청은 성공한다") {
response.expectStatus().isOk
}
it("반환 형식은 JSON이다") {
response.expectHeader().contentType(MediaType.APPLICATION_JSON)
}
it("JSON은 비어 있다") {
response.expectBody().json(emptyTaskResponseBody)
}
}
}
class TaskServiceTest : BehaviorSpec() {
/* ... */
private val manyTaskList = listOf(
TaskFactory.create(Task.Status.IN_PROGRESS),
TaskFactory.create(Task.Status.TODO),
TaskFactory.create(Task.Status.WONT_DO),
TaskFactory.create(Task.Status.DONE)
)
private val manyTaskFlux = TestUtils.listToFlux(manyTaskList)
private val manyGetAllTasksDataList = manyTaskList.map { GetAllTasksData.fromTask(it) }
init {
val taskRepository = mockk<TaskRepository>()
val service = TaskService(taskRepository)
afterContainer {
clearAllMocks()
}
/* ... */
Given("저장된 작업이 N개 있는 상황에서") {
every { taskRepository.getAllTasks() } returns manyTaskFlux.log()
When("모든 작업 리스트를 요청하면") {
val result = StepVerifier.create(service.getAllTasks())
Then("결과 리스트를 조회해야 한다") {
verify(exactly = 1) {
taskRepository.getAllTasks()
}
}
Then("조회된 결과 리스트에 N개의 작업이 존재해야 한다") {
result
.expectSubscription()
.expectNext(*manyGetAllTasksDataList.toTypedArray())
.expectComplete()
.verify()
}
}
}
}
}
위 코드는, TaskService
를 테스트 하기 위한, TaskServiceTest
클래스의 구현이다.
'Given-When-Then' 패턴을 사용하기 위해, BehaviorSpec
를 TaskServiceTest
클래스에 적용시켜주자.
그런 뒤에 TaskRepository
를 MockK로 모킹해 테스트 대상인 TaskService
에 주입해주자.
이러면 테스트 케이스 작성 준비가 전부 끝난다.
'주어진 상황 (Given)'은 '저장된 작업이 N개 있는 상황'이다.
즉, getAllTasks()
메소드를 호출했을 때, 1개 이상의 결과 값이 반환되어야 한다.
TaskRepository
의 getAllTasks()
메소드가 manyTaskFlux
를 리턴할 수 있게끔 every{}
와 returns
구문을 이용해 동작을 정의해준다.
만약에, TaskRepository
의 getAllTasks()
가 인자를 받는다면 어떻게 해야 할까.
대부분의 경우에는 TaskRepository
의 getAllTasks()
에 어떤 인자가 들어가든지 관계없이 특정 값을 리턴하게끔 모킹을 진행할 것이다.
이 경우 every { taskRepository.getAllTasks(any()) }
와 같이 any()
를 통해 인자 자리를 채워주면 된다.
'무엇을 할 때에 (When)'는 '모든 작업 리스트를 요청하면'이다.
즉, TaskService
의 getAllTasks()
메소드를 실제로 호출해야 한다.
TaskService
의 getAllTask()
메소드 호출에 필요한 TaskRepository
는 이미 MockK를 통해 모킹되었기 때문에, 그냥 그대로 호출하면 된다.
첫번째 '이런 결과가 나온다 (Then)'는 '결과 리스트를 조회해야 한다'이다.
즉, TaskRepository
의 getAllTasks()
메소드가 실제로 불리었는지 체크해야 한다.
verify(exactly = 1) {}
구문을 통해 TaskService
의 getAllTasks()
를 호출했을 때에 TaskRepository
의 getAllTasks()
메소드가 실제로 1번만 불리었는지 확인한다.
반대로, 특정 메소드가 한번도 불리지 않았다는 것을 검증하기 위한다면 verify(exactly = 0) {}
를 사용하면 된다.
두번째 '이런 결과가 나온다 (Then)'는 '조회된 결과 리스트에 N개의 작업이 존재해야 한다'이다.
즉, TaskService
의 getAllTasks()
메소드의 반환 값이 1개 이상의 데이터를 반환했는지 체크해야 한다.
내가 진행하고 있는 프로젝트가 WebFlux를 사용하지 않았다면 List<DTO>
가 반환되었을 것이다.
그렇다면 result.size shouldBe dataSourceList.size
와 같은 꼴로 확인할 수 있었을 것이다.
하지만 WebFlux를 사용하고 있기에 Flux<DTO>
를 검증해야 됐고, 이는 Kotest와 MockK 외에 Reactor-Test의 StepVerifier
를 사용해야 한다.
expectSubscription()
: onSubscribe
이벤트를 검증하며, 제일 우선적으로 호출해야 한다.expectNext()
: onNext
이벤트를 검증하며, 각 데이터의 값에 대해 검증할 수 있다.vararg T
에 대한 오버로딩도 있기에 Spread Operator를 써서 쉽게 순차 검증을 할 수 있다.expectComplete()
: onComplete
이벤트를 검증하며, 스트림이 정상 종료되었음을 확인하기 위해 마지막에 호출해야 한다.verify()
: StepVerifier
에 정의된 검증 케이스들을 모두 수행한다. (이 시점에 테스트 케이스의 성공/실패가 결정된다.)개인적으로 'Kotest + MockK' 조합으로 작성한 테스트 코드에 대해서는 매우 만족 중이다.
테스트 목적에 맞는 스타일을 사용하고, 메소드마다 붙던 @Test
어노테이션이 없어지면서 테스트 코드의 가독성도 훨씬 높아졌다.
StepVerifier
나 WebTestClient
와 같이 기존 Spring Boot 테스트에 쓰이던 도구들과의 호환도 문제 없이 되고.
개인적으로는 여태까지 작성했던 'JUnit + Mockito' 테스트 코드를 전부 'Kotest + MockK' 조합으로 갈아엎고 싶을 정도이다.
물론, 이미 잘 동작하는 테스트 코드를 전부 바꾸는 건 해서는 안되는 짓이니 패스.
대신에 새로운 프로젝트를 시작할 때에는 무조건 'Kotest + MockK'를 사용할 예정이다.
afterContainer {
clearAllMocks()
}
위에 기술된 세 줄... 매우 중요하다!
풀어서 쓰면 아래와 같은 의미이다.
afterContainer
: Describe나 Given 등, 각 컨테이너 블록이 끝나고 난 뒤에 실행되는 동작을 기술한다.clearAllMocks()
: 모킹된 모든 객체들을 다시 원상 복구한다.그러니까... 얘 빼먹으면 이전에 이미 수행된 테스트 케이스의 객체들이 그대로 사용되어, 원치 않은 결과가 나올 수 있다.
이렇게 작성해도 정상적으로 동작한다.
class SimpleServiceTest : BehaviorSpec({
val simpleEntity = Simple(/* ... */)
init {
val simpleRepository = mockk<simpleRepository>()
/* ... */
Given("Test Situation 1,") {
/* ... */
}
/* ... */
}
}
이렇게 작성해도 정상적으로 동작한다.
class SimpleServiceTest : BehaviorSpec({
val simpleRepository = mockk<simpleRepository>()
/* ... */
Given("Test Situation 1,") {
/* ... */
}
/* ... */
}) {
companion object {
val simpleEntity = Simple(/* ... */)
}
}
근데 이렇게 작성하면 동작하지 않는다.
class SimpleServiceTest : BehaviorSpec() {
val simpleEntity = Simple(/* ... */)
val simpleRepository = mockk<simpleRepository>()
/* ... */
Given("Test Situation 1,") {
/* ... */
}
/* ... */
}
무슨 말을 하고 싶은거냐면... Kotest / Mockk 테스트 코드는 테스트 클래스가 로드/초기화 되는 시점에 존재해야 한다.
[1]: Test-Driven-Development (테스트 주도 개발), 별도 TIL로 작성 예정.
[2]: Behaivor-Driven-Developement(행위 주도 개발), 사용자 입장에서의 행위를 기반으로 테스트 시나리오를 작성한다.
[3]: Domain Specific Language, 특정 목적(도메인)을 위해 파생된 별도 언어로 라이브러리나 프레임워크의 사용성을 높여준다.
[4]: '첫 인상이 평생을 간다'라고, 사실 이 점이 가장 마음에 들었다.
[5]: Data Transfer Object, 계층간 데이터 교환을 하기 위해 명세를 제한한 데이터 객체 (Data Class).