처음 프로젝트
를 받아서 확인해보니, Gradle
멀티모듈 구조로 이루어져 있었고 어떤 프로젝트는 Spock으로 어떤 프로젝트는 Spring Rest Docs
로 테스트가 작성되어 있어서 통일성이 깨져있었습니다.
다른 프로젝트들은 아직 TDD
를 도입하려고 노력만(?)하고 있는 중이기에 신뢰성
있는 코드
를 작성
하기 위한 Test Code
가 존재하는 프로젝트는 없었습니다.
프로젝트
자체도 너무 거대했고, 거대한 의존성
들이 뒤얽혀서 순환참조로 이어지는 이 서비스
를 과연 살릴 수 있을지도 의문
이였습니다.
그럼에도 불구하고, 제가 담당한 마이크로 서비스
는 우리 서비스의 핵심 기능을 담당하고 있었고 절대 물러설 수 없는 리팩토링
& 테스트 코드
작성 전쟁은 시작되었습니다.
테스트 코드
를 기계적으로 짜는 학습은 여러 번 해온 일이였지만, 정말 이 테스트
가 저를 포함한 개발자들에게 어떤 확신을 줄 수 있을지에 대한 고민을 하게 되었습니다.
테스트
를 짜는 목적
은 무엇인가? → 무엇을 테스트
할 것인가? → 나는 누구인가(?)
생각
이 돌고 돌다보니 테스트
를 왜 짜는지, 테스트 코드를 짜서 어떤 것을 검증
하려고 하는지 명확하게 머리속에 잡혀있지 않았고 테스트
를 짜는 이유와 개념
부터 바로잡기로 했습니다.
테스트
를 어떻게 할지 방법론
을 공부하다 보면, 대립하는 두 개의 스타일이 나옵니다.
Classist
와 Mockist
, 결론부터 말씀드리면 전 어느 하나 선택해서 테스트를 작성하고 있지 않습니다.
무조건 Mocking
해서 테스트
를 작성하면 실제 구현된 코드의 응답을 검증
해야 하는 경우, 충분히 검증되었다고 볼 수 없다고 판단
하였고 Mocking
을 통해 호출이 잘 되는지, 적절한 예외는 잘 던져주고 있는지만 검증
해도 충분한 경우 실제 구현체
를 동작
시키는 것은 too-much 하다고 보았습니다.
그래서 저는 아래와 같은 기준에 의해 테스트 코드
를 작성하고 있습니다.
Repository
: ClassistService
, Controller
: MockingRepository
Test 에서는 Mocking
을 사용하지 않는 구조를 가져가고 있는데요, 그 이유는 DB
를 사용하면서 실제 데이터
가 잘 들어가는지 보는 것 뿐만 아니라 Query Repository
등 다양한 Repository
를 테스트하는데는 단순 호출만 가능할지 보는 것은 문제가 될 수 있기 때문입니다.
특히 JPA
를 사용하는 환경에서는 연관관계
가 잘못 맺어진경우 Mocking
한 결과값만 믿고 테스트가 성공했다고 코드를 배포하면 큰 재앙
을 맞이할 수 있습니다.
지금부터는 계층별로 테스트
를 어떻게 작성했는지, 팀에서 테스트
를 쉽게 작성할 수 있도록 문서화 테스트를 위한 DSL 개발
등에 대해 다뤄보겠습니다.
Repository Test
를 작성하기 위해서 공통된 중복코드들을 모두 support 패키지
에 Util 클래스로 작성했습니다.
테스트
를 작성하면서 필요한 Entity
를 생성해야 할때가 많습니다.
그때마다 상태를 직접 입력하면서 Entity
를 수동으로 만들다보면 Entity
를 생성하는 코드도 커지고 이로인해 중복코드
가 발생합니다.
따라서 kotlin-fixture
를 이용해서 랜덤
한 상태를 가진 Entity
가 생성되도록 하되, 특정 상태를 가진 Entity
의 변화를 추적할 필요가 있을때는 객체 생성
시 직접 상태를 넣어주었습니다.
kotlin-fixture
객체는 한 번만 생성
되서 주입받아 사용하면 되므로, fixtureFactory
를 만들어서 싱글톤으로 관리했습니다.
object FixtureFactory {
private val FIXTURE = Fixture()
fun getInstance() = FIXTURE
}
Repository Test
에서 공통적으로 가져야 하는 애너테이션
이나 속성
을 가진 RepositoryTestSupport
클래스도 추상 클래스로 정의해주었습니다.
@DataJpaTest
@Rollback(false)
@Import(JpaConfiguration::class)
internal abstract class RepositoryTestSupport(
protected val fixture: Fixture = FixtureFactory.getInstance(),
) : BehaviorSpec({
extensions(SpringTestExtension(SpringTestLifecycleMode.Root))
isolationMode = IsolationMode.InstancePerLeaf
})
JPA
와 QueryDSL
설정이 작성되어 있는 설정 클래스인 JpaConfiguration
을 Import
해주고, Kotest
에서 제공하는 Spec
중 BDD
기반 테스트를 작성하기 쉽게 도와주는 BehaviorSpec
을 상속받았습니다.
이 게시글에서 모든 테스트는 Kotest + Kotlin-Fixture + MockkBean
기반으로 작성됩니다.
internal class BoardRepositoryTest(
private val boardRepository: BoardRepository,
) : RepositoryTestSupport() {
init {
this.afterTest {
boardRepository.deleteAllInBatch()
}
this.Given("게시글 전체 조회를 하려는 상황에서") {
val boardList = listOf(
fixture<Board> {
property(Board::id) { null }
property(Board::comments) { mutableSetOf() }
},
fixture<Drive> {
property(Board::id) { null }
property(Board::comments) { mutableSetOf() }
}
)
boardRepository.saveAll(driveList)
val boardIds = boardList.map { it.id!! }
When("게시글 ID 리스트로 조회를 시도하면") {
val findBoardList = boardRepository.findAllByIdIn(driveIds)
Then("정상적으로 조회가 되어야 한다") {
boardList shouldBe findBoardList
}
}
}
}
}
위의 코드는 특정 게시글 리스트를 DB
에 저장
하고 전체 조회하는 테스트 코드
입니다.
kotlin-fixture
와 kotest
사용을 많이 해보지 않으신 분들은 코드가 다소 어려우실 수 있습니다.
이 부분에 대해서는 자세히 다루지 않고 있으니, 공식 document
나 다른 게시글을 봐주시면 좋을 거 같습니다!
Test Code
는 문서
의 기능도 가지고 있어야 하는데, 코드
자체로 충분히 어떤 목적
을 가지고 있는지 알 수 있게 작성
할 수 있고, 검증을 하는 부분에서도 infix function
등을 사용해서 kotlin
특유의 강력한 가독성을 챙겨갈 수 있습니다.
이번에 Repository Test
를 작성하면서 가장 어려웠던 부분은 PessimisticLock
이 걸린 함수를 테스트하는 부분이였는데요. 왜냐하면 트랜잭션 진행
중에 새로운 트랜잭션이 끼어들어야 하기 때문입니다.
저는 이 상황을 재현하기 위해서, TransactionSupport
라는 클래스를 만들고 Kotlin
이 함수형 언어라는 점에 집중했습니다.
@TestComponent
internal class TransactionSupport {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun <T> requiredNewTransaction(action: () -> T) = action()
}
전파속성을 REQUIRES_NEW
로 주어서 무조건 새로운 트랜잭션
을 시작하게 합니다.
사용 예시를 보겠습니다.
val exception = shouldThrow<PessimisticLockingFailureException> {
transactionSupport.requiredNewTransaction {
commendRepository.findForUpdate(comment.id!!)
?.update(1L, "new comment")
transactionSupport.requiredNewTransaction {
driveFileRepository.findForUpdate(comment.id!!)
?.update(1L, "new comment"
}
}
}
특정 댓글에 대한 수정시도를 위해 select for update
쿼리를 날리는 트랜잭션
이 시작되었는데, 그 트랜잭션
이 종료되기 전에 다른 트랜잭션
이 해당 댓글에 대한 조회를 시도하므로 예외
가 발생
합니다.
이 상황을 Kotlin
의 함수형 언어 특성을 이용해 쉽게 구현할 수 있었습니다.
그렇다면 Business Layer
는 순수 Mocking
으로 테스트 코드를 작성하기로 하였습니다.
Business Layer Test
역시 각종 중복코드 제거를 위한 ServiceTestSupport 클래스를 상속받고 있습니다.
internal abstract class ServiceTestSupport(
protected val fixture: Fixture = FixtureFactory.getInstance(),
) : BehaviorSpec({
isolationMode = IsolationMode.InstancePerLeaf
afterTest {
clearAllMocks()
}
})
각 테스트
가 독립적
으로 동작하도록 isolationMode
를 설정해주는 것과, mockk
로 mocking
된 객체
들의 지정된 응답을 비워주는 두 가지 일을 하는 클래스
입니다.
Business Layer
는 대부분 Repository를 의존하고 있는데, Repository Test
는 Classist
기반 테스트이므로 충분히 검증
되었다고 보고 Repository
를 Mocking
하여 Business Layer
의 논리적 흐름을 검증
하고 비즈니스 예외
를 던지는 부분이 잘 동작하는 지 등을 검증합니다.
internal class RegisterBoardServiceTest : ServiceTestSupport() {
init {
val boardRepository = mockk<BoardRepository>()
val eventPublisher = mockk<ApplicationEventPublisher>(relaxed = true)
val registerBoardService = RegisterBoardService(boardRepository, eventPublisher)
this.Given("[정상 케이스] - 게시글을 생성하려는 상황에서") {
val serviceRequest = RegisterBoardServiceRequest(
writerId = 1L,
accessType = AuthorityType.PRIVATE,
title = "Hello'o World"
)
every { boardRepository.isDuplicate(any<Long>(), any<String>()) } returns false
every { boardRepository.save(any<Board>()) } returns fixture<Board> {
property(Board::writerId { 1L }
}
When("게시글 생성을 시도하면") {
val response = registerBoardService.register(serviceRequest, 1L, 2L)
Then("게시글이 정상적으로 생성 되어야 한다") {
response shouldBe result
response.writerId shouldBe registerBoardServiceRequest.writerI
verify(exactly = 1) { boardRepository.isDuplicate(any<Long>(), any<String>()) }
verify(exactly = 1) { boardRepository.save(any<Drive>()) }
verify(exactly = 1) { eventPublisher.publishEvent(any<BoardResgisterEvent>()) }
}
}
}
}
}
이 코드는 정상 케이스
를 검증
하는 Business Layer Test 코드입니다.
mockk
라이브러리를 이용해서 RegisterBoardService
가 의존하는 객체들을 Mocking 합니다.
RegisterBoardService
기대한 횟수만큼 각 의존 객체의 함수를 알맞게 호출하는지 검증합니다.
여기서 핵심은 boardRepository
의 함수들은 Repository Test
에서 모두 검증되었고, ApplicationEventPublisher
의 함수는 이미 Spring 개발자
들에 의해 검증되어 있기 때문에 정말 필요한 단위 테스팅
만 할 수 있는 것입니다.
이제 예외 케이스
를 검증하는 케이스
를 보겠습니다.
this.Given("[예외 케이스 - 제목 중복] - 게시글을 생성하려는 상황에서") {
val serviceRequest = RegisterBoardServiceRequest(
writerId = 1L,
accessType = AuthorityType.PRIVATE,
title = "Hello'o World"
)
every { boardRepository.isDuplicate(any<Long>(), any<String>()) } returns true
When("중복된 제목으로 게시글을 생성하려고 시도하면") {
val exception = shouldThrow<DuplicationException> {
registerBoardService.register(serviceRequest, 1L, 2L)
}
Then("예외가 발생해야 한다") {
exception.code shouldBe ErrorCode.BOARD_DUPLICATION_TITLE
exception.type shouldBe Domain.BOARD
exception.message shouldBe "${request.title} is exist."
verify(exactly = 1) { boardRepository.isDuplicate(any<Long>(), any<String>()) }
verify(exactly = 0) { boardRepository.save(any<Board>()) }
verify(exactly = 0) { eventPublisher.publishEvent(any<BoardResgisterEvent>()) }
}
}
}
위의 코드는 같은 제목
으로 게시글
을 등록하려고 할때, 지정한 예외
가 발생
하는지 확인하는 테스트
입니다.
예외 발생
으로 인해, 예외 발생
지점 하위의 코드들이 실행되지 않아야 하므로 호출되지 않았는지 확인하기 위해서 verify(exatly=0)
검증 조건을 추가해주었습니다.
예외 케이스
역시 Mocking
을 통해 Repository Test
에서 검증된 부분은 재차 검증하지 않고, Business Layer
의 핵심 동작만 Compact
하게 테스트 할 수 있습니다.
Controller Test
는 Service Layer를 Mocking
하고 WebMvcTest
로 작성하고 있습니다.
현재 시스템에서는 Interceptor
를 활용하는 부분이 있기 때문에 Interceptor
는 외부 통신을 통해 권한
등을 체크
하고 있습니다. 따라서 Interceptor
로 인해 테스트가 어려운 부분이 있어, 관련 설정 클래스를 exclude
하는 custom annotation
을 사용합니다.
@WebMvcTest(
excludeFilters = [
ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE,
classes = [
InterceptorCongfiguration::class,
]),
]
)
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
internal annotation class CustomMvcTest(
@get:AliasFor(
annotation = WebMvcTest::class,
attribute = "value"
)
val controllers: KClass<*>,
)
이 애너테이션
을 활용하면 Interceptor
를 등록하는 클래스인 InterceptorCongfiguration
를 등록하지 않고 Web 요청
에만 집중해서 가벼운 테스트를 수행할 수 있습니다.
Controller Test
역시 ControllerTestSupport
클래스를 상속받아 작성됩니다.
internal abstract class ControllerTestSupport(
protected val fixture: Fixture = FixtureFactory.getInstance(),
) : DescribeSpec({
extensions(SpringExtension)
isolationMode = IsolationMode.InstancePerLeaf
afterTest {
clearAllMocks()
}
})
Controller Test
는 BehaviorSpec
을 사용하지 않고 DescribeSpec을 사용하고 있습니다.
Controller Test
는 어떤 API에 대해서 어떤 응답이 나오는지 정의하는것이 주된 목적이므로 DCI 패턴
이 더 어울린다고 판단하여 이렇게 테스트
를 작성하고 있습니다.
@CustomMvcTest(ModifyBoardController::class)
internal class ModifyBoardControllerTest(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper,
@MockkBean
private val modifyBoardUseCase: ModifyBoardUseCase,
) : ControllerTestSupport() {
init {
this.describe("[정상 케이스] - 게시글 수정 API 테스트") {
val result = fixture<ModifyBoardResponse>()
every {
modifyBoardUseCase.modifyBoard(
any<Long>(), any<Long>(),
any<Long>(), any<Long>(), any<ModfiyBoardServiceRequest>())
} returns result
context("특정 게시글의 수정을 시도하면") {
val actions = mockMvc.patch("/board-api/v2/{boardId}", "1") {
accept(MediaType.APPLICATION_JSON)
contentType = MediaType.APPLICATION_JSON
content = objectMapper.writeValueAsString(ModifyBoardCommand("게시글 수정 22", "내용 수정 22"))
with(csrf())
}.andDo { print() }
it("정상적으로 수정되어야 한다") {
actions.andExpect {
status { isOk() }
jsonPath("$") { isNotEmpty() }
}
val response = actions.andReturn().response.contentAsString
val expectedResult = objectMapper.writeValueAsString(result)
JSONAssert.assertEquals(response, expectedResult, true)
verify(exactly = 1) {
modifyBoardUseCase.modifyBoard(
any<Long>(), any<Long>(),
any<Long>(), any<Long>(), any<ModfiyBoardServiceRequest>())
}
}
}
}
}
}
정상 케이스
에 대해서는 기대한 응답이 제대로 나오는지 검증
하고 있습니다.
UseCase
의 호출 횟수와 반환된 Json Body
를 검증해서 제대로 된 응답인지 검증하는 것을 주된 목적으로 삼고 있습니다.
Controller Test
에서 예외 상황을 검증한다고 하면, Validation
이 잘되고 있는지 검증
하는 것이 대부분입니다. 특정 Command Object
가 가질 수 있는 값들의 유효성을 검사하는 테스트
도 살펴보겠습니다.
여기에 등장하는 @MockkBean
은 Mocking
한 객체를 Spring Bean
으로 등록해주는 라이브러리 입니다. 이 라이브러리를 사용해서 테스트 코드
를 간단하게 작성
할 수 있습니다.
@CustomMvcTest(ModifyBoardController::class)
internal class ModifyBoardControllerTest(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper,
@MockkBean
private val modifyBoardUseCase: ModifyBoardUseCase,
) : ControllerTestSupport() {
init {
this.describe("[예외 케이스 - 필수 값 누락] - 게시글 수정 API 테스트") {
val invalidCommand = ModifyBoardCommand(1L, null)
context("필수 값이 누락된 상태로 게시글 수정을 시도하면") {
val actions = mockMvc.patch("/board-api/v2/{boardId}", "1") {
accept(MediaType.APPLICATION_JSON)
contentType = MediaType.APPLICATION_JSON
content = objectMapper.writeValueAsString(invalidCommand)
with(csrf())
}.andDo { print() }
it("400(Bad Request) 에러가 발생해야 한다") {
actions.andExpect {
status { isBadRequest() }
jsonPath("$") { isNotEmpty() }
jsonPath("$.status") { value(HttpStatus.BAD_REQUEST.value()) }
jsonPath("$.msg") {
value(Matchers.containsString("[content is not null] : input value = [null]"))
}
}
verify(exactly = 0) {
modifyBoardUseCase.modifyBoard(
any<Long>(), any<Long>(),
any<Long>(), any<Long>(), any<ModifyBoardServiceRequest>())
}
}
}
}
}
}
예외 테스트
에서는 Validation
에서 유효성 검증에 실패하였을때, 올바른 상태 코드
와 메시지
가 클라이언트에 반환되고 있는지 확인
합니다.
현재 테스트
에서는 필수값을 누락시킨 상황에서 Validation
이 잘되고 있는지 확인
하고 있습니다.
사용자의 입력
을 받는 부분에서는 사용자의 입력을 신뢰할 수 없으므로 클라이언트
와 서버
가 전부 유효성을 검증
해야 합니다.
Domain
의 상태를 변경하는 여러 함수들이 Domain Object
에 포함
되어 있을때가 많습니다.
현재 서비스에서는 Domain Object
의 역할을 Jpa Entity가 수행하고 있습니다.
따라서 Jpa Entity
내부에 구현된 Domain 상태를 변경하는 코드를 검증해야 합니다.
제가 Test
를 작성한 기준은, 단순히 상태값
몇개를 바꾸는 것은 테스트
하지 않았으며, 복잡한 도메인 규칙을 구현하거나 예외가 발생하는 로직을 테스트
하였습니다.
Domain Test
역시 DomainTestSupport
클래스를 기반으로 작성됩니다.
internal abstract class DomainTestSupport(
protected val fixture: Fixture = FixtureFactory.getInstance()
) : BehaviorSpec({
isolationMode = IsolationMode.InstancePerLeaf
})
Domain Test
를 어떻게 작성할 수 있을지 예시를 살펴보겠습니다.
internal class BoardTest : DomainTestSupport() {
init {
this.Given("[정상 케이스] - 게시글을 완전 삭제하려는 상황에서") {
val memberId = 2L
val board = fixture<Board> {
property(Board::creatorId) { memberId }
property(Board::status) { Board.Status.DELETE }
}
When("완전 삭제를 시도하면") {
board.terminate(memberId)
Then("정상적으로 삭제되어야 한다") {
board.updaterId shouldBe memberId
board.status shouldBe Board.Status.DELETE
}
}
}
this.Given("[예외 케이스 - 삭제 상태가 아닌 경우] - 게시글을 완전 삭제하려는 상황에서") {
val board = fixture<Board> {
property(Board::id) { 8L }
property(Board::status) { Board.Status.ALIVE }
}
When("게시글이 삭제 상태가 아닌 경우") {
val exception = shouldThrow<PredicateException> {
board.terminate(1L)
}
Then("예외가 발생해야 한다") {
exception.type shouldBe Constant.Object.WASTEBASKET
exception.code shouldBe ErrorCode.IS_NOT_DELETED
exception.message shouldBe "can't delete board, boardId = [${board.id!!}]"
}
}
}
}
}
게시글
을 삭제
할때 일어날 수 있는 정상 케이스
와 예외 케이스
를 모두 검증하고 있습니다.
모든 케이스에 대해서 테스트
를 작성
할 수 있으면 좋겠지만, 현실적으로 어려운일이기 때문에 최대한 복잡한 로직
에 대해서는 테스트 코드
를 작성하려고 노력하고 있습니다.
Controller Test
를 작성하고보니 이런 생각이 들었습니다.
팀
에서 Confluence
에 API 문서
를 관리하는데 매번 API Spec
이 변경될때마다 문서를 수정해야 해서 누락되거나 현행화
되지 않는 문서가 있는 경우가 많았다는 것이죠.
Spring Rest Docs + Swagger
연동 경험이 사이드 프로젝트에서 있었던 저는 과감하게 DocsTest
까지 마무리를 해보기로 팀에 선언
했습니다.
RestDocsTest
또한 RestDocsTestSupport
기반으로 작성되었습니다.
RestDocsTest
는 StringSpec
을 사용해서 간단하게 어떤 테스트인지와 API 경로를 적어주었습니다.
@AutoConfigureRestDocs
internal abstract class RestDocsTestSupport(
val fixture: Fixture = FixtureFactory.getInstance(),
) : StringSpec({
extensions(SpringTestExtension(SpringTestLifecycleMode.Root))
isolationMode = IsolationMode.InstancePerLeaf
afterTest {
clearAllMocks()
}
})
RestDocsTest
에서 고려되어야 할 사항은 문서
로서 가치
가 있어야 한다는 것입니다.
따라서 테스트
에서 사용하던 fixture
와 같이 난수를 집어넣어 아무 값이나 나오는 객체
를 반환 값으로 사용하면 API 문서
도 난수로 도배될 것입니다.
그래서 각 API Spec
에 맞는 값을 Confluence
를 보고 직접 옮겨서, 클라이언트가 이 API
를 호출
했을 때 어떤 값이 나올지 명시
했습니다.
SpecificationObjectFactory
파일을 정의하고 top-level
함수로 각 API Spec
에 맞는 Object를 정의 했습니다.
internal fun createBoardRegisterResponse() = BoardRegisterResponse(
title = "New 게시글",
content = "문서화 쉽지 않네요..")
.... 중략 이런 함수가 수십개 있음
이제 RestDocsTest
를 작성하는 일만 남았습니다. 간단한 케이스를 한 개를 작성
해보겠습니다.
@CustomMvcTest(ModifyBoardController::class)
internal class ModifyBoardControllerTest(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper,
@MockkBean
private val modifyBoardUseCase: ModifyBoardUseCase,
) : RestDocsTestSupport() {
init {
"게시글 수정 API 문서화 TEST - path[/board-api/v2/{boardId}]" {
val result = createBoardModifyResponse()
every {
modifyBoardUseCase.modifyBoard(
any<Long>(), any<Long>(), any<Long>(),
any<Long>(), any<ModifyBoardServiceRequest>())
} returns result
withMockUser {
mockMvc.perform(patch("/board-api/v2/{boardId}", "1")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(ModifyBoardCommand(1L, 2L)))
.with(csrf())
)
.andDo(MockMvcResultHandlers.print())
.andExpect(status().isOk())
.andDo(
document("Modify-Board-API",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
resource(
builder()
.tag("Board API")
.description("게시글 수정 API")
.requestFields(
fieldWithPath("field1").type(SimpleType.NUMBER).description("field1"),
fieldWithPath("field2").type(SimpleType.NUMBER).description("field2"),
)
.responseFields(
fieldWithPath("field1").type(JsonFieldType.NUMBER).description("field1"),
fieldWithPath("field2").type(JsonFieldType.NUMBER).description("field2"),
fieldWithPath("field3").type(JsonFieldType.NUMBER).description("field3"),
fieldWithPath("field4").type(JsonFieldType.NUMBER).description("field4"),
fieldWithPath("field5").type(JsonFieldType.STRING).description("field5"),
fieldWithPath("field6").type(JsonFieldType.ARRAY).description("field5").isOptional(),
fieldWithPath("field7").type(JsonFieldType.STRING).description("field6"),
... 중략
) )
.requestSchema(Schema.schema("ModifyBoardCommand"))
.responseSchema(Schema.schema("BoardModifyResponse"))
.build()
)
)
)
}
verify(exactly = 1) {
modifyBoardUseCase.modifyBoard(
any<Long>(), any<Long>(), any<Long>(),
any<Long>(), any<ModifyBoardServiceRequest>())
}
}
}
}
게시글
을 수정
하는 간단한 케이스임에도, 코드가 굉장히 길고 복잡
합니다.
또 RestDocs
를 구성하는 여러 코드들을 봤을때 중복
된 코드
도 많고, 직관적
이지 않은 코드들도 많습니다.
이대로 사용해도 Swagger 문서
를 만들고 Test 기반
의 문서를 만드는 목적은 달성했지만, 장기적으로 보았을때 팀에서 테스트
를 쉽게 작성하기 위해서 팀에서 자주 사용하는 용어
로 구성되며 사용성을 향상시킨 RestDocs-DSL
을 만들기로 결정
했습니다.
Kotlin
의 강력한 기능인 확장 함수
와 중위 함수
를 이용하면 정말 훌륭한 DSL
을 만들어 낼 수 있습니다.
Kotest
에서 자주 사용하는 Assertion
함수 중에 shouldBe
시리즈가 전부 infix function
입니다. DSL
에 대해서 찾아보다가 토스
의 기술 블로그
를 보고 우리 팀에 맞게 어떻게 차용할 수 있을지 고민해서 최종적으로 만든 DSL
은 아래와 같습니다.
// RestDocsDSL.kt
internal fun ResultActions.andDocument(
identifier: String,
parameterBuilder: (parameterBuilder: ResourceSnippetParametersBuilder) -> ResourceSnippetParametersBuilder
) : ResultActions {
return this.andDo(
document(identifier,
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
resource(parameterBuilder(builder()).build()),
)
)
}
internal infix fun ParameterDescriptorWithType.description(
description: String,
) : ParameterDescriptorWithType = this.description(description)
internal infix fun HeaderDescriptorWithType.description(
description: String,
) : HeaderDescriptorWithType = this.description(description)
internal infix fun HeaderDescriptorWithType.isOptional(
isOptional: Boolean,
) : HeaderDescriptorWithType = if (isOptional) this.optional() else this
internal fun ResourceSnippetParametersBuilder.queryParams(
vararg queryParams: ParameterDescriptorWithType,
) : ResourceSnippetParametersBuilder = this.queryParameters(*queryParams)
... 중략
internal fun ResourceSnippetParametersBuilder.responseType(
responseType: String,
) = this.responseSchema(Schema.schema(responseType))
Spring MockMvc
에서 제공하는 ResultActions
인터페이스에 임의의 함수를 추가하는 확장 함수
추가 기능으로 자연스럽게 체이닝
해서 사용하도록 만들어보았습니다.
중복코드
는 내부에 숨기고, infix function
을 적극 활용해서 왼쪽
에서 오른쪽
으로 책 읽듯이 잘 읽히는 테스트 코드
를 작성
할 수 있도록 했습니다.
@CustomMvcTest(LoadBoardController::class)
internal class LoadBoardControllerDocsTest(
@MockkBean
private val loadBoardUseCase: LoadBoardUseCase,
) : RestDocsTestSupport() {
init {
"특정 게시글조회 API 문서화 TEST - path[/board-api/v2/{boardId}]" {
val result = LoadBoardResponse(
title = "새로운 게시글",
content = "새로운 게시글 이래요",
)
every {
loadBoardUseCase.loadBoard(
any<Long>()
)
} returns result
mockMvc.perform(get("/board-api/v2/{boardId}", "1")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(status().isOk())
.andDocument("Load-Board-API") {
it.tag("Board API")
.description("특정 게시글 조회 API"
.pathVariables(
"boardId" type ParameterType.NUMBER description "Board ID",
)
.responseBody(
"title" type FieldType.STRING description "게시글 제목",
"content" type FieldType.STRING description "게시글 내용",
)
.responseType("LoadBoardResponse")
}
verify(exactly = 1) {
loadBoardUseCase.load(
any<Long>(), any<Long>(), any<Long>(),
any<Long>(), any<LoadDriveFileDownloadUrlServiceRequest>(),
any<String>(), any<String>()
)
}
}
}
}
이렇게 RestDocsDSL
개발로, 조금 더 간단하게 테스트
를 작성
할 수 있게 되었습니다.
Test
를 짜는 일이 어렵지 않게끔 도구를 개발해서 팀내에 TDD 문화
를 정착시키기 위해 노력했습니다.
아직 더 발전
시킬 수 있는 부분이 많은 서비스
이지만 대규모 개편 이전에 Test
를 작성
함으로서 앞으로 변경되는 코드
가 이전 코드
의 의도를 잘 가지고 있는지 등을 검증
할 수 있게 되었습니다.
오늘까지 총 299개
의 테스트를 작성하고 유지하면서 테스트
에 대한 생각
과 개념
을 정립해나갑니다.
팀
의 모든 프로젝트에 TDD
가 도입되고 테스트
를 사랑하는 문화를 가꿔가기 위해서 또 다른 프로젝트 역시 테스트 가능한 좋은 구조를 가지게 리팩토링
하고 테스트
를 짜며 올해 연말을 보낼 거 같네요 !
오늘도 제 글을 읽어 주셔서 감사합니다.
리얼