파이썬 클린코드 | 8장 단위 테스트와 리팩토링

날짜
Feb 22, 2024
태그
python
설명
보다 우수하고 유지보수성이 뛰어난 소프트웨어를 만드는 방법
 

테스트를 위한 도구

단위 테스트를 작성하고 실행하기 위한 두 가지 프레임 워크를 설명한다.
  • unittest
  • pytest
 

Merge Request

코드 리뷰를 도와주는 간단한 버전 제어 도구로 몇 가지 전제를 가진다.
  • 한 명 이상의 사용자가 변경 내용에 동의하지 않으면 거절된다.
  • 아무도 반대하지 않은 상태에서 두 명 이상의 개발자가 동의하면 승인된다.
  • 이외의 상태는 보류한다.
 
from enum import enum class MergeRequestStatus(Enum): APPROVED = "approved" REJECTED = "rejected" PENDING = "pending" OPEN = "open" CLOSED = "closed" class MergeRequest: def __init__(self): self._context = { "upvotes": set(), "downvotes": set(), } self._status = MergeRequestStatus.OPEN @property def status(self): if self._context["downvotes"] return MergeRequestStatus.REJECTED elif len(self._context["upvotes"]) >= 2: return MergeRequestStatus.APPROVED return MergeRequestStatus.PENDING def upvote(self, by_user): self._context["downvotes"].discard(by_user) self._context["upvotes"].add(by_user) def downvote(self, by_user): self._context["upvotes"].discard(by_user) self._context["downvotes"].add(by_user) def close(self): self._status = MergeRequestStatus.CLOSED def _cannot_vote_if_closed(self): if self._stutus == MergeRequestStatus.CLOSED: raise MergeRequestException("종료된 Merge Request에 투표할 수 없습니다")
코드 리뷰를 도와주는 간단한 버전 제어 도구 MergeRequest
이 코드를 두 가지 단위 테스트 도구(unittest, pytest)로 살펴보자.
 

unittest

  • 거의 모든 종류의 테스트를 작성할 수 있는 풍부한 API를 제공
  • 표준 라이브러리에 포함되어 편리한 사용
  • 자바의 Junit 기반
  • 객체를 사용하여 작성되며, 클래스의 시나로오별로 테스트를 그룹화
 
class TestMergeRequestStatus(unittest.TestCase): def test_simple_rejected(self): merge_request = MergeRequest() merge_request.downvote("maintainer") self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED) def test_just_created_is_pending(self): self.assertEqual(MergeRequest().status, MergeRequestStatus.PENDING) def test_pending_awaiting_review(self): merge_request = MergeRequest() merge_request.upvote("core-dev") self.assertEqual(merge_request.status, MergeRequestStatus.PENDING) def test_approved(self): merge_request = MergeRequest() merge_request.upvotes("dev1") merge_request.upvotes("dev2") self.assertEqual(merge_request.status, MergeRequestStatus.APPROVED) def test_cannot_upvote_on_closed_merge_request(self): self.merge_request.close() self.assertRaises( MergeRequestException, self.merge_request.upvote, "dev1", ) def test_cannot_downvote_on_closed_merge_request(self): self.merge_request.close() self.assertRaises( MergeRquestException, "종료된 Merge Request에 투표할 수 없음", self.merge_request.downvote, "dev1", )
unittest 라이브러리를 사용한 테스트코드 작성
 
💡
예외가 발생하는지 뿐 아니라 오류 메시지도 확인하자. 발생한 예외가 정확히 우리가 원했던 예외인지 확인하기 위함이다. 우연히 같은 타입의 예외가 발생했으나 실제로는 다른 원인에 의한 경우를 제외하기 위한 것이다.
 
제공 API
이름
내용
assertEqual
실제 값과 예상 값을 비교
assertRaises
특정 예외가 발생했는지 확인
 

테스트 파라미터화

중복된 테스트 코드를 만드는 대신 하나의 테스트에 적절한 옵션을 넘겨서 구분한다. 상태와 관련된 코드를 클래스로 분리하고 새롭게 추상화한다.
 
