본문 바로가기

python

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

1. 디자인 원칙과 단위 테스트

 

단위 테스트는 다른 코드의 일부분이 유효한지를 검사하는 코드이다. 단위 테스트는 소프트웨어의 핵심이 되는 필수적인 기능으로서 일반 비즈니스 로직과 동일한 수준으로 다루어져야 한다. 단위 테스트는 비즈니스 로직이 특정 조건을 보장하는지를 확인하기 위해 여러 시나리오를 검증하는 코드이다.

 

단위 테스트는 다음과 같은 특징이 있다.

 

  • 격리 : 단위 테스트는 다른 외부 에이전트와 완전히 독립적이어야 하며 비즈니스 로직에만 집중해야 한다. 때문에 데이터베이스에 연결하지 않아야 하고 HTTP 요청도 하지 않아야 한다. 격리는 또한 테스트 자체가 독립적이라는 것을 의미한다. 테스트는 이전 상태와 관계없이 임의의 순서로 실행될 수 있어야 한다.
  • 성능 : 단위 테스트는 신속하게 실행되어야 한다. 반복적으로 여러 번 실행될 수 있도록 설계해야 한다.
  • 자체 검증 : 단위 테스트의 실행만으로 결과를 결정할 수 있어야 한다. 단위 테스트를 처리하기 위한 추가 단계가 없어야 한다.

 

단위 테스트는 테스트를 작성한 .py 파일을 만들고 이 파일을 도구에서 호출하는 것이다. 이 파일에는 비즈니스 로직에서 필요한 것을 가져오기 위한 import 구문과 비즈니스 로직을 테스트하기 위한 프로그램이 있다. 이제 도구를 사용해 단위 테스트를 하고 결과를 수집하면 된다.

 

1-1. 자동화된 테스트의 다른 형태

 

단위 테스트는 함수 또는 메서드와 같은 매우 작은 단위를 확인하기 위한 것이다. 단위 테스트는 최대한 자세하게 코드를 검사하는 것이 목적이다. 클래스를 테스트하려면 단위 테스트가 아니라 단위 테스트의 집합인 테스트 스위트(test suite) 를 사용한다. 테스트 스위트를 구성하는 테스트들은 메서드처럼 보다 작은 것을 테스트한다.

 

통합 테스트(integration test) 에서는 한 번에 여러 컴포넌트를 테스트한다. 종합적으로 예상대로 잘 작동하는지 집중한다. 이 경우에는 부작용이나 격리를 고려하지 않은 채로, 즉 HTTP 요청을 하거나 데이터베이스에 연결하는 등의 작업을 수행하는 것이 가능하고 때로는 그렇게 하는 것이 바람직하다.

 

인수 테스트(acceptance) 유스 케이스(use case) 를 활용하여 사용자의 관점에서 시스템의 유효성을 검사하는 자동화된 테스트이다.

 

이 두 가지 테스트를 하면 단위 테스트와 관련된 중요한 특성을 잃게 된다. 바로 속도이다. 이러한 테스트는 실행하는데 더 많은 시간이 걸리기 때문에 보다 덜 자주 실행하게 된다.

 

좋은 개발 환경을 구축했다면 개발자는 전체 테스트 스위트를 만들고 코드에 수정이 생길 때마다 반복적으로 단위 테스트와 리팩토링을 할 수 있어야 한다. 코드를 수정하고 pull request 가 생기면 CI(Continuous Integration) 서비스가 실행되어 해당 브랜치에 빌드를 실행한다. 통합 테스트나 인수 테스트가 있는 경우는 빌드 중에 단위 테스트도 함께 수행한다. 물론 merge 전에 빌드가 성공해야 한다. 하지만 중요한 것은 테스트의 차이이다. 일반적으로 단위 테스트는 항상 수행되길 원하지만 통합 테스트나 인수 테스트는 그보다 덜 자주 수행되길 바란다. 이렇게 하는 이유는 전략적으로 단위 테스트에서 작은 기능을 많이 테스트하고, 단위 테스트에서 확인할 수 없는 부분을 다른 자동화된 테스트에서 커버하려고 하기 때문이다.

 

1-2. 단위 테스트와 애자일 소프트웨어 개발

 

최근의 소프트웨어 개발은 가능한 한 신속하고도 지속적으로 가치를 제공하려고 한다. 이미 과거에 성당과 시장(The Cathedral and the Bazaar - CatB)과 같은 에세이에서 언급했던 것들로 이해 관계자에게 빠른 피드백을 받아서 수정하는 것을 반복하자는 내용이다.

 

따라서 변화에 효과적으로 대응할 수 있는 소프트웨어를 개발하고자 한다면 유연하며 확장 가능해야 한다.

 

SOLID 원칙을 준수한 컴포넌트를 만들었다고 하더라도 변경 작업이 아무런 버그를 만들지 않고, 기존 기능이 보존되었다는 것을 보장하기 위해서는 단위 테스트가 그 기준이 될 수 있다.

 

1-3. 단위 테스트와 소프트웨어 디자인

테스트의 용이성(소프트웨어를 얼마나 쉽게 테스트 할 수 있는지를 결정하는 속성)은 클린 코드의 핵심 가치이다.

 

단위 테스트는 기본 코드를 보완하기 위한 것이 아니라 실제 코드의 작성 방식에 직접적인 영향을 미치는 것이다.

 

단위 테스트는 특정 코드에 단위 테스트를 해야겠다고 발견하는 단계에서부터 더 나은 코드를 작성하는 단계 그리고 궁극적으로 모든 코드가 테스트에 의해 작성되는 TDD(test-driven design) 단계까지 여러 단계가 있다.

 

