[ python ] 08. 단위 테스트와 리팩토링_(2)

박찬영·2024년 5월 16일

파이썬 클린 코드

목록 보기
19/19
post-thumbnail

08. 단위 테스트와 리팩토링

pytest

pytest는 훌륭한 테스트 프레임워크로 단순히 assert구문을 사용해 조건을 검사하는 것이 가능하기 때문에 보다 자유롭게 코드를 작성할 수 있다.

기본적으로 pytest에서는 assert 비교만으로 단위 테스트를 식별하고 결과를 보고하는 것이 가능하다. 또한 pytest 명령어를 통해 탐색 가능한 모든 테스트를 한번에 실행하는 것이 가능하다.

기초적인 pytest 사용 예

먼저 간단한 assertion을 사용한 예를 보자

def test_simple_rejected():
  merge_request = MergeRequest()
  merge_request.downvote("maintainer")
  assert merge_request.status == MergeRequestStatus.REJECTED

def test_just_created_is_pending():
  assert MergeRequest().status == MergeRequestStatus.PENDING

def test_pending_awaiting_review():
  merge_request = MergeRequest()
  merge_request.upvote("core-dev")
  assert merge_request.status == MergeRequestStatus.PENDING

간단히 결과가 참인지를 비교하는 것은 assert 구문만 사용하면 되지만, 예외의 발생 유무 검사와 같은 검사는 일부 함수를 사용해야 한다.

def test_invalid_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(MergeRequestException, merge_request.upvote, "dev1")
  with pytest.raises(
      MergeRequestException,
      match="Cannot upvote a closed merge request",
  ):
    merge_request.upvote("dev1")

이 경우 pytest.raises 는 특정 예외가 발생했는지를 확인할 수 있다. 예외적인 상황이 발생하면 잘못된 가정 아래 실행을 계속하는 것보다는 예외를 발생시키고 호출자에게 바로 알려주는 것이 좋다 이것이 pytest.raises가 확인하려는 것이다. 즉 예외를 발생시키고 예외가 잘 발생했는지를 확인하려는 것이다.

match 키워드를 활용해서 발생된 예외의 메시지가 제공된 정규식과 일치하는지 확인할 수 있다. 예외가 발생했지만 정규 표현식과 일치하지 않는 다른 메시지가 있는 경우에도 테스트는 실패한다.

예외가 발생하는지 뿐만 아니라 오류 메시지도 함께 확인하자. 발생한 예외가 정확히 우리가 원했던 예외인지 확인하기 위함이다. 우연히 같은 타입의 예외가 발생했으나 실제로는 다른 원인에 의한 경우를 제외하기 위한 것이다.

pytest는 .value 같은 속성을 통해 추가 검사를 할 수 있도록 원래의 예외를 래핑하지만, 지금 사용한 함수를 사용해도 대부분의 경우에 대해서 확인할 수 있다.

테스트 파라미터화

pytest로 파라미터화된 테스트를 하는 것은 단순히 더 깔끔한 API를 제공해서가 아니라 테스트 조합마다 새로운 테스트 케이스를 생성하기 때문에 더 발전된 방법이라 할 수 있다.

이렇게 하려면 pytest.mark.parameterize 데코레이터를 사용해야 한다. 데코레이터의 첫 번째 파라미터는 테스트 함수에 전달할 파라미터의 이름을 나타내는 문자열이고, 두 번째 파라미터는 해당 파라미터에 대한 각각의 값으로 반복 가능해야 한다.

테스트 함수의 본문에서 내부 for 루프와 중첩된 컨텍스트 관리자가 제거되고 한 줄로 변경된 것에 주목하자. 각 테스트 케이스의 데이터는 함수 본문에서 올바르게 분리되어 이제 확장과 유지보수에 유리한 구조가 된다.

