Kotlin / Spring Boot로 간단한 example 주문서버 구현해보기- 2) Unit / Integration Tests

Mango
12 min readFeb 17, 2024

--

해당 example 주문서버는 이전 1편에서 설명드린 것처럼 Layered Architecture / Hexagonal 구조를 기반에 두고 설계하였습니다.

그렇기 때문에 도메인 기반 비즈니스 로직(domain 모듈)과 외부 요청 로직(infrastructure 모듈)은 레이어가 서로 다른 모듈로 분리되어 있는데요, 이에 따라 domain 모듈의 정상적인 수행에 대한 검증과 외부 요청인 infrastructure 모듈에 대한 검증은 물리적으로도 각각 수행할 수 밖에 없고 두 레이어별로 검증할 주제가 서로 다르다고 생각하였습니다.

물론 테스트 환경에서만 domain 모듈에서 infrastructure 모듈을 참조하도록 설정한다면 테스트는 물리적으로 분리되지 않을수도 있기는 합니다.

example 프로젝트 Github 링크

Unit Test

domain 모듈 service 객체의 public method 단위로 발생할 수 있는 input 종류에 대한 output / exception 케이스들에 대하여 검증할 수 있도록 유닛테스트를 수행하였습니다.

이 중 테스트 단위에 대해 일반적으로 비슷하게 적용할것이라 생각되지만 좀만 더 설명을 보탠다면,

service 객체의 public method가 도메인 기반 비즈니스 로직을 오케스트레이션하는 단위이자 api 모듈(presentation layer) 에서 호출되는 단위이기 때문입니다.

여기서 말하는 도메인 기반 비즈니스 로직을 오케스트레이션 한다는 말의 의미는

비즈니스 로직 수행 요청을 받아서 → 비즈니스 로직을 수행하고 → 외부 요청(DB Access / external API)이 필요할 경우 호출을 요청하는 일련의 과정을 수행하고 DB Access에 대한 트랜잭션을 관리한다는 뜻입니다.

위에서 설명한 내용대로 실제 어떻게 유닛 테스트를 수행했나 코드로 살펴보겠습니다.

아래 테스트는 junit으로 작성하였는데 테스트를 각 주제별로 묶을 수 없다는 아쉬움이 있었던 차에 kotest 라는 라이브러리가 이를 지원한다는걸 알게되어, 조만간 junit → kotest로 바꿔보려 합니다.

@Suppress("UNCHECKED_CAST")
@SpringBootTest(classes = [DomainTestConfig::class])
@AutoConfigureWebTestClient
@ExtendWith(MockKExtension::class)
class OrderServiceTest(
@Autowired @Qualifier("OrderService") private val service: OrderService,
@Autowired private val reactiveTransactionManager: ReactiveTransactionManager,
@Autowired private val repository: OrderRepository,
) {
private val responseOrder = Order(UUID.randomUUID())

@BeforeEach
fun setup(): Unit =
runBlocking {
MockKAnnotations.init(this@OrderServiceTest)

responseOrder.addOrder(OrderItem(BigDecimal(25000)))

coEvery { reactiveTransactionManager.getReactiveTransaction(any()) } answers { mono { mockk() } }
coEvery { reactiveTransactionManager.commit(any()) } answers { mono { mockk() } }
coEvery { reactiveTransactionManager.rollback(any()) } answers { mono { mockk() } }

coEvery { repository.findById(responseOrder.id) } returns responseOrder
coEvery { repository.save(any()) } just Runs
}

@Test
fun `주문받은 상품에 대한 주문 데이터 생성 요청 후, 상품의 가격에 해당하는 데이터가 생성 되어야 한다`() =
runBlocking {
val price = BigDecimal(30000)
val orderItem = OrderItem(price)
val createdId = service.createOrder(orderItem)
val order = Order()
order.addOrder(orderItem)

coEvery { repository.findById(createdId) } returns order

val sut = repository.findById(createdId)

assertEquals(sut?.price, price)
}

@Test
fun `진행중인 주문에 상품을 추가하면, 추가한 상품 가격이 주문 가격에 반영 되어야 한다`() =
runBlocking {
val orderItem = OrderItem(BigDecimal(23000))
val beforeResponseOrderPrice = responseOrder.price

service.addOrderItem(responseOrder.id, orderItem)

assertEquals(responseOrder.price, beforeResponseOrderPrice + orderItem.price)
}

@Test
fun `이미 완료된 주문에 상품을 추가하면, 올바른 메시지와 함께 BadRequestException이 발생해야 한다`(): Unit =
runBlocking {
responseOrder.status = OrderStatus.COMPLETED

val exception =
assertFailsWith<BadRequestException> {
service.addOrderItem(responseOrder.id, OrderItem(BigDecimal(25000)))
}

assertEquals(exception.message, "The order is in completed state")
}

@Test
fun `진행중인 주문에 상품을 추가하면, 추가한 상품 가격이 주문 아이템에 반영 되어야 한다`() =
runBlocking {
val orderItem = OrderItem(BigDecimal(23000))

service.addOrderItem(responseOrder.id, orderItem)

assertEquals(responseOrder.orderItems.last().price, orderItem.price)
}

.......
}

Integration Test

