Search

[파이썬 아키텍처 패턴] 5장

높은 기어비와 낮은 기어비 TDD

지금까지 했던 단위 테스트는 저수준에서 작동하며, 모델에 직접 작용한다. 이런 테스트를 상위 계층으로 끌어올리면 발생하는 트레이드오프와 더 많은 일반적인 테스트 지침에 대해 알아본다.
지난 서비스 계층 챕터에서 플라스크 앱에 있던 서비스 로직을 따로 분리하는 작업을 했다. 이를 통해 API 엔드포인트 부분이 http request /response 관련 부분만 신경쓸 수 있도록 가벼워졌고, 주요 서비스 관련 기능을 오케스트레이션 하는 서비스 계층과 저장소 패턴을 통한 가짜 저장소를 조합해 높은 수준의 테스트까지 가능해졌다.
여기서 한 단계 더 나아가보자.
서비스 계층은 서비스 로직를 테스트하기 때문에 더는 도메인 모델 테스트가 필요없다. 대신 앞선 1장에서 작성했던 도메인 수준의 테스트를 전부 서비스 계층에 대한 테스트로 재작성한다.
# 도메인 계층 테스트 def test_prefers_current_stock_batches_to_shipments(): in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None) shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow) line = OrderLine("oref", "RETRO-CLOCK", 10) allocate(line, [in_stock_batch, shipment_batch]) assert in_stock_batch.available_quantity == 90 assert shipment_batch.available_quantity == 100 # 서비스 계층 테스트 def test_prefers_warehouse_batches_to_shipments(): in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None) shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow) repo = FakeRepository([in_stock_batch, shipment_batch]) session = FakeSession() line = OrderLine("oref", "RETRO-CLOCK", 10) services.allocate(line, repo, session) assert in_stock_batch.available_quantity == 90 assert shipment_batch.available_quantity == 100
Python
복사
도메인 계층 테스트를 왜 서비스 계층으로 옮겨야 할까요?
도메인 모델에 대한 테스트가 너무 많으면, 코드 베이스를 바꿀 때마다 많은 테스트를 변경해야 하는 문제가 생깁니다.
테스트는 변하지 않아야 하는 시스템의 특성을 강제로 유지하기 위해 사용됩니다.
서비스 계층에 대한 테스트만 수행하도록 하고 도메인 계층 테스트를 최소화하면, 모델 객체의 private 속성이나 메서드와 테스트가 직접 상호작용하는 것을 막을 수 있습니다. 이렇게 하면 도메인 모델 객체를 더 자유롭게 리팩터링 할 수 있습니다.
그러면, 모든 단위 테스트를 다시 작성해야 할까요? 도메인 모델을 직접 사용하는 테스트는 잘못된 것일까요?
이 질문에 대한 답은 결합과 설계 피드백 간의 트레이드오프를 이해해야 합니다.
서비스 테스트를 중간으로 두고, API 테스트와 도메인 테스트가 양 극단에 위치한다고 생각해보세요.
API 테스트 (개인적으로 컨트롤러 테스트)
피드백 적음 (테스트 결과가 전체 비즈니스 로직에 미치는 영향도가 적음)
변경 장벽 낮음
더 넓은 시스템 테스트
도메인 테스트
피드백 많음
변경 장벽 높음
한정된 영역 테스트
API 테스트는 높은 수준의 추상화를 사용하므로 객체의 세부 설계에 대한 피드백을 제공하지 않는다.
반면, 전체 애플리케이션을 다시 작성해도 URL 이나 요청 형식을 바꾸지 않는 한, 앱은 HTTP 테스트를 계속 통과한다. 이는 데이터베이스 스키마 변경 등의 대규모 변경 시, 이 변경이 코드를 망가뜨리지 않는다고 확신할 수 있게 한다.
도메인 테스트는 객체에 대한 이해를 증진시킬 때 도움이 된다. 테스트 자체가 도메인 언어로 되어 있으며 적절한 설계를 가이드해준다. 또한 테스트 자체가 도메인 언어로 작성되므로 모델의 실재하는 문서 역할을 한다. 새로운 팀원이 해당 테스트를 읽고 시스템을 빠르게 이해하고 핵심 개념이 어떻게 연관되어 있는지 파악할 수 있다.
제목에서 이야기하는 기어비는 이러한 테스트 스팩트럼과 관련하여 비유를 들었다고 볼 수 있다. 자전거가 움직이기 시작할 때는 기어를 저단으로 둔다.(낮은 기어비) 이는 즉 초기에는 도메인 모델을 중심으로 하는 저수준 테스트를 통해 큰 뼈대를 잡는 작업이다.
그러다가 자전거의 속도가 붙기 시작해서 가속이 되면 기어를 고단으로 놓는다.(높은 기어비) 상황에 맞게 보다 효율적으로 움직이는 것이다. 이는 일단 시스템이 동작하기 시작하면서, 여러가지 기능들이 추가되는데 이 때 도메인 테스트를 최소화하고 서비스 계층으로 끌어올려 테스트의 효율성을 높이는 작업과 같다.
만약 급경사를 마주하거나 장애물로 인해 속도를 강제로 낮춰야 한다면 다시 기어비를 낮춘다. 이는 비즈니스 로직에 문제가 생겼거나 도메인 모델 설계가 잘못된 경우라고 볼 수 있다. 이때는 다시 도메인 모델 중심의 테스트를 수행해야 한다.