@pytest.mark.parameterize("context,expected_status",(
    (
        {"downvotes": set(), "upvotes": set()},
        MergeRequestStatus.PENDING,
    ),
    (
        {"downvotes": {"core-dev"}, "upvotes": set()},
        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

@pytest.mark.parameterize 를 사용하여 반복을 없애고 테스트 본문을 응집력 있게 유지한다. 테스트에 전달할 입력 값과 시나리오는 명시적으로 파라미터를 만들어 제공한다.

파라미터화를 할 때 중요한 권장 사항은 각각의 파라미터가 하나의 테스트에만 할당되어야 한다는 것이다. 즉 다른 테스트 조건을 동일한 파라미터에 혼합해서는 안 된다. 다른 파라미터 조합을 테스트하려면 각각의 파라미터를 누적해서 사용하자. 데코레이터를 누적시키면 발생 가능한 모든 조합에 대해서 테스트 조건이 생성된다.

@pytest.mark.parametrize("x",(1,2))
@pytest.mark.parametrize("y",("a","b"))

def test_add(x,y):
  pass

#(x=1,y=a), (x=1,y=b), (x=2,y=a) (x=2,y=b) 값 전달

Fixture(픽스처)

pytest의 가장 큰 장점 중 하나는 재사용 가능한 기능을 쉽게 만들 수 있다는 점이다. 이렇게 생성한 데이터나 객체를 재사용해 보다 효율적으로 테스트를 할 수 있다. 예를 들어 특정 상태를 가진 MergeRequest 객체를 만들고 여러 테스트에서 이 객체를 재사용할 수 있다.

픽스처를 정의하려면 먼저 함수를 만들고 @pytest.fixture 데코레이터를 적용한다. 이 픽스처를 사용하길 원하는 테스트에는 파라미터로 픽스처의 이름을 전달하면 pytest가 그것을 활용한다.

@pytest.fixture
def rejected_mr():
  merge_request = MergeRequest()
  merge_request.downvote("dev1")
  merge_request.upvote("dev2")
  merge_request.upvote("dev3")
  merge_request.downvote("dev4")

  return merge_request

def test_simple_rejected(rejected_mr):
  assert rejected_mr.status == MergeRequestStatus.REJECTED

def test_rejected_with_approvals(rejected_mr):
  rejected_mr.upvote("dev2")
  rejected_mr.upvote("dev3")
  assert rejected_mr.status == MergeRequestStatus.REJECTED

def test_rejected_to_pending(rejected_mr):
  rejected_mr.upvote("dev1")
  assert rejected_mr.status == MergeRequestStatus.PENDING

def test_rejected_to_approved(rejected_mr):
  rejected_mr.upvote("dev1")
  rejected_mr.upvote("dev2")
  assert rejected_mr.status == MergeRequestStatus.APPROVED

테스트는 메인 코드에도 영향을 미치므로 클린 코드의 원칙이 테스트에도 적용된다는 것을 기억하자.
픽스처는 테스트 스위트 전반에 걸쳐 사용될 여러 객체를 생성하거나 데이터를 노출하는 것 외에도 직접 호출되지 않는 함수를 수정하거나 사용될 객체를 미리 설정하는 등의 사전조건 설정에 사용될 수도 있다.

코드 커버리지

테스트 러너는 테스트의 실행을 조율하고 실행 결과를 사용자에게 보여주는 도구이다. 여기서 전달하고자 하는 바는 상용 코드 중에 테스트 코드로 커버되지 않는 코드가 발견되면 반드시 그 부분을 커버할 수 있는 테스트 코드를 추가해야 한다는 것이다. 테스트가 없는 코드는 손상된 코드로 간주해야 한다는 것을 기억하자. 커버리지를 높이기 위해 다음과 같은 일을 할 것이다.

  • 테스트 시나리오를 완전 놓치고 있다는 것을 깨달을 수 있다.
  • 더 많은 단위 테스트를 만들거나, 기존 단위 테스트가 더 많은 부분을 커버하도록 수정한다.
  • 사용 코드를 단순화하고, 중복을 제거하고, 더 간결하게 만들려고 노력한다. 즉 커버리지를 높이기 쉽도록 변경한다.
  • 또는 기존 단위 테스트가 커버하지 못한 코드가 사실은 도달할 수 없는 코드임을 깨달을 수도 있다. 이런 경우 안전하게 제거할 수 있다.

다만, 커버리지 자체가 목표가 되어서는 안된다는 것을 기억하자 즉 100% 커버리지를 달성하기 위해 노력하는 것은 그다지 생산적이지도 효율적이지도 않은 일이다.

pytest의 경우 pytest-cov 패키지를 사용할 수 있다. 설치 후에 테스트를 실행할 때, pytest 러너에게 pytest-cov가 실행될 것이라는 것과 어떤 패키지를 사용할지 알려줘야 한다.

다음 명령을 사용하여 실행 결과를 확인할 수 있다.

PYTHONPATH=src pytest --cov-report term-missing --cov=coverage_1 tests/test_coverage_1.py

다음의 예시를 살펴보자

def my_function(number: int):
	return "짝수" if number % 2 == 0 else "홀수"

이제 다음과 같이 테스트를 작성했다고 해보자.

@pytest.mark.parameterize("number, expected", [(2, "짝수")])
def test_my_function(number, expected):
	assert my_function(number) == expected

이것에 대해서 테스트를 하면 커버리지가 100%로 나온다. 하지만 실제로는 홀/짝 중에 한 가지만 테스트 했으므로 테스트해야 하는 조건의 50%만 확인했다. 더 큰 문제는 else문은 아예 실행되지 않았기 때문에 어떻게 동작할지 모른다는 점이다. 이처럼 커버리지가 얼마인지 확인하는 것은 좋은 습관이지만, 우리의 목적을 이루기 위한 도구라는 것을 명심해야 한다.

단위 테스트에 대한 추가 논의

속성 기반 테스트 (Property-based test)

속성 기반 테스트는 이전 단위 테스트에서 다루지 않았던 것으로 테스트를 실패하게 만드는 데이터를 찾는 것이다. 이를 위해 hypothesis 라이브러리를 사용할 수 있다. 이 라이브러리는 코드를 실패하게 만드는 데이터를 찾는데 도움을 준다.

이 라이브러리를 통해 성공하지 못하는 반대 사례를 찾을 수 있다. 상용 코드에 대해 단위 테스트를 하여 정확하다는 것을 입증하려 할 것이다. 이제 라이브러리에 유효한 가설을 정의하면 hypothesis 라이브러리가 에러를 유발하는 사례를 찾아줄 것이다.

변형 테스트 (Mutation test)

테스트는 작성한 코드가 정확하다는 것을 입증해줄 공식적인 확인 방법이다. 그런데 테스트가 정확한지 확인하는 방법은 무엇일까? 바로 상용 코드이다. 메인 코드를 테스트 코드의 반대 개념으로 생각할 수 있다.

단위 테스트를 작성하는 이유는 버그로부터 코드를 보호하고 서비스 중에 정말 발생해서는 안 되는 실패에 대해 미리 검증하기 위한 것이다. 검사는 통과하는 것이 좋지만, 테스트를 잘못하 여 통과한 것이라면 더 위험할 수 있다. 즉, 자동화된 회귀 도구로 단위 테스트를 하는 중에 누 군가 버그를 추가했다면 나중에 적어도 하나 이상의 테스트에서 이를 포착하여 테스트에 실패 해야 한다. 만약 테스트에 실패하지 않았다면 테스트에 누락된 부분이 있다거나 올바른 체크를 하지 않았다는 뜻이다.

이것이 변형 테스트를 하는 이유이다. 변형 테스트 도구를 사용하면 원래 코드를 변경한 새로운 버전(돌연변이-mutant라고 함)으로 코드가 수정된다 (예: 연산자를 교체하거나 조건을 변경).

좋은 테스트 스위트는 이 러한 돌연변이를 죽여야(kill) 하는데, 이런 경우 테스트에 의지할 수 있음을 의미한다. 일부 돌연변이가 실험에서 생존하면 대개 나쁜 징후이다. 물론 완전히 정확한 것은 아니므로 무시할 수도 있는 중간 상태가 있다.

파이썬의 변형 테스트 도구는 mutpy가 있다.

마치며..

단위 테스트와 그 도구들을 살펴보았는데 실제로 프로젝트에서 단위 테스트를 사용해보면서 익힐 필요가 있을 것 같다.

profile
안녕하세요 박찬영입니다.

0개의 댓글