-
이전장 내용
- 4장에서는 서비스 계층을 추가해서 유스케이스와 워크플로를 정의했음
- 플라스크 앱의 책임을 서비스 계층으로 분리하므로 플라스크 앱에서는 세부 구현사항이 드러나지 않고 웹기능만 하게 만들었고, 엔드투엔드 테스트도 정상/비정상 경로만 테스트 해도 되었음
- 세부 로직에 대한 테스트도 서비스 계층에서 단위 테스트로 실행할 수 있었음
하지만 현재 단위 테스트는 저수준에서 작동하며 모델에 직접 작용함. 5장에서는 테스트를 상위 계층으로 끌어올리면 발생하는 트레이드오프와 더 많은 일반적인 테스트 지침에 대해 논의
테스트 피라미드는 어떻게 생겼는가?
* 테스트 피라미드 - E2E 테스트 > 통합 테스트 > 단위 테스트
grep -c test_ test_*.py tests/unit/test_allocate.py :4 tests/unit/test_batches.py:8 tests/unit/test_services.py:3 tests/integration/test_orm.py :6 tests/integration/test_repository.py:2 tests/e2e/test_api.py:2
단위 테스트 15개, 통합 테스트 8개, 엔드투엔드 테스트 2개
도메인 계층 테스트를 서비스 계층으로 옮겨야 하는가?
서비스 계층에서 통합 테스트를 하기 때문에 더는 도메인 모델 테스트가 필요 없음
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
도메인 모델에 대한 테스트가 너무 많으면 코드베이스를 바꿀때마다 수십~수백개의 테스트를 변경해야 하는 문제가 생김
작업을 진행하는 과정에 변하면 안되는 시스템의 특성을 강제로 유지하는 데에 테스트의 목적이 있음
API에 대해 테스트를 작성하고 직접 모델 객체의 속성이나 메서드가 테스트와 상호작용하지 못하게 하면 좀 더 자유롭게 모델 객체를 리팩토링 할 수 있음어떤 종류의 테스트를 작성할지 결정하는 방법
그렇다면 도메인 모델을 직접 사용하는 테스트는 잘못된건가? -> 결합과 설계 피드백 사이의 트레이드 오프 존재
테스트 스펙트럼 API 테스트
- HTTP API 테스트는 추상화 레벨이 높기 때문에 세부 설계에 대한 테스트를 할 순 없음
- but, URL이나 요청 형식을 바꾸지 않는 한 HTTP 테스트를 계속 통과하기 때문에 내부 코드 변경시 애플리케이션 동작여부를 테스트 할 수 있음
도메인 테스트
- 도메인 테스트는 적절한 설계를 만드는데 도움을 주고, 테스트가 도메인 언어로 작성되었으므로 테스트를 보고 시스템이 어떻게 동작하는지 이해 할 수 있음
- 하지만 그만큼 구체적인 테스트이기 때문에 코드 구조 변경시 테스트가 깨지게 된다
높은 기어비와 낮은 기어비
- 새로운 기능 추가나 버그 수정시에는 도메인 모델을 크게 변경할 필요가 없음
도메인 모델을 변경해야 하는 경우 더 낮은 결합과 더 높은 커버리지를 제공하는 서비스에 대한 테스트를 작성하는게 좋음 - 새로운 프로젝트나 아주 어려운 특정 문제를 다룬다면 도메인 테스트를 재작성해서 더 나은 피드백과 실행가능한 문서를 얻을 수 있음
- 책에서의 기어비 비유 - 여행을 시작할때는 자전거 기어를 낮은데 둬야 관성을 이길 수 있고, 자전거가 빠르게 움직일 때는 기어를 더 높이 줘야 효율적으로 움직일 수 있다. 하지만 급경사나 위험한 장애물이 있어서 속도를 강제로 낮춰야 한다면 기어비를 다시 낮춰야한다 -> 새로운 기능 추가나 버그 수정시는 API, 서비스 테스트를 통해 높은 기어비로 빠르게 갈 수 있고, 새로운 프로젝트나 어려운 문제직면시 도메인 테스트를 작성해 다시 기어비를 낮춰 갈 수 있음을 비유적으로 표현
서비스 계층 테스트를 도메인으로부터 완전히 분리하기
서비스 테스트에 여전히 남아있는 도메인 모델에 대한 의존성을 분리
이전 : allocate 함수는 도메인 객체를 받음
def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:
이후 : allocate는 문자열과 정수를 받는다
def allocate( orderid: str, sku: str, qty: int, repo: AbstractRepository, session ) -> str:
테스트 재작성
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"
테스트는 직접 Batch 객체를 인스턴스화해서 사용하고 있기 때문에 여전히 도메인 의존적. 나중에 Batch 모델의 동작 변경시 수만은 테스트를 변경해야함
마이그레이션: 모든 도메인 의존성을 픽스처 함수에 넣기
도우미 함수나 픽스처로 도메인 모델을 내보내는 추상화해서 테스트에 있는 도메인 의존성을 한군데로 모을 수 있다
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"
누락된 서비스 추가
한단계 더 나아가서 재고를 추가하는 서비스를 만들어 이 서비스에 대한 테스트를 작성 할 수 있음
> 일반적으로 서비스 테스트에서 도메인 계층에 있는 요소가 필요하다면 이는 서비스 계층이 완전하지 않다는 사실을 보여주는 지표 일 수 있다
add_batch에 사용할 새로운 서비스
def add_batch( ref: str, sku: str, qty: int, eta: Optional[data], repo: AbstractRepository, session ): repo.add(model.Batch(ref, sku, qty, eta)) session.commit() def allocate( orderid: str, sku: str, qty: int, repo: AbstractRepository, session ) -> str: ...
새로운 add_batch 서비스에 대한 테스트
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 def test_allocate_returns_allocation(): repo, session = FakeRepository([]), FakeSession() services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session) result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session) 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())
서비스 테스트는 이제 서비스만 사용하게 되었음
E2E 테스트에 도달할 때까지 계속 개선하기
배치를 추가하는 API 엔드포인트 추가하면 add_stock이라는 픽스처도 없앨 수 있음
E2E 테스트는 데이터베이스에 직접 의존하지 않게 됨@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
기존 API 테스트
@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
API테스트는 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
정리: 여러 유형의 테스트를 작성하는 간단한 규칙
- 특성당 엔드투엔드 테스트를 하나씩 만든다는 목표 세우기
- 테스트 대부분 서비스 계층을 사용해 만들기
- 도메인 모델을 사용하는 핵심 테스트를 적게 작성하고 유지하기
- 오류처리도 특성으로 취급하기
'책 & 스터디' 카테고리의 다른 글
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 7 애그리게이트와 일관성 경계 (1) 2024.05.13 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 6 작업단위 패턴 (0) 2024.03.31 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 4 첫번째 유스 케이스: 플라스크 API와 서비스 계층 (1) 2024.03.24 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 3 막간: 결합과 추상화 (1) 2024.03.23 데이터 메시를 읽고 (1) 2024.02.25 댓글