부트캠프
에서 시작한 최초의 프로젝트에 이어서 약 4개
정도의 프로젝트를 경험한 저는 테스트 코드
를 제대로 작성해 본 경험이 없었습니다.
최근에 포트폴리오
를 위해 작업한 프로젝트에서 Spring Rest Docs
도입을 위해 작성한 컨트롤러 테스트가 테스트 코드 작성 경험의 전부라고 봐도 무방하죠.
이러던 제가 우아한 남형제
들 프로젝트에 참여하게 되면서, 팀원들과 팀 문화를 만들어가며 테스트 코드
를 반드시 작성해야 한다는 규칙이 만들어지게 되었습니다.
백엔드 개발자
로서 당연히 테스트 코드
에 관심이 있고 짜보고 싶고 어떻게 하면 제대로 돌아가고 어떻게 하면 펑펑 터지는지 정말 궁금했습니다.
그런데 가장 큰 문제는, 테스트 코드
를 어떻게 시작해야 될 지 모른다는 것입니다.
Spring Boot
프로젝트를 생성하면 기본적으로 있는 Test 폴더
에는 자동으로 생성된 테스트 클래스 하나 뿐이고, 어디서부터 시작해야 할지 정말 막막했습니다.
출근길
에 제가 즐겨보는 한 유튜브에서 테스트 코드
도 많이 짜봐야 더 좋은 테스트가 가능하다고 말해주는 감사한 분
이 계셔서 용기를 가지고 일단 짜보자는 마음으로 시작했습니다.
저는 우아한 남형제
들 프로젝트에서 주문 서비스를 담당하고 있습니다.
주문 서비스는 Kotlin + Spring
기반으로 개발하고 있고, Hexagonal Architecture
로 구성되어 있습니다.
특정 구현 기술
에 종속되지 않는 순수한 도메인 객체
를 만들고자 꽤 많은 시간을 고민하고 공들였기 때문에 정말 분리가 잘 되있는지 궁금해서 UseCase
부터 테스트
하기로 결정했습니다.
유저
가 주문할 상품을 담고, 결제
를 완료하면 서버는 주문을 생성해야 합니다.
이 과정에서 주문 생성이 잘 되고 있는지 테스트하기 위해서 Junit5 + Mockk
조합으로 테스트를 작성했습니다.
DB
에 직접 데이터를 넣고, 확인하는 것은 애플리케이션
이 커질 수록 테스트 수행 시간
이 기하급수적으로 늘어날 것을 염려하여 비즈니스 로직
이 정상 동작
하는지 확인
할 수 있도록 대역 객체
를 사용했습니다.
class CreateOrderUseCaseTest {
private val createOrderPort = mockk<CreateOrderPort>()
private val createOrderService = CreateOrderService(createOrderPort)
@Test
fun `주문 생성 정상 케이스 테스트`() {
// given
val itemList = listOf(
CreateOrderItemCommand(1L, "서브웨이", 9500, 2, 19000),
CreateOrderItemCommand(3L, "버거킹", 13000, 2, 26000)
)
val orderCommand = CreateOrderCommand(
storeId = 7L,
totalPrice = 45000,
address = "서울시 마포구",
orderItems = itemList
)
// Mocking한 객체의 동작을 정의
every { createOrderPort.createOrder(orderCommand) } returns Unit
// when
val actualData = createOrderService.createOrder(orderCommand)
// then
Assertions.assertThat(actualData).isEqualTo(Unit)
// createOrderPort.createOrder() 함수가 한 번만 호출되었는지 검증
verify(exactly=1) { createOrderPort.createOrder(orderCommand) }
}
}
CreateOrderService
를 테스트하기 위해서 CreateOrderPort
의 대역객체를 생성하고, 동작을 정의해 주었습니다.
정의된대로 동작한다면 CreateOrderPort
의 createOrder
함수는 Unit
을 반환하게 되고, 테스트는 성공하게 됩니다.
주문의 PK 값으로 주문을 찾는 테스트
또한 비슷한 형식으로 작성했습니다.
class LoadOrderUseCaseTest {
private val loadOrderPort = mockk<LoadOrderPort>()
private val loadOrderService = LoadOrderService(loadOrderPort)
@Test
fun `주문 아이디로 주문 찾기 테스트`() {
// given
val orderId = 1L
val expectedOrderData = Order(
orderer = Orderer("Dev Seo Rex", "010-1111-1111", "서울시 마포구"),
orderItem = listOf(
OrderItem("로세라티 치킨", 7000, 2),
OrderItem("서브웨이 클럽", 6500, 2)
),
orderStatus = OrderStatus.RECEIPT
)
every { loadOrderService.loadOrderById(orderId) } returns expectedOrderData
// when
val actualData = loadOrderService.loadOrderById(orderId)
// then
Assertions.assertThat(actualData).isEqualTo(expectedOrderData)
verify { loadOrderService.loadOrderById(orderId) }
}
}
특정 구현 기술
에 종속되지 않는 순수한 도메인 규칙
을 구현
한 도메인 객체 테스트를 작성해보겠습니다.
현재 주문 상태
가 조리 중
이거나 배달 중
이 아니라면 주문 취소 접수
가 가능하다고 정의된 도메인 규칙을 검증하기 위한 테스트 코드
를 작성
해보겠습니다.
@Test
fun `주문 취소 요청 정상 케이스 테스트`() {
// given
val order = Order(
orderer = Orderer("Dev Seo Rex", "010-1111-1111", "서울시 마포구"),
orderItem = listOf(
OrderItem("로세라티 치킨", 7000, 2),
OrderItem("서브웨이 클럽", 6500, 2)
),
orderStatus = OrderStatus.RECEIPT
)
// when
order.requestCancel()
// then
Assertions.assertThat(order.orderStatus).isEqualTo(OrderStatus.USER_CANCEL_REQUEST)
}
주문 상태
가 조리 중
이거나 배달 중
일때 주문 취소 접수를 시도하면 예외
가 발생하는지 검증하는 테스트도 작성하겠습니다.
@Test
fun `주문 취소 비정상 케이스 테스트 - 조리 중 취소 불가`() {
// given
val order = Order(
orderer = Orderer("Dev Seo Rex", "010-1111-1111", "서울시 마포구"),
orderItem = listOf(
OrderItem("로세라티 치킨", 7000, 2),
OrderItem("서브웨이 클럽", 6500, 2)
),
orderStatus = OrderStatus.COOKING
)
// when
val exception = Assertions.assertThatThrownBy { order.requestCancel() }
// then
Assertions.assertThat(order.orderStatus).isEqualTo(OrderStatus.COOKING)
exception.isInstanceOf(IllegalArgumentException::class.java)
}
현재 작성한 테스트
들은 모두 잘 동작하고, given-when-then
패턴으로 직관적인 형태를 보이고 있습니다.
그렇다면 이 테스트
를 유지해도 괜찮을까요?
위에서 작성한 테스트 코드
의 문제점이 있다면 저는 크게 두 가지로 보았습니다.
주석
으로 명시
해서 생각보다 잘 보이지 않는다.테스트
를 위한 DTO
나 도메인 객체
를 생성하는 보일러 플레이트 코드가 너무 많다.위에서 작성한 테스트
가 어떻게 개선되었는지 보겠습니다.
internal class CreateOrderUseCaseTest : BehaviorSpec({
val createOrderPort = mockk<CreateOrderPort>()
val createOrderUseCase = CreateOrderService(createOrderPort)
val fixture = kotlinFixture()
Given("유저가 상품을 주문 상품을 담고, 결제가 완료된 상태에서") {
val orderCommand = fixture<CreateOrderCommand>()
every { createOrderPort.createOrder(orderCommand) } returns 1L
When("배달 주문을 요청하면") {
val actualData = createOrderUseCase.createOrder(orderCommand)
Then("정상적으로 주문이 생성(접수)되어야 한다") {
actualData shouldBe 1L
verify(exactly = 1) { createOrderPort.createOrder(orderCommand) }
}
}
}
})
먼저 테스트
를 위해 데이터를 담는 객체를 직접 값을 넣어주지 않고, kotlinfixture
라이브러리를 이용해 임의의 값을 넣어주도록 변경했습니다.
// 1. 직접 생성
val itemList = listOf(
CreateOrderItemCommand(1L, "서브웨이", 9500, 2, 19000),
CreateOrderItemCommand(3L, "버거킹", 13000, 2, 26000)
)
val orderCommand = CreateOrderCommand(
storeId = 7L,
totalPrice = 45000,
address = "서울시 마포구",
orderItems = itemList
)
// 2. kotlinfixture를 이용한 자동 생성
val orderCommand = fixture<CreateOrderCommand>()
들어가는 값
은 다르겠지만, 테스트를 위한 데이터이므로 임의의 값
이 들어가도 크게 문제가 되지 않습니다.
kotlinfixture
를 사용해 작성한 코드가 훨씬 가독성이 좋고 간단합니다.
일부 필드 값
만 직접 지정해야 되고, 나머지 필드의 값들은 임의
로 생성
되도 되는 경우에도 사용이 가능합니다.
val orderCommand = fixture<CreateOrderCommand> {
// CreateOrderCommand의 stordeId 필드의 값을 1L로 지정
property<CreateOrderCommand, Long>("storeId") { 1L }
}
이렇게 필요한 특정 필드
의 값만 제대로 세팅하는 것도 어렵지 않기에 생산성
을 매우 높여줍니다.
두 번째로 변화를 준 부분은, Kotest
를 도입했다는 점입니다.
Kotest
는 Kotlin DSL
을 이용해 가독성 높은 테스트 코드
를 작성할 수 있습니다.
Kotlin
의 중위 함수와 확장 함수를 토대로 강력한 기능들을 지원하고 있기 때문에 선택했습니다.
Given("유저가 상품을 주문 상품을 담고, 결제가 완료된 상태에서") {
val orderCommand = fixture<CreateOrderCommand>()
every { createOrderPort.createOrder(orderCommand) } returns 1L
When("배달 주문을 요청하면") {
val actualData = createOrderUseCase.createOrder(orderCommand)
Then("정상적으로 주문이 생성(접수)되어야 한다") {
actualData shouldBe 1L
verify(exactly = 1) { createOrderPort.createOrder(orderCommand) }
}
}
}
Given - When - Then
을 주석으로 사용하는 것이 아니라, BehaviorSpec
클래스를 상속받으면 이렇게 테스트 코드를 작성할 수 있기 때문에 훨씬 직관적
이고 좋습니다.
JUnit
을 사용하며 호출하던 assert
로 시작하는 함수들을 should~
로 시작하는 중위 함수로 더 코틀린스러운 간결한 테스트
를 작성할 수 있도록 도와주기도 하죠.
도메인 테스트
도 같은 방식으로 리팩토링
을 진행했더니, 훨씬 직관적
이고 좋아졌어요
internal class OrderDomainTest : BehaviorSpec({
val fixture = kotlinFixture()
Given("배달 주문 접수 취소가 가능한 상황에서") {
val order = fixture<Order> {
property<Order, OrderStatus>("orderStatus") { RECEIPT }
}
When("배달 주문 접수 취소 시도를 하면") {
order.requestCancel()
Then("정상적으로 취소 요청이 접수되어야 한다") {
order.orderStatus shouldBe USER_CANCEL_REQUEST
}
}
}
Given("배달 주문 접수 취소가 불가능 한 상황에서") {
val order = fixture<Order> {
property<Order, OrderStatus>("orderStatus") { COOKING }
}
When("배달 주문 접수 취소 시도를 하면") {
val exception = shouldThrow<CustomException> {
order.requestCancel()
}
Then("취소가 되지 않아야 한다") {
exception.errorCode shouldBe ErrorCode.CANNOT_CANCEL_ORDER
}
}
}
})
DB
와 직접 연결되는 PersistenceAdapter
계층도 Mocking
해서 테스트를 작성하는 분도 많지만, 저는 DB 만큼은 정말 데이터가 잘 들어가는지 충분한 검증
이 필요
하다고 생각해서 Mocking
하지 않았어요.
@SpringBootTest
를 활용해 모든 Bean
을 로딩하면 너무 무거운 테스트
가 될 수 있기 때문에, @DataJpaTest
로 JPA와 관련된 클래스나 설정만 로딩해서 가볍게 테스트
를 작성
했습니다.
PersistenceAdapter
클래스는 JPA
와 관련되어 있지 않고, 개발자가 직접 정의한 클래스이기 때문에 @Import
를 사용해서 로딩해주었습니다.
@DataJpaTest
@Import(CreateOrderPersistenceAdapter::class)
internal class CreateOrderPersistenceAdapterTest(
private val orderRepository: OrderRepository,
private val orderItemRepository: OrderItemRepository,
private val createOrderPersistenceAdapter: CreateOrderPersistenceAdapter
) : BehaviorSpec({
val fixture = kotlinFixture()
Given("주문 데이터를 생성하려는 상황에서") {
val orderCommand = fixture<CreateOrderCommand>()
When("주문과 주문 상품을 저장하면") {
val savedOrderId = createOrderPersistenceAdapter.createOrder(orderCommand)
Then("저장이 되어야 한다") {
val savedOrderItems = orderItemRepository.findByOrderId(savedOrderId)
val savedOrder = orderRepository.findById(savedOrderId).get()
// 저장된 주문 상품들의 개수와 입력된 주문 상품의 개수를 검증
savedOrderItems.size shouldBe orderCommand.orderItems.size
// 저장된 주문과 입력된 주문의 데이터를 검증
savedOrder.totalPrice shouldBe orderCommand.totalPrice
savedOrder.storeId shouldBe orderCommand.storeId
savedOrder.address shouldBe orderCommand.address
savedOrder.memberId shouldBe 1L
}
}
}
})
UseCase
를 Test
한 코드들과 다른 점은 DB를 직접 사용한다는 점이 다르다고 볼 수 있습니다.
Controller Test Code
는 정말 애증의 관계라고 볼 수 있습니다.
의존성
이 꼬이고 꼬여서 제대로 들어가지 않는 문제 때문에 8시간
가까이 삽질을 하며 해결했습니다.
Spring
에서 제공하는 mockMvc
와 @SpringBootTest
를 이용해서 통합 테스트 코드를 작성했는데요.
모든 Bean
을 생성하지 않도록 ObjectMapper
만 classes에 명시해주었습니다.
첫 시도
-> Mocking한 UseCase
가 동작의 정의되지 않았다는 에러를 만남@SpringBootTest
@AutoConfigureMockMvc
internal class CreateOrderControllerTest(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper,
@MockkBean
private val createOrderUseCase: CreateOrderUseCase
) : DescribeSpec({
val fixture = kotlinFixture()
describe("주문 생성 컨트롤러 테스트") {
it("주문을 생성하면 201 응답과 주문 번호를 응답해야 한다") {
val orderCommand = fixture<CreateOrderCommand>()
every { createOrderUseCase.createOrder(orderCommand) } returns 1L
val result = mockMvc.perform(
post("/api/orders")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(orderCommand)))
.andDo(MockMvcResultHandlers.print())
.andExpect(status().isCreated)
.andReturn()
val responseBody = result.response.contentAsString.toLong()
responseBody shouldBe 1L
verify(exactly = 1) { createOrderUseCase.createOrder(orderCommand) }
}
}
})
분명히 every 함수
를 호출에 동작을 정의해 주었는데도, 초기화
되지 않는 문제가 생겼습니다.
두 번째 시도
-> MockMvc 자동 구성 포기하기@SpringBootTest(classes = [ObjectMapper::class])
internal class CreateOrderControllerTest(
private val objectMapper: ObjectMapper
) : DescribeSpec() {
private val fixture = kotlinFixture()
private lateinit var createOrderUseCase: CreateOrderUseCase
private lateinit var mockMvc: MockMvc
init {
this.beforeTest {
createOrderUseCase = mockk<CreateOrderUseCase> {
every { createOrder(any()) } returns 1L
}
mockMvc = MockMvcBuilders.standaloneSetup(
CreateOrderController(createOrderUseCase)
).build()
}
this.describe("POST /api/orders") {
context("주문 생성을 위해 유효한 데이터가 전달되면") {
val orderCommand = fixture<CreateOrderCommand>()
it("201 응답과 주문 번호를 응답해야 한다") {
val result = mockMvc.perform(
post("/api/orders")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(orderCommand)))
.andDo(print())
.andExpect(status().isCreated)
.andReturn()
val expectedResult = objectMapper.writeValueAsString(BaseResponse(1L))
val responseResult = result.response.contentAsString
expectedResult shouldBe responseResult
verify(exactly = 1) { createOrderUseCase.createOrder(any()) }
}
}
}
}
}
mockMvc
를 자동 구성할때 Controller
나 ControllerAdvice & RestController를 스캔하면서 Bean
을 만들어 두는데, 그 과정에서 Mocking
한 UseCase
가 제대로 주입되지 않는 것이 문제였습니다.
따라서, MockMvcBuilders
를 이용해서 Mocking
한 UseCase
를 직접 Controller에 주입해주고, 그렇게 생성한 Controller를 테스트에 사용하도록 강제했습니다.
this.beforeTest {
// Mock을 생성하면서 동작을 동시에 정의
createOrderUseCase = mockk<CreateOrderUseCase> {
every { createOrder(any()) } returns 1L
}
// Mocking한 UseCase를 직접 Controller에 주입해준다.
mockMvc = MockMvcBuilders.standaloneSetup(
CreateOrderController(createOrderUseCase)
).build()
}
StackOverFlow
와 한국 블로그
등등 많이 찾아다녔었는데도 해결이 되지 않아 찾은 방법입니다.
더 좋은 방법이 있다면, 저도 계속 알아보겠지만 혹시 알려주실 분이 계시다면 미리 감사합니다.. 🙇🏻♂️
테스트 코드
를 제대로 작성할려면 얼마나 많은 고민
을 해야하는지 새삼 깨닫게 된 거 같습니다.
지금 작성한 테스트
는 여러 번의 리팩토링을 거치며 좋아지겠지만, 아직도 테스트
가 어색한 저인 것 같습니다.
Kotest
+ Mockk
기반으로 테스트를 작성하다보니, 정말 가독성이 왜 중요한지 깨닫게 된 거 같습니다.
BehaviorSpec
이외에 DescribeSpec
도 사용했지만 자세히 다루지는 않았습니다.
이 부분에 대해서는 추후 정리
를 할 예정
입니다.
테스트 코드
의 필요성과 어떻게 작성해야 하는지에 대한 요령이 없는 상태에서 작성한 코드라 많이 난해하고 품질이 낮은 것 같습니다.
이번 게시글을 쓰며 느낀 점은 테스트 코드
를 잘 짜기 위해서 공부를 제대로 해보자! 입니다.
이 게시글을 보시는 모든 분에게 테스트 풀 통과
의 기운이 가득하시길 바라면서, 오늘도 읽어주셔서 감사합니다.
사이드 프로젝트에서 테스트 코드 작성을 연습 중인데 덕분에 많은 도움이 되었습니다!👍