다음 예제는 특정 작업에서 얻은 지표를 외부 시스템에 보내는 프로세스이다. Process 객체는 도메인 문제에 대한 일부 작업을 나타내며, MetricsClient 는 외부 엔티티(syslog, statsd 등)에 실제 지표를 전송하기 위한 객체이다.

 

class MetricsClient:
    """3rd party 지표 전송 클라이언트"""

    def send(self, metric_name, metric_value):
        if not isinstance(metric_name, str):
            raise TypeError("expected type str for metric_name")

        if not isinstance(metric_value, str):
            raise TypeError("expected type str for metric_value")

        logger.info("sending %s = %s", metric_name, metric_value)


class Process:
    def __init__(self):
        self.client = MetricsClient()  # 3rd party 지표 전송 클라이언트

    def process_iterations(self, n_iterations):
        for i in range(n_iterations):
            result = self.run_process()
            self.client.send("iteration.{}".format(i), result)

    def run_process(self):
        return random.randint(1, 100)

 

지표 전송 클라이언트는 파라미터가 문자열 타입이어야 한다는 요구사항이 있다. 따라서 run_process 메서드에서 반환한 result 가 문자열이 아닌 경우 전송에 실패하게 된다.

 

Traceback (most recent call last):
    ...
    raise TypeError("expected type str for metric_value")
TypeError: expected type str for metric_value

 

타사에서 제공하는 (3rd party) 라이브러리는 직접 제어할 수 없으므로 반드시 실행 전에 정확한 타입을 제공해야만 한다. 이러한 버그를 발견했으므로 이제는 단위 테스트를 통해 이러한 문제가 발생하지 않는다는 것을 확실히 하고자 한다.

 

Process 객체의 client 를 모의하여 테스트할 수도 있다. 그러나 그렇게 하려면 더 많은 코드가 필요하다. 그러나 다행히도 메서드가 상대적으로 작다. 메서드가 작다는 것은 테스트 가능성과 관련해 좋은 디자인(작고 응집력이 높은 함수 또는 메서드)이라고 할 수 있다.

 

필요한 부분만 테스트하기 위해 main 메서드에서 client 를 직접 다루지 않고 wrapper 메서드에 위임할 것이다.

 

class WrappedClient:
    """3rd party 라이브러리를 통제 하에 둘 수 있도록 하는 wrapper 객체"""

    def __init__(self):
        self.client = MetricsClient()

    def send(self, metric_name, metric_value):
        return self.client.send(str(metric_name), str(metric_value))


class Process:
    def __init__(self):
        self.client = WrappedClient()

    ... # 나머지는 그대로 유지

 

3rd party 라이브러리를 직접 사용하는 대신 자체적으로 만든 클래스를 지표 전송 client 로 사용했다.

이러한 컴포지션 방식은 어댑터 다지인 패턴과 유사하다. 메인 코드에 대해 직접 단위 테스트를 작성하면 추상화를 하지 못한다.

 

메서드가 분리되었으므로 단위 테스트를 작성한다.

 

from unittest import TestCase, main
from unittest.mock import Mock


class TestWrappedClient(TestCase):
    def test_send_converts_types(self):
        wrapped_client = WrappedClient()
        wrapped_client.client = Mock()
        wrapped_client.send("value", 1)

        wrapped_client.client.send.assert_called_with("value", "1")

 

예제에서는 wrapped_client 로 MetricsClient 를 직접 사용하지 않고, Mock 객체를 사용하였다. wrapped_client.send("value", 1) 를 호출하여 WrappedClient 클래스의 send 메서드를 호출했는데 그 안에서 self.client.send 가 두 개의 문자열을 받는 함수라고 정의하고 있다. 이를 몽키패치라고 하며, 그렇기 때문에 send.assert_called_with("value", "1") 를 호출하면 올바른 파라미터 호출이므로 어설트에 성공하게 된다.

 

1-4. 테스트의 경계 정하기

 

테스트의 범위는 우리가 작성한 코드의 범위로 한정해야 한다. 외부 라이브러리나 모듈과 같은 의존성까지 확인해야 한다면 의존성의 의존성을 확인해야 하고, 이런 식으로는 끝이 없다. 의존성을 테스트하는 것은 우리의 책임이 아니므로 외부 프로젝트에 대해서는 자체적인 테스트가 있다고 가정해도 된다. 외부 의존성에 대해서는 올바른 파라미터를 사용해 호출하면 정상적으로 실행된다는 것만 확인해도 충분하다.

 

좋은 단위 테스트는 시스템의 경계에는 패치를 적용하여 넘어가고 핵심 기능에 초점을 둔다.

 

2. 테스트를 위한 프레임워크와 도구

2-1. 단위 테스트 프레임워크와 라이브러리

 

테스트 시나리오를 다루는 것은 unittest 만으로도 충분할 것이다. 그러나 외부 시스템에 연결하는 등의 의존성이 많은 경우 테스트 케이스를 파라미터화할 수 있는 픽스처(fixture) 라는 패치 객체가 필요하다. 이러한 보다 복잡한 옵션이 필요한 경우는 pytest 가 적합하다.

 

이 둘을 비교할 수 있도록 하는 예제를 만들어 두 가지 옵션으로 테스트해본다.

이 예제는 MR(Merge Request) 에 대해 코드 리뷰를 도와주는 버전 제어 도구로 다음의 전제를 갖는다.

 

  • 한 명 이상의 사용자가 변경 내용에 동의하지 않은 경우 머지 리퀘스트가 거절된다.
  • 아무도 반대하지 않은 상태에서 두 명 이상의 개발자가 동의하면 해당 머지 리퀘스트는 승인된다.
  • 이 외의 상태는 보류 상태이다.

 

코드는 다음과 같다.

 

from enum import Enum


class MergeRequestStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"
    PENDING = "pending"


