Kotest + Mockk 군침 삭 도는 테스트 코드 작성하기

DevSeoRex·2024년 1월 29일
4
post-thumbnail

😟 테스트 코드 없는 나의 프로젝트

부트캠프에서 시작한 최초의 프로젝트에 이어서 약 4개 정도의 프로젝트를 경험한 저는 테스트 코드를 제대로 작성해 본 경험이 없었습니다.

최근에 포트폴리오를 위해 작업한 프로젝트에서 Spring Rest Docs 도입을 위해 작성한 컨트롤러 테스트가 테스트 코드 작성 경험의 전부라고 봐도 무방하죠.

이러던 제가 우아한 남형제들 프로젝트에 참여하게 되면서, 팀원들과 팀 문화를 만들어가며 테스트 코드를 반드시 작성해야 한다는 규칙이 만들어지게 되었습니다.

백엔드 개발자로서 당연히 테스트 코드에 관심이 있고 짜보고 싶고 어떻게 하면 제대로 돌아가고 어떻게 하면 펑펑 터지는지 정말 궁금했습니다.

그런데 가장 큰 문제는, 테스트 코드를 어떻게 시작해야 될 지 모른다는 것입니다.

🤯 어디서부터 시작해야 하지?

Spring Boot 프로젝트를 생성하면 기본적으로 있는 Test 폴더에는 자동으로 생성된 테스트 클래스 하나 뿐이고, 어디서부터 시작해야 할지 정말 막막했습니다.

출근길에 제가 즐겨보는 한 유튜브에서 테스트 코드도 많이 짜봐야 더 좋은 테스트가 가능하다고 말해주는 감사한 분이 계셔서 용기를 가지고 일단 짜보자는 마음으로 시작했습니다.

😇 UseCase Test Code 작성

저는 우아한 남형제들 프로젝트에서 주문 서비스를 담당하고 있습니다.
주문 서비스는 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의 대역객체를 생성하고, 동작을 정의해 주었습니다.

정의된대로 동작한다면 CreateOrderPortcreateOrder 함수는 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) }
    }
}

😀 Domain Test Code 작성

특정 구현 기술에 종속되지 않는 순수한 도메인 규칙구현한 도메인 객체 테스트를 작성해보겠습니다.

  • 주문 취소 테스트

현재 주문 상태조리 중이거나 배달 중이 아니라면 주문 취소 접수가 가능하다고 정의된 도메인 규칙을 검증하기 위한 테스트 코드작성해보겠습니다.

	@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 패턴으로 직관적인 형태를 보이고 있습니다.
그렇다면 이 테스트를 유지해도 괜찮을까요?

🤪 Test Refactoring

위에서 작성한 테스트 코드의 문제점이 있다면 저는 크게 두 가지로 보았습니다.

  • 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를 도입했다는 점입니다.

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

😄 PersistenceAdapter Test Code 작성

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

})

UseCaseTest한 코드들과 다른 점은 DB를 직접 사용한다는 점이 다르다고 볼 수 있습니다.

🥲 Controller Test Code 작성

Controller Test Code는 정말 애증의 관계라고 볼 수 있습니다.
의존성이 꼬이고 꼬여서 제대로 들어가지 않는 문제 때문에 8시간 가까이 삽질을 하며 해결했습니다.

Spring에서 제공하는 mockMvc@SpringBootTest를 이용해서 통합 테스트 코드를 작성했는데요.
모든 Bean을 생성하지 않도록 ObjectMapperclasses에 명시해주었습니다.

  • 첫 시도 -> 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를 자동 구성할때 ControllerControllerAdvice & RestController를 스캔하면서 Bean을 만들어 두는데, 그 과정에서 MockingUseCase가 제대로 주입되지 않는 것이 문제였습니다.

따라서, MockMvcBuilders를 이용해서 MockingUseCase를 직접 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도 사용했지만 자세히 다루지는 않았습니다.

이 부분에 대해서는 추후 정리를 할 예정입니다.
테스트 코드의 필요성과 어떻게 작성해야 하는지에 대한 요령이 없는 상태에서 작성한 코드라 많이 난해하고 품질이 낮은 것 같습니다.

이번 게시글을 쓰며 느낀 점은 테스트 코드를 잘 짜기 위해서 공부를 제대로 해보자! 입니다.

이 게시글을 보시는 모든 분에게 테스트 풀 통과의 기운이 가득하시길 바라면서, 오늘도 읽어주셔서 감사합니다.

🙇

4개의 댓글

comment-user-thumbnail
2024년 1월 29일

사이드 프로젝트에서 테스트 코드 작성을 연습 중인데 덕분에 많은 도움이 되었습니다!👍

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

크..

1개의 답글