TDD의 볼모지에서,, TDD 도입하기!

DevSeoRex·2024년 11월 7일
10

😥 이제와서 TDD..?

처음 프로젝트를 받아서 확인해보니, Gradle 멀티모듈 구조로 이루어져 있었고 어떤 프로젝트는 Spock으로 어떤 프로젝트는 Spring Rest Docs로 테스트가 작성되어 있어서 통일성이 깨져있었습니다.

다른 프로젝트들은 아직 TDD를 도입하려고 노력만(?)하고 있는 중이기에 신뢰성있는 코드작성하기 위한 Test Code가 존재하는 프로젝트는 없었습니다.

프로젝트 자체도 너무 거대했고, 거대한 의존성들이 뒤얽혀서 순환참조로 이어지는 이 서비스를 과연 살릴 수 있을지도 의문이였습니다.

그럼에도 불구하고, 제가 담당한 마이크로 서비스는 우리 서비스의 핵심 기능을 담당하고 있었고 절대 물러설 수 없는 리팩토링 & 테스트 코드 작성 전쟁시작되었습니다.

🙃 무엇을 테스트 할 것인가?

테스트 코드를 기계적으로 짜는 학습은 여러 번 해온 일이였지만, 정말 이 테스트가 저를 포함한 개발자들에게 어떤 확신을 줄 수 있을지에 대한 고민을 하게 되었습니다.

테스트를 짜는 목적은 무엇인가? → 무엇을 테스트 할 것인가? → 나는 누구인가(?)

생각이 돌고 돌다보니 테스트를 왜 짜는지, 테스트 코드를 짜서 어떤 것을 검증하려고 하는지 명확하게 머리속에 잡혀있지 않았고 테스트를 짜는 이유와 개념부터 바로잡기로 했습니다.

🫢 Classist vs Mockist

테스트를 어떻게 할지 방법론공부하다 보면, 대립하는 두 개의 스타일이 나옵니다.

ClassistMockist, 결론부터 말씀드리면 전 어느 하나 선택해서 테스트작성하고 있지 않습니다.

무조건 Mocking 해서 테스트를 작성하면 실제 구현된 코드응답검증해야 하는 경우, 충분히 검증되었다고 볼 수 없다고 판단하였고 Mocking을 통해 호출이 잘 되는지, 적절한 예외는 잘 던져주고 있는지만 검증해도 충분한 경우 실제 구현체동작시키는 것은 too-much 하다고 보았습니다.

그래서 저는 아래와 같은 기준에 의해 테스트 코드작성하고 있습니다.

  • Repository : Classist
  • Service, Controller : Mocking

Repository Test 에서는 Mocking을 사용하지 않는 구조를 가져가고 있는데요, 그 이유는 DB를 사용하면서 실제 데이터가 잘 들어가는지 보는 것 뿐만 아니라 Query Repository 등 다양한 Repository를 테스트하는데는 단순 호출만 가능할지 보는 것은 문제가 될 수 있기 때문입니다.

특히 JPA를 사용하는 환경에서는 연관관계가 잘못 맺어진경우 Mocking한 결과값만 믿고 테스트가 성공했다고 코드배포하면 큰 재앙을 맞이할 수 있습니다.

지금부터는 계층별로 테스트를 어떻게 작성했는지, 팀에서 테스트를 쉽게 작성할 수 있도록 문서화 테스트를 위한 DSL 개발 등에 대해 다뤄보겠습니다.

🛢️ Repository Test 작성하기

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
})

JPAQueryDSL 설정이 작성되어 있는 설정 클래스인 JpaConfigurationImport 해주고, Kotest에서 제공하는 SpecBDD 기반 테스트를 작성하기 쉽게 도와주는 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-fixturekotest 사용을 많이 해보지 않으신 분들은 코드가 다소 어려우실 수 있습니다.

이 부분에 대해서는 자세히 다루지 않고 있으니, 공식 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함수형 언어 특성을 이용해 쉽게 구현할 수 있었습니다.

🥴 Service Test

그렇다면 Business Layer는 순수 Mocking으로 테스트 코드를 작성하기로 하였습니다.

Business Layer Test 역시 각종 중복코드 제거를 위한 ServiceTestSupport 클래스를 상속받고 있습니다.

internal abstract class ServiceTestSupport(
    protected val fixture: Fixture = FixtureFactory.getInstance(),
) : BehaviorSpec({
    isolationMode = IsolationMode.InstancePerLeaf

    afterTest {
        clearAllMocks()
    }
})

테스트독립적으로 동작하도록 isolationMode를 설정해주는 것과, mockkmocking객체들의 지정된 응답을 비워주는 두 가지 일을 하는 클래스입니다.

Business Layer는 대부분 Repository를 의존하고 있는데, Repository TestClassist 기반 테스트이므로 충분히 검증되었다고 보고 RepositoryMocking 하여 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

Controller TestService LayerMocking하고 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 TestBehaviorSpec을 사용하지 않고 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가 가질 수 있는 값들의 유효성검사하는 테스트도 살펴보겠습니다.

여기에 등장하는 @MockkBeanMocking한 객체를 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 Test

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를 작성하고보니 이런 생각이 들었습니다.

에서 ConfluenceAPI 문서를 관리하는데 매번 API Spec이 변경될때마다 문서를 수정해야 해서 누락되거나 현행화되지 않는 문서가 있는 경우가 많았다는 것이죠.

Spring Rest Docs + Swagger 연동 경험이 사이드 프로젝트에서 있었던 저는 과감하게 DocsTest까지 마무리를 해보기로 팀에 선언했습니다.

RestDocsTest 또한 RestDocsTestSupport 기반으로 작성되었습니다.

RestDocsTestStringSpec을 사용해서 간단하게 어떤 테스트인지와 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을 만들기로 결정했습니다.

🤔 진짜 마지막! 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가 도입되고 테스트를 사랑하는 문화를 가꿔가기 위해서 또 다른 프로젝트 역시 테스트 가능한 좋은 구조를 가지게 리팩토링하고 테스트를 짜며 올해 연말을 보낼 거 같네요 !

오늘도 제 글을 읽어 주셔서 감사합니다.

참고한 레퍼런스

5개의 댓글

comment-user-thumbnail
2024년 11월 7일

리얼

1개의 답글
comment-user-thumbnail
2024년 11월 7일

좋은 글 감사합니다~!ㅎㅎㅎ

답글 달기