class MergeRequest:
    """An entity abstracting a merge request."""

    def __init__(self):
        self._context = {"upvotes": set(), "downvotes": set()}

    @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)

 

# unittest

 

unittest 모듈은 자바의 JUnit을 기반으로 한다. JUnit 은 Smalltalk의 아이디어를 기반으로 만들어졌으므로 객체 지향적이다. 이러한 이유로 테스트는 객체를 사용해 작성되며 클래스의 시나리오별로 테스트를 그룹화하는 것이 일반적이다.

 

단위 테스트를 만들려면 unittest.TestCase 를 상속하여 테스트 클래스를 만들고 메서드에 테스트할 조건을 정의하면 된다. 이러한 메서드는 test_ 로 시작해야하며 본문에서는 unittest.TestCase 에서 상속받은 메서드를 사용하여 체크하려는 조건이 참인지 확인하면 된다.

 

class BaseCase:
    """Base test suite."""

    def setUp(self):
        self.merge_request = self.mr_cls()

    def test_simple_rejected(self):
        self.merge_request.downvote("maintainer")
        self.assertEqual(
            self.merge_request.status.value, MergeRequestStatus.REJECTED.value
        )

    def test_just_created_is_pending(self):
        self.assertEqual(
            self.mr_cls().status.value, MergeRequestStatus.PENDING.value
        )

    def test_pending_awaiting_review(self):
        self.merge_request.upvote("core-dev")
        self.assertEqual(
            self.merge_request.status.value, MergeRequestStatus.PENDING.value
        )

    def test_approved(self):
        self.merge_request.upvote("dev1")
        self.merge_request.upvote("dev2")

        self.assertEqual(
            self.merge_request.status.value, MergeRequestStatus.APPROVED.value
        )

    def test_no_double_approve(self):
        self.merge_request.upvote("dev1")
        self.merge_request.upvote("dev1")

        self.assertEqual(
            self.merge_request.status.value, MergeRequestStatus.PENDING.value
        )

    def test_upvote_changes_to_downvote(self):
        self.merge_request.upvote("dev1")
        self.merge_request.upvote("dev2")
        self.merge_request.downvote("dev1")

        self.assertEqual(
            self.merge_request.status.value, MergeRequestStatus.REJECTED.value
        )

    def test_downvote_to_upvote(self):
        self.merge_request.upvote("dev1")
        self.merge_request.downvote("dev2")
        self.merge_request.upvote("dev2")

        self.assertEqual(
            self.merge_request.status.value, MergeRequestStatus.APPROVED.value
        )

    def test_invalid_types(self):
        self.assertRaises(
            TypeError, self.merge_request.upvote, {"invalid-object"}
        )


class TestsUTFrameworks1(BaseCase, TestCase):
    mr_cls = MergeRequest

 

단위 테스트 API 는 비교를 위한 다양한 메서드를 제공하는데, 가장 일반적인 메서드는  실제 값과 예상 값을 비교하는 assertEquals(<actual>, <expected>[, message]) 이다.

 

또 다른 유용한 메서드를 사용하면 특정 예외가 발생했는지 여부를 확인할 수 있다.

 

앞선 예제를 확장하여 사용자가 merge request 를 종료할 수 있도록 수정한다. 병합을 종료하면 더 이상 투표를 할 수 없다.

두 개의 새로운 상태(OPEN, CLOSED) 와 한 개의 새로운 메서드 close() 를 추가한 후, 투표 메서드에 조건을 추가한다.

 

class MergeRequestExtendedStatus(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

    def close(self):
        self._status = MergeRequestStatus.CLOSED

    @property
    def status(self):
        if self._status == MergeRequestStatus.CLOSED:
            return self._status

        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING

    def _cannot_vote_if_closed(self):
        if self._status == MergeRequestStatus.CLOSED:
            raise MergeRequestException("can't vote on a closed merge request")

    def upvote(self, by_user):
        self._cannot_vote_if_closed()

        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)

    def downvote(self, by_user):
        self._cannot_vote_if_closed()

        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user)

 

이제 유효성 검사가 실제로 작동하는지 확인하기 위해 assertRaises 와 assertRaisesRegex 메서드를 사용한다.

 

class ExtendedCases:
    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.assertRaisesRegex(
            MergeRequestException,
            "can't vote on a closed merge request",
            self.merge_request.downvote,
            "dev1",
        )
        

class TestsUTFrameworks2(BaseCase, ExtendedCases, TestCase):
    mr_cls = MergeRequest

 

첫 번째 테스트는 제공한 예외가 실제로 발생하는지를 확인하는 것이다. 두 번째 파라미터로 호출 가능한 객체를 전달하고 나머지 파라미터에 호출에 필요한 파라미터를 (*args, **kwargs) 전달하면 된다.

두 번째 테스트는 동일한 방식으로 처리하지만 발생된 예외의 메시지가 제공된 정규식과 일치하는지 확인한다. 예외가 발생했지만 정규 표현식과 일치하지 않는 다른 메시지가 있는 경우에도 테스트는 실패한다.

 

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

 

테스트 파라미터화

 

이제 데이터에 따라 머지 리퀘스트가 정상적으로 동작하는지를 확인하기 위해 임계값을 변경하며 테스트해본다. status 프로퍼티에서 종료 여부를 확인한 뒤의 코드를 테스트하고자 하는 것이다.

 

이렇게 하는 가장 좋은 방법은 해당 컴포넌트를 다른 클래스로 분리하고 컴포지션을 사용하여 다시 가져오는 것이다. 분리된 클래스에 대해서는 자체 테스트 스위트를 가진 새로운 추상화 객체를 만들고 이것에 대해 테스트를 한다.

 

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 == MergeRequestStatus.CLOSED:
            return self._status

        return AcceptanceThreshold(self._context).status()

 

