주문서 비지니스 로직은 복잡하다. 수많은 외부 API 결과들을 조합해서 만들어야 하기 때문이다. 이 글에선 주문서 테스트코드 작성 시 겪었던 이슈에 대해 정리했다. 주문은 범용적인 도메인이다. 내가 속한 팀의 특수한 부분만 빼고 재구성해서 설명했다.
1. 개발 진행 방식
주문등록을 하고 주문서 페이지가 보여지기까지 flow 를 하나의 테스트코드로 만들었다. 전체를 관통하는 작은 단위의 feature 와 테스트코드를 먼저 만들어, 테스트를 계속 통과시키면서 feature 들을 추가하는 Walking Skeleton 방식으로 개발을 진행했다.
A Walking Skeleton is a tiny implementation of the system that performs a small end-to-end function
여기서 end-to-end function 을 어떻게 해석하는지는 사람마다 다를거라 생각한다. 우리는 주문등록 API 와 주문서 조회 API 를 묶어서 한 테스트코드로 만들었다. 다시말해 하나의 테스트코드에서 두번의 operation 을 수행한다(각각을 검증하는 별도의 테스트도 존재한다). 이런 테스트코드를 만든 이유는 우리가 제대로 feature 들을 추가했는지 지속적으로 검증하면서 개발하고 싶었기 때문이다.
cf) 주문서 객체, db 연동 등 큰 껍데기부터 먼저 만들어놓고 디테일을 채우는 방식은 아키텍처가 구축되었다는 전제가 필요하다.
개발 초반은 테스트코드 만들기 어렵지 않았고 feature 를 하나씩 추가하더라도 부담이 적었다.
@Test
fun test() {
val no = "123456"
// 1. 주문등록 API 호출
val id = registerOrder()
// 2. 주문서 조회 API 호출
Given {
// ...
} When {
get("/orderSheet/{id}/{no}", id, no)
} Then {
body(
// 주문서 조회 결과와 주문등록 요청 data 비교
"products[0].productName", equalTo(request.product[0].name),
// ...
)
}
}
private fun registerOrder(): String =
Given {
body("생략")
} When {
post("/register")
} Then {
status(OK)
} Extract {
body().asString()
}
2.진행하면서 겪은 어려움
추가된 feature 들이 누적되면서 외부 API 호출이 점점 많아졌다. 주문서 페이지 하나를 띄우기 위해선 배송비, 쿠폰할인, 멤버십, 결제 등 다양한 비지니스들의 조합이 필요하다. 자연스럽게 테스트코드도 복잡해질수밖에 없는데 그중에서도 아래 두 가지 이유가 큰 비중을 차지한다.
2-1. 유효성 검사를 통과하기 위해 고정값 setting
우리는 테스트코드를 만들 때 fixture-monkey 라이브러리를 사용한다. 링크를 타고가면 자세한 설명이 있으므로 여기선 간단히 설명하겠다. 먼저 fixture 에 대해서 알아보자. 테스트라는 context에서 fixture 는 테스트를 돌리기 위해 미리 갖춰야 하는 모든것이라 볼 수 있다(단순히 코드만을 뜻하진 않는다).
A test fixture is an environment used to consistently test some item, device, or piece of software. Test fixtures can be found when testing electronics, software and physical devices(wiki).
하지만 이 글에서는 fixutre를 테스트를 위한 객체로 범위를 좁혀 설명하겠다. 아래 코드를 보자. 상품이 과자 카테고리면 가격이 10만원 미만이어야 한다는 정책이 있다.
// 비지니스 로직
if (registerOrderRequest.category == "과자" && registerOrderRequest.price > 100_000) {
// 예외발생
}
의도한대로 예외가 발생하는지 테스트하기 위해선 가격이 10만원 넘는 객체를 만들고 로직을 수행하면된다. 이때 유효성검사 테스트를 위해 만든 RegisterOrderRequest 객체를 fixture 라고 한다.
@Test
fun snackPriceValidTest() {
val registerOrderRequest = RegisterOrderRequest("과자", "포켓몬빵", 3_000_000)
service.register(registerOrderRequest);
// TODO: 정상적으로 예외가 발생하는지 테스트
}
여기서 RegisterOrderRequest 객체 필드들을 랜덤한 값으로 채워주는 라이브러리가 fixture-monkey 다. 테스트코드를 돌릴 때마다 랜덤하게 다른값으로 채워져 수행되므로 다양한 엣지 케이스를 같이 검증할 수 있다. 사용방법은 아래와 같다.
registerOrderRequest = fixture.giveMeOne(RegisterOrderRequest::class.java)
하지만 랜덤한 값으로 RegisterOrderRequest 객체 필드들이 채워지면 아까와 같은 유효성검사 로직을 통과할 수 없다. 그래서 유효성검사 대상이 되는 필드들은 고정값 setting 을 해줌으로써 유효성검사들을 통과시킨다.
val registerOrderRequest = fixture.giveMeBuilder(RegisterOrderRequest::class.java)
.set(Exp<RegisterOrderRequest>() into RegisterOrderRequest::category, "과자")
.set(Exp<RegisterOrderRequest>() into RegisterOrderRequest::price, 90_000)
.sample()
그런데 유효성검사에서 예외가 제대로 발생하는지 테스트 하기 위해선 반대로 10만원이 넘는 값을 setting 시켜야한다. 즉 테스트 목적에 따라 고정값 setting 도 달라진다. 주문서 프로덕션 코드에는 다양한 유효성검사들이 존재하므로 필요에 따라 고정값 setting 을 만들어야 하는데 쉽지 않다.
2-2. 수많은 mock 처리
20개가 넘는 외부 API 호출이 있다보니 테스트 코드를 만들기 위해선 그만큼의 mocking 이 필요하다. 테스트코드마다 mocking 처리를 하면 코드 중복이 많아져서 비효율적이다. 또한 갯수가 많다보니 어떤것들이 필요한지 항상 헷갈려 테스트코드 만들기 쉽지 않다.
3.해결방식
모든 테스트마다 필요에 따라 mocking 처리를 하거나 고정값 setting 을 하면 중복코드가 많아지고 테스트코드 작성하기가 어려워진다. 그래서 각각의 fixture 들이 수행하는 작업들을 역할과 책임에 따라 분리시켰다. 우리는 DomainFixture 라는 네이밍으로 정의하고, 각 DomainFixture 들은 필요에 따라 사용 가능하게 만들었다.
예를 들어 주문등록DomainFixture 인 registerOrderDomainFixture 는 아래와 같이 유효성검사 테스트를 위해 독립적으로 사용할 수 있다.
val registerOrder = fixture.registerOrderDomainFixture()
.apply {
this.registerOrderBuilder
// 유효성검사 테스트를 강제 null setting
.setNull(RegisterOrder::getItem[0] into RegisterOrderItem::getName)
}.registerOrder
thenThrownBy { validate(registerOrder) }
.isExactlyInstanceOf(ValidationException::class.java)
.hasMessageContaining("Item name must not be null")
그리고 아래 그림과 같이 계층관계를 갖고 통합적으로도 사용 가능하다. 가장 큰 단위인 주문서DomainFixture 안에는 주문등록, 쿠폰, 포인트 등 다양한 DomainFixture 들을 갖고있다. 이렇게 각 DomainFixture 에서 필요한 고정값 셋팅을 하고 mocking을 정의해둬서 필요에 따라 조합해 사용할 수 있게 했다.
// 주문서DomainFixture
val orderSheetFixture = fixture.orderSheetDomainFixture()
.apply { mockingEndPoint() }
// fixture.orderSheetDomainFixture() 내부
fun FixtureMonkey.orderSheetDomainFixture() = OrderSheetDomainFixture()
data class OrderSheetDomainFixture() {
val registerOrderDomainFixture by lazy {
fixture.registerOrderFixture()
}
val couponDomainFixture by lazy {
fixture.couponDomainFixture()
}
...
}
4. 결론
테스트코드를 효율적으로 작성하기 위해 DomainFixture 라는 추상화 작업을 하다보니 초기 진입장벽이 생겼다. 테스트코드를 이해하기 위해서 전체적인 구조를 알아야 하는 노력이 필요하다.
'Computer Engineering' 카테고리의 다른 글
DB 장애로 인한 EDA 기반 이벤트 보정처리 (0) | 2023.01.15 |
---|---|
주문 동시성 이슈 정리 (0) | 2022.11.14 |
github action matrix strategy 활용 (0) | 2022.11.13 |
nslookup 과 dig 명령어 결과 차이 (0) | 2022.01.12 |
자주 쓰는 쉘스크립트 모음 (0) | 2021.01.17 |