class AcceptanceThreshold: def __init__(self, merge_request_context: dict) -> None: self._context = merge_request_context def status(self): if self._context["downvotes"]: return MergeRequestStatus.REJECTED elif len(self._context["upvotes"]) >= 2: return MergeRequestStatus.APPROVED return MergeRequestStatus.PENDING class MergeRequest: ... @property def status(self): if self._status == MergeReqeustStatus.CLOSED: return self._status return AcceptanceThreshold(self._context).status()
테스트 파라미터를 통한 리팩터링
 
class TestAccrptanceThreshold(unittest.TestCase): def setUp(self): self.fixture_data = ( ( {"downvotes": set(), "upvotes": set()}, MergeRequestStatus.PENDING ), ( {"downvotes": set(), "upvotes": {"dev1"})}, MergeRequestStatus.PENDING ), ( {"downvotes": "dev1", "upvotes": set()}, MergeRequestStatus.REJECTED ), ( {"downvotes": set(), "upvotes": {"dev1", "dev2"}}, MergeRequestStatus.APPROVED ), ) def test_status_resolution(self): for context, expected in self.fixture_data with self.subTest(context=context) status = AcceptanceThreshold(context).status() self.assertEqual(status, expected)
setUp 함수로 테스트 전반에 걸쳐 사용될 데이터 픽스처를 정의한다.
 
제공 API
이름
내용
setUp
- 테스트 케이스 클래스 내의 각 테스트 메서드를 실행하기 전에 호출 - 테스트 실행 전에 필요한 초기 설정 작업을 수행하는 데 사용
subTest
- 테스트 케이스 내에서 여러 다른 조건이나 매개변수에 대해 하위 테스트(sub-tests)를 실행할 수 있게 해주는 기능 - 각각의 시나리오는 독립적으로 평가
💡
테스트에 파라미터를 사용하는 경우 각 인스턴스에 최대한 많은 컨텍스트 정보를 제공하여 오류 발생 시 디버깅을 쉽게 한다.
 
 

pytest

  • 더 간결하고 유연한 문법을 제공
  • 함수 기반 테스트를 작성 가능
  • fixture 기능을 통한 테스트 데이터와 환경 설정
  • 실패한 테스트에 대해 자세한 정보와 함께 풍부한 에러 리포팅을 제공
  • unittest 로 작성된 코드도 실행할 수 있기 때문에 점진적인 교체도 가능
 

pytest 기초적 사용

def test_simple_rejected(): merge_request = MergeRequest() merge_request.downvote("maintainer") assert merge_request.status == MergeRequestStatus.REJECTED def test_invaild_types(): merge_request = MergeRequest() pytest.raises(TypeError, merge_request.upvote, {"invalid-object"}) def test_cannot_vote_on_closed_merge_request(): merge_request = MergeRequest() merge_request.close() pytest.raises(MergeRquestException, merge_request.upvote, "dev1") with pytest.raises( MergeRequestException, match="종료된 Merge Request에 투표할 수 없음", ): merge_request.downvote("dev1")
좀 더 간단한 표현식을 제공한다.
 
@pytest.mark.parametrize("context, expected_status",( ( {"downvotes": set(), "upvotes": set()}, MergeRequestStatus.PENDING, ), ( {"downvotes": set(), "upvotes": {"dev1"}}, MergeRequestStatus.PENDING, ), ( {"downvotes": {"dev1"}, "upvotes": set()}, MergeRequestStatus.REJECTED ), ( {"downvotes": set(), "upvotes": {"dev1", "dev2"}}, MergeRequestStatus.APPROVED ), ),) def test_acceptance_threshold_status_resolution(context, expected_status): assert AcceptanceThreshold(context).status() == expected_status
더 나아진 테스트 파라미터화
unittest에서 쓰여진 테스트 파라미터화와 비교해, 내부 for 루프와 중첩된 컨텍스트 관리자가 제거됐다(코드가 간결해졌다). 각 테스트의 데이터가 올바르게 분리됐고, 확장과 유지보수에 유리한 구조를 만들었다.
 
💡
@pytest.mark.parameterize를 사용하여 반복을 없애고, 테스트 본문을 응집력 있게 유지한다. 테스트에 전달한 입력 값과 시나리오는 명시적으로 파라미터를 만들어 제공한다.
 

 

댓글

guest