이렇게 수정하고 다시 테스트를 실행하면 테스트에 통과한다. 방금 수정한 리팩토링은 현재 기능을 전혀 손상시키지 않는다.

 

class TestsUTFrameworks3(BaseCase, ExtendedCases, TestCase):
    mr_cls = MergeRequest

    def setUp(self):
        super().setUp()
        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.value, expected.value)

 

setUp() 메서드에서는 테스트 전반에 걸쳐 사용될 데이터 픽스처를 정의한다. 사실 지금은 직접 메서드에 값을 전달하면 되기 때문에 꼭 필요하지는 않지만 모든 테스트 실행 전에 준비 작업이 필요하다면 이 메서드에 작성하면 된다.

 

subTest 는 호출되는 테스트 조건을 표시하는데 사용된다. 반복 중 하나가 실패하면 unittest 는 subTest 에 전달된 변수의 값을 보고한다.

 

테스트에 파라미터를 사용하는 경우 각 인스턴스에 최대한 많은 컨텍스트 정보를 제공하여 오류 발생 시 디버깅을 쉽게 한다.

 

# pytest

pytest 는 unittest 처럼 테스트 시나리오를 클래스로 만들고 객체 지향 모델을 생성하는 것이 가능하지만 필수 사항이 아니며, 단순히 assert 구문을 사용해 조건을 검사하는 것이 가능하기 때문에 보다 자유롭게 코드를 작성할 수 있다는 점이 차이점이다.

 

pytest 명령어를 통해 탐색 가능한 모든 테스트를 한 번에 실행할 수 있다. 심지어 unitttest 로 작성된 테스트도 실행한다. 이러한 호환성 때문에 unittest 에서 pytest 로 점진적으로 전환하는 것도 가능하다.

 

기초적인 pytest 사용의 예

 

이전 섹션의 테스트는 pytest 를 사용해 재작성할 수 있다.

 

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


def test_approved():
    merge_request = MergeRequest()
    merge_request.upvote("dev1")
    merge_request.upvote("dev2")

    assert merge_request.status == MergeRequestStatus.APPROVED


def test_no_double_approve():
    merge_request = MergeRequest()
    merge_request.upvote("dev1")
    merge_request.upvote("dev1")

    assert merge_request.status == MergeRequestStatus.PENDING


def test_upvote_changes_to_downvote():
    merge_request = MergeRequest()
    merge_request.upvote("dev1")
    merge_request.upvote("dev2")
    merge_request.downvote("dev1")

    assert merge_request.status == MergeRequestStatus.REJECTED


def test_downvote_to_upvote():
    merge_request = MergeRequest()
    merge_request.upvote("dev1")
    merge_request.downvote("dev2")
    merge_request.upvote("dev2")

    assert merge_request.status == MergeRequestStatus.APPROVED

 

결과가 참인지를 비교하는 것은 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="can't vote on a closed merge request"
    ):
        merge_request.downvote("dev1")

 

이 경우 pytest.raises 는 unittest.TestCase.assertRaises 와 동일하며 메서드 형태 또는 컨ㄴ텍스트 관리자 형태로 호출될 수 있다. 예외의 메시지를 검사하고 싶으면 assertRaisesRegex 같은 다른 메서드를 사용하는 대신에 같은 함수를 사용하되 match 파라미터에 확인하려는 표현식을 전달하면 된다.

 

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

 

테스트 파라미터화

 

pytest 로 파라미터화 된 테스트를 하는 것은 unittest 보다 더 나은 방식으로 가능하다. 단순히 더 깔끔한 API 를 제공해서가 아니라 테스트 조합마다 새로운 테스트 케이스를 생성하기 때문이다.

 

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

 

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

 

@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

 

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

 