서비스 계층 테스트를 도메인으로부터 완전히 분리하기

다시 서비스 계층 테스트로 돌아와서, 위의 예제에서는 아직 도메인 모델에 대한 의존성이 남아 있다. 테스트 내부에서 도메인 모델 객체를 사용하기 때문이다.
도메인으로부터 완전히 분리된 서비스 계층을 만들기 위해서는 원시 타입만 사용하는 API 를 작성해야 한다.
현재 서비스 계층은 OrderLine 이라는 도메인 객체를 받는다.
def allocate(line: OrderLine, repo: AbstractRepository, session) -> str: batches = repo.list() if not is_valid_sku(line.sku, batches): raise InvalidSku(f"Invalid sku {line.sku}") batchref = model.allocate(line, batches) session.commit() return batchref
Python
복사
이 함수를 원시 타입을 파라미터로 하는 형태로 바꿔보자.
def allocate(orderid: str, sku: str, qty: int, repo: AbstractRepository, session) -> str: line = OrderLine(orderid, sku, qty) ...
Python
복사
이처럼 파라미터가 직접 도메인 모델 객체에 의존하는 것이 아니라 원시 타입으로 받아서 함수 내부에서 도메인 객체를 생성한다. 이 함수를 사용하여 테스트를 재작성한다.
def test_returns_allocation(): batch = model.Batch("batch1", "COMPLICATED-LAMP", 100, eta=None) repo = FakeRepository([batch]) result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) assert result == "batch1"
Python
복사
OrderLine 에 대한 의존성은 없앴으나, 여전히 Batch 라는 도메인 모델 객체를 사용하고 있다. 이 또한 변경이 필요하다.

모든 도메인 의존성을 픽스처 함수에 넣기

Batch 를 테스트에서 아예 사용하지 않을 수는 없지만 도우미 함수나 픽스처로 도메인 모델을 보내 추상화할 수는 있다.
다음은 FakeRepository 에 팩토리 함수를 추가하여 추상화를 구현하였다.
class FakeRepository(set): @staticmethod def for_batch(ref, sku, qty, eta=None): return FakeRepository([ model.Batch(ref, sku, qty, eta), ]) ... 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"
Python
복사
이런 식으로 도메인 의존성을 한 군데로 모을 수 있다.
더 나아가 재고를 추가하는 서비스가 있다고 해보자. 이 서비스를 사용해 온전히 서비스 계층의 유스 케이스만 사용하는 서비스 계층 테스트를 작성할 수 있다. 또한 기존의 FakeRepository 에 있던 남은 도메인 의존성도 제거할 수 있다.
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()
Python
복사
services.py 에 배치를 추가하는 함수를 만들고, 이를 활용해 기존 테스트를 수정할 수 있다.
def test_add_batch(): repo, session = FakeRepository([]), FakeSession() services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session) assert repo.get("b1") is not None assert session.committed # for batch가 사라짐 def test_allocate_returns_allocation(): repo, session = FakeRepository([]), FakeSession() services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session) result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) assert result == "batch1" def test_allocate_errors_for_invalid_sku(): repo, session = FakeRepository([]), FakeSession() services.add_batch("b1", "AREALSKU", 100, None, repo, session) with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession())
Python
복사
이렇게 add_batch 함수를 통해서 기존 Batch 도메인 객체를 제거할 수 있다. 서비스 계층 테스트는 오직 서비스 계층에만 의존하게 된다. 따라서 얼마든지 모델을 리팩터링할 수 있게 되었다.
마지막으로 add_batch 에 대한 엔드포인트를 추가하여 그 전에 테스트 픽스처로 사용한 add_stock 이라는 픽스처를 없앨 수 있다.
서비스 계층 테스트는 오직 서비스 계층에만 의존한다. 따라서 얼마든지 필요에 따라 모델을 리팩터링할 수 있다.

E2E 테스트에 도달할 때까지 계속 개선하기

