본문 바로가기
Computer Engineering

주문서 테스트코드 작성 이슈정리

by ybs 2022. 5. 7.
반응형

주문서 비지니스 로직은 복잡하다. 수많은 외부 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 라는 추상화 작업을 하다보니 초기 진입장벽이 생겼다. 테스트코드를 이해하기 위해서 전체적인 구조를 알아야 하는 노력이 필요하다. 


반응형