Repository interface의 구현체 Class(infrastructure 모듈) 단위로 DB 쿼리 요청을 예상대로 잘 수행하는지 검증합니다.

쿼리 요청에 대한 검증이기 때문에 inmemory db로 테스트를 한다면 각 DB(MySQL / PostgreSQL…)별 정확한 쿼리 검증이 되지 않기 때문에 TestContainer를 사용하여 실 환경에서 사용하는 DB 엔진을 띄워 테스트 했습니다.

TestContainer가 Docker 환경을 띄울 수 있게 infrastructure 모듈 내에 src/resource/db/migration 경로에 DB에 세팅 되어야 할 Database,테이블 등을 세팅하기 위한 init.sql 파일과 도커를 띄우기 위한 docker-compose 파일 등을 구성해 두었습니다.

아래는 통합 테스트 수행 예시입니다.

companion object 블럭 내에 코드는 Testcontainer를 세팅하는 로직이고 setup 메서드는 각 테스트 케이스 실행 이전 테스트 데이터를 세팅하는 로직입니다.

@TestPropertySource(properties = ["spring.r2dbc.initialization-mode=never"])
@Testcontainers
@SpringBootTest
class MySQLOrderRepositoryTest(
@Autowired private val repository: MySQLOrderRepository,
) {
private var initOrder = Order()

companion object {
val dockerServiceName = "mysql-test"
val dockerServicePort = 3306
val databaseName = "mango"
val container =
DockerComposeContainer(File("src/test/resources/db/migration/docker-compose.yaml"))
.withExposedService(dockerServiceName, dockerServicePort)
.apply { start() }

@DynamicPropertySource
@JvmStatic
fun registerDynamicProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.r2dbc.url") {
"r2dbc:mysql://${container.getServiceHost(
"mysql-test",
dockerServicePort,
)}:${container.getServicePort(dockerServiceName, dockerServicePort)}/$databaseName"
}
registry.add("spring.r2dbc.username") { "root" }
registry.add("spring.r2dbc.password") { "root" }
}
}

@BeforeEach
fun setup(): Unit =
runBlocking {
initOrder.addOrder(OrderItem(BigDecimal.TWO))
repository.save(initOrder)
}

@Test
fun `기존 저장된 주문 데이터 조회 시, 저장된 객체와 동일한 값이 반환 되어야 한다`() =
runBlocking {
val sut = findOneByOrderId(initOrder.id)

assertEquals(sut.price, initOrder.price)
assertEquals(sut.orderItems.size, initOrder.orderItems.size)

sut.orderItems.forEachIndexed { index, it -> assertEquals(initOrder.orderItems[index].price, it.price) }
}

@Test
fun `초기 주문 데이터를 저장하면 uuid, 가격 정보가 저장되고 해당하는 아이템 객체가 추가 되어야 하고 uuid로 조회 시 저장한 데이터가 반환 되어야 한다`() =
runBlocking {
val order = Order()
order.addOrder(OrderItem(BigDecimal(5000)))

repository.save(order)

val sut = findOneByOrderId(order.id)

assertEquals(sut.price, order.price)
assertEquals(sut.orderItems.size, 1)
assertEquals(sut.orderItems[0].price, order.price)
}

@Test
fun `기존 저장된 데이터를 다시 저장하면 원하는대로 데이터가 수정 되어야 한다`() =
runBlocking {
initOrder.addOrder(OrderItem(BigDecimal.TEN))

repository.save(initOrder)

val sut = findOneByOrderId(initOrder.id)

assertEquals(sut.price, initOrder.price)
assertEquals(sut.orderItems.size, initOrder.orderItems.size)
assertEquals(sut.orderItems.last().price, initOrder.orderItems.last().price)
}

private suspend fun findOneByOrderId(id: UUID) =
repository.findById(id) ?: throw NotFoundException(
"failed to find order entity by uuid($id)",
)
}

Conclusions

해당 아티클에서는 example 주문서버를 구현 하면서 어떻게 로직을 검증했는지에 대해 설명드렸습니다.

테스트에 대한 중요성은 이미 더 많은 경험을 한 유명 개발자들이 일목요연하게 설명한 자료들이 많고 이를 공감하는 개발자가 많을거라 생각합니다.

하지만 유닛 테스트에 대해서는 다양하게 의견이 갈리는것 같습니다. 일반적으로 두가지 의견으로 나뉘는것 같은데요, 비즈니스 로직 전체를 통합 테스트로 수행하는게 더 안정적이며 DB 모킹은 불필요하다는 의견과 이 둘은 목적이 다르므로 분리해서 수행해야 한다는 의견으로 많이 나뉘는데

저는 두가지 관점 사이에서 아직 정확한 정답을 내리지는 못하였으나 아키텍처 구성에 따라서도 다르게 적용된다 생각합니다. 예를들어 example 주문서버의 경우에는 비즈니스 로직을 수행하는 모듈과 DB Access를 담당하는 모듈이 물리적으로 분리 되었기 때문에 이에 따라 자연스레 각각 검증해야 하는 주제가 둘로(비즈니스 로직 / DB 쿼리 로직) 분리 되었습니다.

--

--

Mango
Mango

Written by Mango

Wanna be a good programmer

No responses yet