기존 e2e test
@pytest.mark.usefixtures("restart_api") def test_happy_path_returns_201_and_allocated_batch(add_stock): sku, othersku = random_sku(), random_sku("other") earlybatch = random_batchref(1) laterbatch = random_batchref(2) otherbatch = random_batchref(3) add_stock( [ (laterbatch, sku, 100, "2011-01-02"), (earlybatch, sku, 100, "2011-01-01"), (otherbatch, othersku, 100, None), ] ) data = {"orderid": random_orderid(), "sku": sku, "qty": 3} url = config.get_api_url() r = requests.post(f"{url}/allocate", json=data) assert r.status_code == 201 assert r.json()["batchref"] == earlybatch
Python
복사
add_stock 이라는 픽스처를 사용하였는데 아래와 같이 구현되어 있다.
@pytest.fixture def add_stock(postgres_session): batches_added = set() skus_added = set() def _add_stock(lines): for ref, sku, qty, eta in lines: postgres_session.execute( "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" " VALUES (:ref, :sku, :qty, :eta)", dict(ref=ref, sku=sku, qty=qty, eta=eta), ) [[batch_id]] = postgres_session.execute( "SELECT id FROM batches WHERE reference=:ref AND sku=:sku", dict(ref=ref, sku=sku), ) batches_added.add(batch_id) skus_added.add(sku) postgres_session.commit() yield _add_stock for batch_id in batches_added: postgres_session.execute( "DELETE FROM allocations WHERE batch_id=:batch_id", dict(batch_id=batch_id), ) postgres_session.execute( "DELETE FROM batches WHERE id=:batch_id", dict(batch_id=batch_id), ) for sku in skus_added: postgres_session.execute( "DELETE FROM order_lines WHERE sku=:sku", dict(sku=sku), ) postgres_session.commit()
Python
복사
상당히 복잡하고 SQL 하드코딩이 들어가 있다.
이 픽스처를 사용하지 않고 추가된 add_batch 라는 서비스 계층의 함수를 활용한 테스트가 어떻게 구현되는지보자.
우선 endpoint 를 추가한다.
@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
Python
복사
앞서 구현한 add_batch 함수를 사용한 endpoint 를 추가하였다.
이제 SQL 문이 하드코딩되어 DB 를 사용하던 테스트를 수정해보자.
def post_to_add_batch(ref, sku, qty, eta): url = config.get_api_url() r = requests.post( f"{url}/add_batch", json={"ref": ref, "sku": sku, "qty": qty, "eta": eta} ) assert r.status_code == 201 @pytest.mark.usefixtures("postgres_db") @pytest.mark.usefixtures("restart_api") def test_happy_path_returns_201_and_allocated_batch(): sku, othersku = random_sku(), random_sku("other") earlybatch = random_batchref(1) laterbatch = random_batchref(2) otherbatch = random_batchref(3) post_to_add_batch(laterbatch, sku, 100, "2011-01-02") post_to_add_batch(earlybatch, sku, 100, "2011-01-01") post_to_add_batch(otherbatch, othersku, 100, None) data = {"orderid": random_orderid(), "sku": sku, "qty": 3} url = config.get_api_url() r = requests.post(f"{url}/allocate", json=data) assert r.status_code == 201 assert r.json()["batchref"] == earlybatch
Python
복사
지금까지 도메인 테스트를 서비스 계층으로 옮기고 의존성을 줄이는 서비스 계층 위주의 단위 테스트를 작성했다.
이를 통해 단위 테스트 위주의 건전한 테스트 피라미드를 만들 수 있다.
마지막으로 여러 유형의 테스트를 작성하는 규칙을 정리해본다.
특성당 e2e 테스트를 하나씩 만든다는 목표를 세워야 한다.
쉽게 말해 각 기능당 하나씩 만든다고 보면 된다.
테스트 대부분은 서비스 계층을 사용해 만드는 걸 권한다.
서비스 계층 테스트를 통해 모든 케이스를 다루고, 비즈니스 로직의 입/출력을 테스트해볼 수 있다.
도메인 모델을 사용하는 핵심 테스트를 적게 작성하고 유지하는 것을 권한다.
이러한 테스트는 커버리지가 좁고, 테스트가 쉽게 깨질 수 있다. 서비스 계층 기반 테스트로 대신 할 수 있다면 되도록 그렇게 하느 것이 좋다.
오류 처리도 특성으로 취급하자
이상적인 경우 모든 오류가 endpoint로 거슬러 올라와서 처리되는 구조로 되어 있다.
비정상 경로에 대한 단위 테스트는 많이 만들되, e2e 테스트는 모든 비정상 경로를 테스트하는 하나의 테스트만 있으면 된다.
이 과정에서 유념하면 좋을 점은 아래와 같다.
서비스 계층을 도메인 객체가 아니라 원시 타입을 바탕으로 구현한다.
테스트 대상이 되는 서비스를 저장소나 데이터베이스를 통해 접근할 필요없이 오직 서비스 계층 기반으로 테스트한다.
앞서 살펴본 예제에서 다뤘던 내용이다.
결론적으로 도메인 계층에 대한 테스트는 핵심을 제외하고는 최소화하고, 이를 서비스 계층으로 끌어올려 의존성을 낮추고 리팩터링으로부터 자유로운 구조로 만들어야 한다.

질문

세션 객체는 서비스에 있어야 하나요? 원래 세션을 레포지토리에 격리시키는 것이 레포지토리의 목적 아닌가…?