픽스처(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

 

테스트는 메인 코드에도 영향을 미치므로 클린 코드의 원칙이 테스트에도 적용된다는 것을 기억해야 한다. 이번 예제에는 이전 장에서 살펴본 DRY(Do not Repeat Yourself) 원칙을 적용할 수 있으며 pytest 의 픽스처를 활용하여 해당 원칙을 준수할 수 있었다.

 

픽스처는 테스트 스위트 전반에 걸쳐 사용될 여러 객체를 생성하거나 데이터를 노출하는 것 이외에도, 직접 호출되지 않는 함수를 수정하거나 사용될 객체를 미리 설정하는 등의 사전조건 설정에 사용될 수도 있다.

 

2-2. 코드 커버리지



테스트 러너는 pip 를 통해 플러그인을 제공한다. 이 플러그인은 테스트 도중 코드의 어떤 부분이 실행되었는지, 어떤 부분을 다뤄야 할지, 어떤 부분이 개선되었는지를 알 수 있게 해준다. 가장 널리 사용되는 것은 coverage 라이브러리이다.

가끔 파이썬에서는 잘못 분석하는 경우도 있으므로 커버리지 보고서를 주의해서 살펴봐야 한다.

 

# 코드 커버리리지 도구 설정

 

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

 

pytest-cov 패키지의 여러 기능 중에서 가장 권장되는 것은 테스트되지 않은 행을 알려주는 기능이다. 커버되지 않은 코드를 확인하면 추가로 테스트를 작성할 수 있기 때문이다.

 

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

 

pytest \
    --cov-report term-missing \
    --cov=coverage_1 \
    test_coverage_1.py

 

이렇게 하면 다음과 같은 출력이 나온다.

 

test_coverage_1.py ................                                                                                                                                                                                                                                  [100%]

---------- coverage: platform darwin, python 3.7.7-final-0 -----------
Name            Stmts   Miss  Cover   Missing
---------------------------------------------
coverage_1.py      38      1    97%   44

 

출력 결과에 단위 테스트를 하지 않은 라인이 있다는 것이 표시된다. 이렇게 단위 테스트에서 커버하지 못한 부분(Miss) 과 라인(Missing) 이 표시된다. 이렇게 단위 테스트에서 커버하지 못한 부분을 발견하고 작은 메서드를 만들어서 리팩토링 하는 것이 일반적인 시나리오다.

 

문제는 높은 커버리지를 있는 그대로 신뢰할 수 있는가이다. 높은 커버리지를 가진 코드가 반드시 올바르게 작성된 코드를 뜻하지는 않는다. 오히려 높은 커버리지에도 불구하고 더 많은 테스트를 필요로 할 수도 있다.

 

# 코드 커버리지 사용 시 주의사항

 

파이썬은 인터프리트 방식의 언어이다. 커버리지 도구는 테스트가 실행되는 동안 고수준에서 실행되는 라인을 식별하여 커버리지를 측정한다. 그리고 정보를 취합하여 보고서를 만든다. 라인이 인터프리트 되었다고 해서 적절히 테스트되었다는 것을 의미하지는 않는다. 이것이 커커버리지 보고서를 해석할 때 주의해야 하는 이유이다.

 

모든 코드가 제공된 데이터에 대해 통과했다는 것은 해당 데이터에 대해 문제가 없다는 것이지, 그 이외의 모든 데이터 조합에 대해서도 안전하다는 것을 뜻하지는 않는다.

 

코드의 사각지대를 찾기 위해 커버리지 도구를 사용하지만, 커버리지 자체가 궁극적인 목표는 아니다.

 

2-3. 모의(mock) 객체

 

테스트를 하는 과정 중에는 우리가 작성한 코드만 실행되는 것이 아니다. 어떤 시스템이 실제로 서비스되기 위해서는 외부 서비스(데이터베이스, 스토리지 서비스, 외부 API, 클라우드 서비스 등)와 연결하게 된다. 이런 외부 서비스에는 필연적으로 부작용이 존재한다. 부작용을 최소화하기 위해 외부 요소를 분리하고 인터페이스를 사용해 최대한 추상화하겠지만 이러한 부분 역시 테스트에 포함되어야 하며 효과적으로 처리할 수 있어야 한다.

 

모의 객체는 원하지 않는 부작용으로부터 테스트 코드를 보호하는 가장 좋은 방법 중 하나이다. 코드에서 HTTP 요청을 수행하거나 알림 이메일을 보내야 할 수도 있지만, 단위 테스트에서 확인할 내용은 아니다. 게다가 단위 테스트는 빠르게 실행되어야 하기 때문에 대기 시간을 감당할 수 없다. 따라서 단위 테스트에서는 외부 서비스를 호출하지 않는다. 

 

즉, 데이터베이스를 연결하지 않고 HTTP 요청을 하지 않으며 기본적으로 상용 코드를 시뮬레이션하는 것 외에는 아무것도 수행하지 않는다.

 

단위 테스트에서는 이것들이 호출되는지만 확인하면 된다. 통합 테스트는 거의 실제 사용자의 행동을 모방하여 더 넓은 관점에서 기능을 테스트한다. 때문에 시간은 오래 걸린다. 외부 시스템과 서비스에 실제 연결하기 때문에 실행 시간이 오래 걸리고 비용이 많이 든다. 일반적으로 단위 테스트는 많이 실행하고 항상 실행하며, 통합 테스트는 덜 자주 실행하도록 한다. 예를 들어 새로운 머지 리퀘스트가 있을 경우에만 통합 테스트를 할 수 있다.

 

모의 객체는 유용하지만 남용하여 안티패턴을 만들지 않도록 유의해야 한다.

 

# 패치와 모의에 대한 주의사항

 

간단한 테스트 케이스를 작성하기 위해 다양한 몽키 패치 또는 모의 객체를 생성해야 한다면 좋은 코드가 아니라는 신호이다.

 

unittest 모듈은 unittest.mock.patch 에서 객체를 패치하기 위한 도구를 제공한다. 패치란 임포트 중에 경로를 지정했던 원본 코드를 모의 객체 같은 다른 것으로 대체하는 것을 말한다. 이렇게 하면 런타임 중에 코드가 바뀌고 처음에 있던 원래 코드와의 연결이 끊어져 테스트가 조금 더 어려워지는 단점이 있다. 런타임 시 인터프리터에서 객체를 수정하는 오버헤드도 있으므로 성능상의 이슈도 있다.

 

# Mock 객체 사용하기

 

단위 테스트에서 말하는 테스트 더블(test double; 원래 코드를 대신하는 대역 테스트 코드) 의 카테고리에 속하는 타입에는 여러 객체가 있다. 테스트 더블은 여러 가지 이유로 테스트 스위트에서 실제 코드를 대신해 실제인 것처럼 동작하는 코드를 말한다. 실제 사용 코드는 필요하지 않다거나 특정 서비스에 접근해야 하는데 권한이 없다거나, 부작용이 있어서 단위 테스트에서 실행하고 싶지 않은 경우 등이다.

 

테스트 더블에는 더미(dummy), 스텁(stub), 스파이(spy), 모의(mock)와 같은 다양한 타입의 객체가 있다. 모의 객체는 가장 일반적인 유형의 객체이며 매우 융통성 있고 다양한 기능을 가지고 있기 때문에 모든 경우에 적합하다.

 

모의(Mock)는 스펙(상용 클래스의 객체와 유사)을 따르는 객체 타입으로 응답 값을 수정할 수 있다. 즉, 모의 객체 호출 시 응답해야 하는 값이나 행동을 특정할 수 있다. Mock 객체는 내부 호출 방법을 기록하고 나중에 이 정보를 사용하여 애플리케이션의 동작을 검증한다.

 

Mock 객체의 종류

 

파이썬 표준 라이브러리는 unittest.mock 모듈에서 Mock 과 MagicMock 객체를 제공한다. 전자는 모든 값을 반환하도록 설정할 수 있는 테스트 더블이며 모든 호출을 추적한다. 후자 역시 똑같지만 매직 메서드를 지원한다. 즉 매직 메서드를 사용한 경우는 MagicMock 객체를 사용해야 한다.

 

만약 Mock 객체에서 매직 메서드를 사용하려고 하면 에러가 발생한다.

 

class GitBranch:
    def __init__(self, commits: List[Dict]):
        self._commits = {c["id"]: c for c in commits}

    def __getitem__(self, commit_id):
        return self._commits[commit_id]

    def __len__(self):
        return len(self._commits)


def author_by_id(commit_id, branch):
    return branch[commit_id]["author"]

 

위의 예시에서 author_by_id 를 테스트 해보려고 한다.

 

def test_find_commit():
    branch = GitBranch([{"id": "123", "author": "dev1"}])
    assert author_by_id("123", branch) == "dev1"


def test_find_any():
    author = author_by_id("123", Mock()) is not None
    ...

 

commit_id = '123', branch = <Mock id='4499646224'>

    def author_by_id(commit_id, branch):
>       return branch[commit_id]["author"]
E       TypeError: 'Mock' object is not subscriptable

 

예상대로 동작하지 않는다. 이 때 MagicMock 을 사용하면 된다. return_value 속성을 사용하여 필요한 값을 반환하도록 직접 매직 메서드를 수정할 수 있다.

 

def test_find_any():
    mbranch = MagicMock()
    mbranch.__getitem__.return_value = {"author": "test"}
    assert author_by_id("123", mbranch) == "test"

 

테스트 더블의 사용 예

 

모의 객체의 사용 예로 애플리케이션에 머지 리퀘스트의 빌드 상태를 알리는 컴포넌트를 추가해보자. 빌드가 끝나면 머지 리퀘스트 아이디와 빌드 상태를 파라미터로 하여 객체를 호출한다. 그러면 특정 엔드포인트에 POST 요청을 보내 최종 머지 리퀘스트의 상태를 업데이트 한다.

 

from datetime import datetime

import requests
from constants import STATUS_ENDPOINT


class BuildStatus:
    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()

    @classmethod
    def notify(cls, merge_request_id, status):
        build_status = {
            "id": merge_request_id,
            "status": status,
            "built_at": cls.build_date(),
        }
        response = requests.post(STATUS_ENDPOINT, json=build_status)
        response.raise_for_status()
        return response

 

이 클래스의 문제는 외부 모듈에 의존성이 너무 크다. 아무것도 수정하지 않고 위 코드를 실행시키면 실패할 것이다.

지금 테스트하려는 것은 적절하게 정보가 구성되어 API 에 잘 전달되었는지 여부이다. 따라서 실제로 API 를 호출할 필요는 없고 단지 호출이 잘 되는지만 확인하면 된다.

 

또 다른 문제는 API 에 전달하는 값 중에 시간 값이 있는데 만약 빌드 시간을 비교하는 조건이 있다면 이 시간 값이 고정되어야 하는데, 실시간으로 변하는 값이므로 정확히 예측을 할 수가 없다는 점이다. datetime 모듈 자체는 C로 작성되었으므로 datetime 을 직접 패치할 수는 없다. 이런 경우를 위해 datetime 모듈을 override 하여 사용자가 지정한 시간으로 반환해주는 freezegun 같은 외부 라이브러리도 있지만 성능상의 불이익이 따르고 이 예제에서는 과도한 기능이다. 따라서 여기서는 직접 패치할 수 있는 build_date 정적 메서드를 래핑할 것이다.

 

이제 단위 테스트를 작성해보면 아래와 같다.

 

from unittest import mock

from constants import STATUS_ENDPOINT
from mock_2 import BuildStatus


@mock.patch("mock_2.requests")
def test_build_notification_sent(mock_requests):
    build_date = "2018-01-01T00:00:01"
    with mock.patch("mock_2.BuildStatus.build_date", return_value=build_date):
        BuildStatus.notify(123, "OK")

    expected_payload = {"id": 123, "status": "OK", "built_at": build_date}
    mock_requests.post.assert_called_with(
        STATUS_ENDPOINT, json=expected_payload
    )

 

먼저 @mock.patch 데코레이터를 사용하여 테스트 안에서 mock_2.requests 를 호출하면 mock_request 라는 mock 객체가 대신할 것이라고 알려준다. 그리고 mock.patch 함수를 컨텍스트 매니저로 사용하여 build_date() 메서드 호출 시 어설션에 사용할 build_date 날짜를 반환하도록 패치한다.

 

그 후에 BuildStatus.notify @classmethod를 호출을 통해 mock 객체의 post 메서드에 특정 날짜를 포함한 파라미터가 전달될 경우 HTTP 상태가 200이 될 것이라는 지정을 한셈이다. 따라서 mock_request.post 에 동일한 파라미터를 사용해 호출하면 assert_called_with 는 성공하게 된다.

 

이것이 mock 객체의 장점이다. 이번처럼 알림을 보내는 외부 HTTP 요청을 하지 않아도 될 뿐만 아니라, API 호출 성공 여부와 파라미터의 유효성까지도 확인할 수 있다.

 

테스트에서 사용하는 각각의 외부 컴포넌트를 mock 객체로 패치하여 테스트를 할 수 있었지만 특정 부분을 반복적으로 패치해야 한다면 분명 추상화가 잘못된 것이고 나쁜 코드라는 것을 짐작할 수 있다.

 

3. 리팩토링

 

리팩토링은 소프트웨어 유지 관리에서 중요한 활동이지만 단위 테스트가 없다면 정확성을 보장 받기 어렵다. 언제든 새로운 기능을 의도한 바대로 지원할 수 있어야 한다. 이러한 요구 사항을 수용할 수 있는 방법은 먼저 코드를 리팩토링하여 보다 일반적인 형태로 만들어야 한다.

 

일반적으로 코드를 리팩토링할 때는 구조를 개선하여 보다 나은 코드를 만들려는 경우가 있고 때로는 좀 더 일반적인 코드로 수정하여 가독성을 높이려는 경우가 있다. 중요한 점은 이러한 수정 작업 이전과 이후가 완전히 동일한 기능을 유지해야 한다는 것이다. 즉, 리팩토링을 한 컴포넌트의 고객 관점에서는 아무 일도 일어나지 않은 것처럼 느껴져야 한다.

 

3-1. 코드의 진화

 

앞의 예제에서는 단위 테스트에서 제어할 수 없는 의존성이 있는 것들을 패치하여 코드의 부작용을 분리할 수 있었다. 그러나 단점은 모듈을 포함하여 모의하려는 객체의 경로를 문자열로 제공해야 한다는 것이다. 이 상태에서 코드를 리팩토링하면, 패치를 한 모든 곳을 업데이트하거나 테스트가 실패할 것이므로 취약하다.

 

예제에서 notify() 메서드가 구현 세부 사항(request 모듈)에 직접 의존한다는 것은 설계상의 문제다. 즉, 앞서 언급한 취약성과 함께 단위 테스트에 영향을 미치고 있다.

 

코드를 리팩토링하면 보다 나은 방법으로 처리할 수 있다. 메서드들을 더 작게 나눈다. 그리고 가장 중요한 것은 의존성을 주입하는 것이다. 의존성 역전 원칙을 적용하여 requests 모듈이 제공하는 것과 같은 인터페이스를 지원하도록 한다.

 

class BuildStatus:

    endpoint = STATUS_ENDPOINT

    def __init__(self, transport):
        self.transport = transport

    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()

    def compose_payload(self, merge_request_id, status) -> dict:
        return {
            "id": merge_request_id,
            "status": status,
            "built_at": self.build_date(),
        }

    def deliver(self, payload):
        response = self.transport.post(self.endpoint, json=payload)
        response.raise_for_status()
        return response

    def notify(self, merge_request_id, status):
        return self.deliver(self.compose_payload(merge_request_id, status))

 

notify 를 분리하여 compose 와 deliver 로 나누고, 클래스를 패치하지 않고 바꿀 수 있도록 compose_payload() 라는 새로운 메서드를 만들었다. 또한 transport 라는 의존성을 주입하였다. 이제 transport 는 주입되는 것이므로 테스트 더블의 형태를 변경하는 것이 훨씬 쉬워졌다.

 

@pytest.fixture
def build_status():
    bstatus = BuildStatus(Mock())
    bstatus.build_date = Mock(return_value="2018-01-01T00:00:01")
    return bstatus


def test_build_notification_sent(build_status):

    build_status.notify(1234, "OK")

    expected_payload = {
        "id": 1234,
        "status": "OK",
        "built_at": build_status.build_date(),
    }

    build_status.transport.post.assert_called_with(
        build_status.endpoint, json=expected_payload
    )

 

3-2. 상용 코드만 진화하는 것이 아니다.

 

단위 테스트 코드가 메인 코드만큼 중요하다면 확장성을 염두에 둬야 하고, 유지보수가 가능하도록 디자인해야 한다. 모든 코드는 결국 원래 작성자가 아닌 다른 엔지니어가 유지 관리해야 하는 것이므로 가독성이 높아야 한다.

 

우리가 이렇게 코드의 유연성을 높이기 위해 많은 관심을 기울이는 이유는 시간이 지남에 따라 요구사항이 변화하고 진화한다는 것을 알고 있기 때문이다. 또한 결국에는 도메인 비즈니스 규칙이 변경될 것이고 이러한 새로운 요구사항을 지원하기 위해 코드도 변경되어야 하기 때문이다. 상용 코드가 새로운 요구사항을 지원하도록 변경되었기 때문에 테스트 코드도 새로운 버전의 상용 코드를 지원하기 위해 변경되어야 한다. 

 

앞의 merge request 예제에서는 객체에 대한 일련의 테스트가 있었고, 다양한 조합으로 리퀘스트의 상태를 확인했다. 물론 좋은 방법이지만 더 좋은 방법이 있다.

 

문제를 정확히 이해한다면 더 나은 추상화를 할 수 있다. 또한 특정 조건을 검사하는 더 높은 수준의 추상화를 만들 수도 있다.

예를 들어 특별히 MergeRequest 클래스를 대상으로 하는 테스트 스위트 객체가 있는 경우, MergeRequest 클래스는 단일 책임 원칙(SRP)를 준수했을 것이므로 테스트는 이 클래스의 역할에만 초점을 맞추어 제작하면 된다. 그에 따라 정형화된 단순 코드를 줄이는데 도움이 된다. 왜냐하면 이제 해당 클래스가 정확하게 구현되었는지 반복해서 체크하는 대신 특정 기능을 캡슐화한 메서드를 만들고 여러 테스트에서 재사용할 수 있기 때문이다.

 

# 기존
    def test_simple_rejected(self):
        self.merge_request.downvote("maintainer")
        self.assertEqual(
            self.merge_request.status.value, MergeRequestStatus.REJECTED.value
        )

# 캡슐화
class TestMergeRequestStatus(TestCase):
    def setUp(self):
        self.merge_request = MergeRequest()

    def assert_rejected(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestExtendedStatus.REJECTED
        )

    def assert_pending(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestExtendedStatus.PENDING
        )

    def assert_approved(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestExtendedStatus.APPROVED
        )

    def test_simple_rejected(self):
        self.merge_request.downvote("maintainer")
        self.assert_rejected()

 

merge request 상태를 확인하는 방법이 변경되거나 추가 검사를 하려는 경우 assert_rejected() 메서드 하나만 변경하면 된다.

 

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

 

단위 테스트를 통해 코드에 대한 확신을 얻을 수 있다고 했는데 테스트 시나리오를 충분히 검증했으마 누락된 것이 없다는 것은 어떻게 확신할 수 있을까? 누가 이 테스트가 정확하다고 판단할 수 있을까?

 

첫 번째 질문은 작성한 테스트에 대한 완벽성에 대한 것인데 속성 기반의 테스트를 통해 해답을 얻을 수 있다.

두 번째 질문은 여러 관점에서 다양한 답변을 얻을 수 있겠지만 테스트가 정말 정확한지 확인하기 위해 돌연변이 변형 테스트를 해볼 수 있다.

 

4-1. 속성 기반 테스트

 

속성 기반(Property-based) 테스트는 테스트를 실패하게 만드는 데이터를 찾는 것이다. hypothesis 라이브러리를 사용한다. 이 라이브러리를 통해 성공하지 못하는 반대 사례를 찾을 수 있다. 코드에 유효한 가설을 정의하면 hypothesis 라이브러리가 에러를 유발하는 사례를 찾아준다. 이를 통해 상용 코드에 대해 더 많이 생각할 수 있는 기회를 가질 수 있다.

 

4-2. 변형 테스트

 

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

 

테스트를 통과하는 것은 좋지만, 테스트를 잘못하여 통과한 것이라면 더 위험할 수 있다. 즉, 자동화된 단위 테스트를 사용하는 중에 누군가 버그를 추가했다면 적어도 하나 이상의 테스트에서 이를 포착하여 테스트에 실패해야 한다. 만약 실패가 발생하지 않는다면 테스트에 누락된 부분이 있다거나 올바른 체크를 하지 않았다는 것이다.

 

이것이 변형 테스트를 하는 이유이다. 변형 테스트 도구를 사용하면 원래 코드를 변경한 새로운 버전(mutant)으로 코드가 수정된다. 좋은 테스트 스위트는 돌연변이를 죽여야(kill) 하는데, 이런 경우 테스트가 신뢰할 만하다는 것을 의미한다. 일부 돌연변이가 실험에서 생존하면 대게 좋지 않은 징후이다.

 

def evaluate_merge_request(upvote_count, downvotes_count):
    if downvotes_count > 0:
        return Status.REJECTED
    if upvote_count >= 2:
        return Status.APPROVED
    return Status.PENDING

 

앞선 merge request 예제에서 승인과 거절 횟수에 따라 상태가 결정되도록 수정했다. 

간단한 단위 테스트를 추가하여 특정 조건에서의 결과를 확인한다.

 

class TestMergeRequestEvaluation(unittest.TestCase):
    def test_approved(self):
        result = evaluate_merge_request(3, 0)
        self.assertEqual(result, Status.APPROVED)

 

파이썬의 변형 테스트 도구인 mutpy 를 설치한다. 그리고 다음과 같이 변형 테스트를 실행한다.

 

mut.py \
    --target mutation_testing_$N \
    --unit-test test_mutation_testing_$N \
    --operator AOD  `# 산술 연산자 삭제` \
    --operator AOR  `# 산술 연산자 교체` \
    --operator COD  `# 조건 연산자 삭제` \
    --operator COI  `# 조건 연산자 추가` \
    --operator CRP  `# 상수 교체` \
    --operator ROR  `# 관계 연산자 교체` \
    --show-mutants

 

다음과 같은 결과가 나온다.

 

[*] Mutation score [0.06685 s]: 100.0%
   - all: 4
   - killed: 4 (100.0%)
   - survived: 0 (0.0%)
   - incompetent: 0 (0.0%)
   - timeout: 0 (0.0%)

 

결과를 분석하기 위해 인스턴스 중 하나를 자세히 살펴보면 다음과 같은 돌연변이가 보인다.

 

   - [#   1] ROR mutation_testing_1:9  : 
--------------------------------------------------------------------------------
  5: from mrstatus import MergeRequestStatus as Status
  6: 
  7: 
  8: def evaluate_merge_request(upvote_count, downvotes_count):
~ 9:     if downvotes_count < 0:
 10:         return Status.REJECTED
 11:     if upvote_count >= 2:
 12:         return Status.APPROVED
 13:     return Status.PENDING
--------------------------------------------------------------------------------
[0.00877 s] killed by test_approved (test_mutation_testing_1.TestMergeRequestEvaluation)

 

이 돌연변이는 원래 코드의 9번째 줄에서 연산자를 변경(> 를 <로 변경)한 것이며 테스트에 의해 죽었음을 알려준다.

즉, 실수로 누군가 코드를 이렇게 변경한다고 가정하면 함수의 반환 값은 APPROVED 인데 테스트에서는 REJECTED 를 기대하고 있으므로 테스트에 실패할 거라는 뜻이다. 이것은 테스트가 버그를 잡았다는 뜻이므로 좋은 신호이다.

 

변경 테스트는 단위 테스트의 품질을 보장하는 좋은 방법이지만 복잡환 환경 속에서는 각 시나리오를 분석하는데 오랜 시간이 걸릴 수 있으며 여러 버전의 코드를 여러 번 실행해야 하기 때문에 테스트를 실행하는데 비용이 많이 든다. 그럼에도 이러한 확인 작업을 수동으로 한다면 훨씬 더 비싼 비용과 시간이 들것이며, 아예 이런 종류의 확인을 하지 않는다는 것은 테스트의 품질을 떨어뜨릴 수 있다.