5. 높은 기어비와 낮은 기어비의 TDD

hyuckhoon.ko·2021년 11월 7일
0

Recap

서비스 계층(=애플리케이션 서비스)이란, 외부 세계에서 오는 요청을 처리해 연산을 오케스트레이션하는 것이다.
즉, 아래와 같은 일을 통해 애플리케이션을 제어한다.

"DB에서 데이터를 얻어 도메인 모델을 업데이트하고,
영속화하는 모든 연산을 오케스트레이션한다."


단점

  • 퓨어 웹 앱인 경우, 컨트롤러/뷰 함수가 이미 있다.
  • 서비스 계층도 추상화를 하나 만든 것에 불과하다.
  • 서비스 계층이 비대해지면, 빈약한 도메인 모델이라는 안티패턴 발생한다.
  • Fat Model을 지향하라(Django)

장점

  • API가 서비스 계층의 클라이언트가 되면서, 도메인 로직을 API 뒤로 감췄다.(리팩터링이 쉽다)
  • 도메인 계층보다 더 높은 수준에서 테스트 작성이 가능하다.


유닛 테스트를 도메인 계층(저단 기어)에서 서비스 계층(고단 기어)으로 작성하기 위한 챕터

1️⃣ 테스트 피라미드

세로축으로 갈수록 실제 유저의 영역과 밀접해진다.
하지만 여러 영역이 맞물려 작동하므로 피드백이 구체적이지 않아 디버깅이 어렵다.
E2E 테스트나 매뉴얼 테스트 시 발생하는 에러가 실제 배포 환경에서 메일로 수신되는 에러 메시지라고 보면 이해가 쉽다.

발생한 에러가 어떤 부분인지 명확하지가 않아, sql이나 shell에서 데이터를 검토해보기도 하고, 브라우저의 문제인가 싶어 FireFox, Brave 브라우저 등으로 테스트해보거나 postman으로 실제 API를 테스트해보는 등 디버깅 시간이 오래 걸린다.



2️⃣ 테스트를 서비스 계층으로 끌어 올려야 하는가?

끌어 올리면, 도메인 모델 테스트가 필요없다.
이는 시사하는 바가 크다. 도메인 모델 테스트가 많아 코드수정은 곧 테스트 실패로 이어졌던 적이 있다.

"서비스 계층에 대한 테스트만 수행하도록 우리 자신을 제한하고, 직접 모델 객체의 '사적인'속성이나 메서드와 테스트가 직접 상호작용하지 못하게 막는다면 좀 더 자유롭게 모델 객체를 리팩터링할 수 있다."

이전 직장에서의 경험
도메인 모델과 밀접한 테스트로 인해 기획/디자인이 변경될 때마다 코드가 변경되고, 그동안 작성했던 테스트들이 실패했었다.
서비스 함수가 있었다면, 서비스함수 코드만 수정하면 됐을텐데, 각 유닛테스트마다 코드를 수정해야 했었다. 아니면 최소한 장고의 모델 메소드를 잘 활용했다면, 중복되는 코드들을 최소화할 수 있었을 것이다.



3️⃣ API 테스트 VS 도메인 테스트

첫 문단 요약: 테스트 코드작성이 어렵다면 코드 설계를 재검토해라.

두 번재 문단 요약: API테스트는 매우 높은 추상화 수준에 있다. 코드 변경에 자유롭다.

세 번째 문단 요약: 도메인 테스트는 도메인과 아주 밀접하다. TDD방식을 적용하면서 코드를 작성하면 바로 작성하려는 테스트코드와 코드가 해결하려는 바가 일치한다. 즉, 도메인 및 객체의 이해를 증진시킨다. 또한, 도메인 테스트를 잘 해석하면 그 자체로 문서이기 때문에 시스템이 어떻게 동작하는지 보인다.



4️⃣ 낮은 기어비와 높은 기어비

"여행을 시작할 때 자전거 기어를 저단에 줘야 관성을 이길 수 있다. 일단 자전거가 빠르게 움직이기 시작하면 더 높은 기어비로 바꿔서 더 효율적으로 더 빨리 움직일 수 있다. 하지만 갑자기 급경사를 마주하거나 위험한 장애물이 있어서 속도를 강제로 낮춰야 한다면 다시 기어비를 낮춰야 한다."



5️⃣ 높은 기어비로 바꾸기: 도메인 테스트에서 서비스 계층 테스트로 마이그레이션하기

1단계: 서비스 계층 함수는 도메인 객체를 인자로 받지 않는다.

도메인 객체를 인자로 받는것 자체가 의존적이라는 의미다.
유저 관련 서비스 함수가 user객체를 인자로 받고 있다면,
user_id를 인자로 받게 리팩터링해라.

2단계: 도메인 의존성을 한 군데로 모으기

fixture: 테스트에 필요한 부분들을 미리 준비해놓은 리소스 코드. DB가 필요해서 어떤 내용들을 테스트 할 때만 DB에 넣어서 확인을 한다던가 특정 파일을 테스팅 할 때 필요하다면 특정 파일들이 그 fixture라고 볼 수 있습니다.
이렇듯, 도메인 의존성이 있는 코드들을 helper_funcs.py이나 fixtures.py에 모은다.

# 오로지 픽스처에 사용하기 위해 fot_batch함수를 정의
# 중요한 건 이러한 도메인 의존적인 부분을 fixtures.py에 모으라는 거다 
class FakeRepository(set):

   @staticmethod
   def for_batch(ref, sku, qty, eta=None):
       return FakeRepository([
           model.Batch(ref, sku, qty, eta),
       ])
       
       
   # test.py    
   def test_returns_allocation():
   repo = FakeRepository.for_batch("batch1", "COMPLICATED-LAMP", 100, eta=None)
   result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession())
   assert result == "batch1"

3단계: 유스케이스에 집중한 리팩터링

동시에 여러 개의 방을 예약해야하는 서비스를 개발해야 한다고하자.
이를 서비스 (계층)함수로 구현하려고 하는데, 테스트를 서비스 계층 함수를 이용하지 않을 이유가 있을까?

"일반적으로 서비스 계층 테스트에서 도메인 계층에 있는 요소가 필요하다면 이는 서비스 계층이 완전하지 않다는 사실을 보여주는 지표일 수 있다."

요약 높은 기어비로 변환을 하는 과정에서는 아래의 사항을 고려하자.

  • 객체를 인자로 넘기지말고, 원시타입을 넘겨라
  • 서비스 함수를 만들고, 테스트도 서비스 함수로 해라.


6️⃣ 점점 더 높은 기어비로 바꾸기: E2E 테스트가 될 때까지 리팩토링

add_batch라는 서비스 함수 덕분에 API를 추가하는게 쉬워졌다.


# services.py
def add_batch(
    ref: str, sku: str, qty: int, eta: Optional[date],
    repo: AbstractRepository, session,
) -> None:
    repo.add(model.Batch(ref, sku, qty, eta))
    session.commit()

# flask_app.py
@app.route("/add_batch", methods=["POST"])
def add_batch():
    session = get_session()
    repo = repository.SqlAlchemyRepository(session)
    eta = request.json["eta"]
    if eta is not None:
        eta = datetime.fromisoformat(eta).date()
    services.add_batch(
        request.json["ref"],
        request.json["sku"],
        request.json["qty"],
        eta,
        repo,
        session,
    )
    return "OK", 201

마치며

  • 테스트 대부분은 서비스 계층을 사용해 만드는 걸 권한다.
  • 도메인 모델 테스트는 최대한 적게 유지하며, 서비스 계층 테스트로 대신할 수 있다면 주저하지 말고 진행한다.

0개의 댓글