본문 바로가기
프로그래밍 언어

단위 테스트(Pytest, Mock)란 무엇인가?

by 내기록 2023. 9. 4.
반응형

목차 LIST

     

     

     

    단위 테스트

    단위 테스트는 보조 수단이 아닌 소프트웨어의 핵심이 되는 필수적인 기능으로 일반 비즈니스 로직과 동일한 수준으로 다뤄져야 함

    단위 테스트는 비즈니스 로직이 특정 조건을 보장하는지 확인하기 위해 여러 시나리오를 검증하는 코드

     

    특징

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

     

    단위 테스트 / 통합 테스트 / 인수 테스트

    단위 테스트는 함수와 같은 매우 작은 단위를 확인하기 위한 것으로 최대한 자세하게 코드를 검사하는 것이 목적이다.

    클래스 테스트는 단위 테스트의 집합인 테스트 스위트(test suite)를 사용해야 하며 테스트 스위트를 구성하는 테스트들은 메서드처럼 작은 것 영역을 테스트한다.

     

    통합 테스트는 한 번에 여러 컴포넌트를 테스트하며 종합적으로 잘 동작하는지 검증한다. HTTP 요청을 하거나 데이터베이스에 연결하는 것과 같은 작업을 수행하는 것이 가능하고 때로는 그렇게 하는 것이 바람직하다.

     

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

     

    이 두 가지 테스트는 단위 테스트와 관련된 중요한 특성인 '속도'를 보장하기 어렵다.

    일반적으로 단위 테스트는 항상 수행되길 원하지만 통합 테스트나 인수 테스트가 자주 수행되는 것을 원하지는 않는다.

    -> 단위 테스트에서 작은 기능들을 많이 테스트하고, 단위 테스트에서 확인할 수 없는 데이터베이스 같은 부분은 다른 자동화된 테스트에서 커버할 수 있다.

     

    단위 테스트 과정 및 간단한 예제

    단위 테스트는 단순히 기본 코드를 보완하는 것이 아니라 실제 코드의 작성 방식에도 영향을 준다. 

     

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

     

    단위테스트 예제

    예시)

    에러 상황 : 타사 지표 전송 클라이언트(MetricsClient)는 파라미터가 문자열이어야 한다. run_process()의 결과인 result가 문자열이 아니라면 전송에 실패하게 된다.

    class MetricsClient:
    	def send(self, metric_name, metric_value):
        	if not isinstance(metric_name, str):
            	raise TypeError("metric_name으로 문자열 타입을 사용해야 함")
        	if not isinstance(metric_value, str):
            	raise TypeError("metric_value로 문자열 타입을 사용해야 함")
                
    class Process:
    	def __init__(self):
        	self.client = MetricsClient() # 타사 지표 전송 클라이언트
            
        def process_iterations(self, n_iterations):
            for i in range(n_iterations):
                result = self.run_process() # 여기!
                self.client.send(f"metric_{i}", result)

     

    이 버그를 단위 테스트로 검증해보자. 단위 테스트가 있으면 리팩토링을 여러 번 하더라도 버그가 재현되지 않는다는 것을 증명할 수 있다. 필요한 부분만 테스트하기 위해 client를 직접 다루지 않고 래퍼(wrapper) 메서드에 위임한다.

    class WrappedClient:
    	def __init__(self):
        	self.client = MetricsClient()
    
    	def send(self, metric_name, metric_value):
        	return self.clients.send(str(metric_name), str(metric_value)) # convert 추가됨
            
    class Process:
    	def __init__(self):
        	self.client = WrappedClient()
            
    	# 나머지 코드는 그대로 유지

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

    이러한 컴포지션 방식은 어댑터 디자인 패턴과 유사하다. 

     

    메서드를 분리했으니 실제 단위테스트를 작성한다.

    import unittest
    from unittest.mock import Mock
    
    class TestWrappedClient(unittest.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")

    Mock은 unittest.mock 모듈에서 사용할 수 있는 타입으로 어떤 종류의 타입에도 사용할 수 있는 편리한 객체이다. 예를 들어 타사 라이브러리 대신 Mock 객체를 사용하면 예상대로 호출되는지 확인할 수 있다. (라이브러리 자체 테스트가 아니라 올바르게 호출되었는지 확인하는 것)

     

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

    • unittest : 파이썬의 표준 라이브러리
    • pytest : pip 설치 라이브러리

     

    외부 시스템에 연결하는 것과 같이 의존성이 많은 경우 테스트 케이스를 파라미터화 할 수 있는 픽스쳐(fixture)라는 패치 객체가 필요하다. 이런 상황에는 pytest가 적합하다.

     

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

     

    pytest

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

    pytes는 assert 비교만으로 단위 테스트를 식별하고 결과 확인이 가능하다. pytest 명령어를 통해 탐색 가능한 모든 테스트를 한번에 실행 수도 있다. (심지어 unittest로 작성한 테스트도 실행한다.) 이러한 호환성 때문에 unittest에서 pytest로 점진적으로 전환하는 것도 가능하다.

    def test_simple_reject():
        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 구문만 사용하면 되지만, 예외 발생 메시지를 검사하고 싶으면 아래와 같이 raises를 사용한다.

    pytest.raises는 메서드 형태 또는 컨텍스트 관리자 형태로 호출될 수 있다. 예외 메시지를 검사하고 싶으면 match를 사용하면 된다.

    def test_invalid_types():
        merge_request = MergeRequest()
        pytest.raise(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="종료된 머지 리퀘스트에 투표할 수 없음", # 에러 메시지 확인
        ):
        merge_request.downvote("dev1")

     

    테스트 파라미터화

    pytest.mark.parameterize 데코레이터를 사용하면 테스트케이스를 파라미터화 할 수 있다.

    @pytest.mark.parametrize("context,excepted_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

     

    픽스처(Fixture)

    pytest을 사용하면 재사용 가능한 기능을 쉽게 만들어서 효율적으로 테스트를 할 수 있다.

    예를 들어, 특정 상태를 가진 MergeRequest 객체를 만들고 여러 테스트에서 이 객체를 재사용 할 수 있다. 픽스처를 정의하려면 먼저 함수를 만들고 @pytest.fixture 데코레이터를 적용한다. 이 픽스처를 사용하길 원하는 테스트에 파라미터로 픽스처의 이름을 전달하면 된다.

    @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

     

    마지막 함수인 test_rejected_to_approved 기준으로 코드 설명을 간단히 하자면,

    rejected_mr이라는 fixture를 사용해서 upvote()를 수행하면 merge_request.upvote()가 실행된다고 보면 된다.

     

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

     

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

     

    예제

    이해를 돕기 위해 예제를 하나 추가하려고 한다.

    - fixtures.py 파일

    @pytest.fixture(name="client", scope="module")
    def test_client_with_session(app_instance, user: str) -> FlaskClient:
        app = get_app()
        client = app.test_client()
        
        with client.session_transaction() as session:
            session["user_name"] = user
            session["jwt_issued"] = False
            
        yield client
    
    @pytest.fixture(name="jwt", scope="session", autouse=True)
    def user_access_token(app_instance, user):
    	app = get_app()
        with app.app_context():
            return custom_create_access_token(user)# 사용자 정의 함수 호출

    코드 설명

    `pytest` 에서는 테스트 전/후에 자주 사용되는 설정이나 자원 할당/해제 등을 위해 `fixture`라는 기능을 제공한다.

     

    1. test_client_with_session

        - FlaskClient를 반환한다. `FlaskClient`는 Flask 웹 앱을 테스트하기 위한 클래스이다.

        - `get_app()`을 호출하여 Flask 앱 인스턴스를 가져오고, `app.test_client()`를 사용해 테스트 클라이언트를 생성한다.

        - `client.session_transaction()`을 사용하여 세션에 몇 가지 값을 설정한다.

        - 마지막으로, `yield client`로 해당 fixture를 사용하는 테스트 함수에 `client` 인스턴스를 제공한다.

        - `name` 매개변수는 fixture의 이름이며, `scope`는 fixture의 범위로 "module"은 해당 모듈의 모든 테스트에 대해 한 번만 실행됨을 의미한다.

        - 여기서 모듈은 파이썬 파일 단위로 나뉜다. 예를 들어, test_example.py라는 파일에 여러 테스트 함수가 있으면, 파일 전체는 하나의 "모듈" 간주된다.

     

    2. jwt_fixture

        - JWT (JSON Web Token)을 반환하는 함수이다.

        - `get_app()`을 사용해 Flask 앱 인스턴스를 가져오고, `app.app_context()` 내에서 `custom_create_access_token(user)`를 호출하여 커스텀 JWT를 생성하고 반환한다.

        - `name`과 `scope` 매개변수는 fixture의 이름과 범위를 정의하는데, "session" 범위는 모든 세션에 대해 한 번만 실행됨을 의미한다.

        - `autouse=True`는 이 fixture가 해당 범위(session) 내의 모든 테스트에 자동으로 적용됨을 의미한다.

     

    사용 방법

    sample_test.py

    def test_get_list(self, client, jwt):
    	response = client.get(...)
        assert response.status_code == 200
    • client.get(...): ... 부분에는 테스트하려는 엔드포인트의 URL이 들어간다. 이 메서드를 호출하면 해당 URL에 GET 요청이 보내지며, 그 결과로 응답 객체를 반환받게 된다.

     

    코드 커버리지

    테스트러너 플러그인을 사용하면 테스트 도중 코드의 어떤 부분이 실행되었ㅈ는지 알 수 있다. 이 정보는 테스트에서 어떤 부분을 다뤄야 할지, 어떤 부분이 개선되었는지 알 수 있게 해준다.

    가장 널리 사용되는 것은 coverage 라이브러리이다. 좋은 도구로 CI에서 테스트를 실행할 때 같이 설정하기를 추천하지만 파이썬에서는 가끔 잘못 분석하는 경우가 있기 때문에 커버리지 보고서를 주의해서 살펴봐야 한다.

     

    코드 커버리지 도구 설정

    pytest는 pytest_cov 패키지를 설치하고 pytest 러너에게 pytest-cov가 실행될 것이라는 것과 어떤 패키지를 사용할지 알려줘야 한다.

    특히 테스트되지 않은 행을 알려주는 기능을 사용하면 추가로 어떤 테스트를 작성해야 할지 확인할 수 있다.

    pytest --cov-report term-missing --cov=coverage_1 test_coverate_1.py

    위와 같이 실행하면 출력 결과에 단위 테스트를 하지 않은 라인이 있다는 것이 표시된다. 결과를 보고 단위 테스트를 어떻게 작성할지 살펴볼 수 있다. 이렇게 단위 테스트에서 커버하지 못한 부분을 발견하고 작은 메서드를 만들어서 리팩토링 하는 것이 일반적인 시나리오다.

     

    문제는 반대의 경우인데 높은 커버리지를 있는 그대로 신뢰할 수 있는지 고민해봐야 한다. 높은 테스트 커버리지는 좋지만 클린 코드를 위한 조건으로는 부족하다. 높은 커버리지에도 불구하고 보다 많은 테스트가 필요할 수 있으며 이 부분은 생각해봐야하는 테스트 커버리지의 맹점이다.

     

     

    모의(Mock) 객체

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

     

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

     

    단위 테스트에서는 외부 서비스들이 호출되는지만 확인하면 된다. 단위 테스트는 많이 실행하고 항상 실행하기 때문이다.

    반면에 통합 테스트는 거의 실제 사용자의 행동을 모방하여 넓은 관점에서 기능을 테스트하기 때문에 시간이 오래걸린다. 외부 시스템과 서비스에 실제 연결까지 테스트한다. 하지만 자주 실행해서는 안되는데 예를 들어, 새로운 머지 리퀘스트가 있을 경우에만 통합 테스트를 할 수 있다.

     

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

     

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

    단위 테스트는 보다 나은 코드를 작성하는데 도움이 된다. 왜냐하면 특정 코드를 테스트하려면 테스트가 가능하게 짜야하는데 이는 코드 응집력이 뛰어나고, 세분화되어 있으며, 작다는 것을 의미한다.

    또한 테스트를 통해 문제가 없다고 생각하던 부분에서 안티패턴을 찾을 수 있다. 간단한 테스트 케이스를 작성하기 위해 다양한 몽키 패치(모의)를 해야 한다면 코드에서 나쁜 냄새가 난다는 신호이다.

     

    몽키 패치(모의)를 사용하는 것 자체는 문제되지 않지만, 이것을 남용하게 된다면 원본 코드를 개선할 여지가 있다는 신호이다.

     

    Mock 객체 사용하기

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

     

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

     

    모의(mock)는 모의 객체 호출 시 응답해야 하는 값이나 행동을 특정할 수 있다. Mock 객체는 내부에 호출 방법(파라미터와 호출 횟수 등)을 기록하고 나중에 이 정보를 사용하여 애플리케이션의 동작을 검증한다.

     

    테스트 더블 사용 예시

    단위테스트를 할 때 실제로 API를 호출할 필요는 없고 잘 호출되는지만 확인하면 된다.

    import pytest
    from unittest.mock import patch
    import my_module
    
    def test_get_message():
        # Mock 데이터 생성
        mock_data = {"message": "Hello, World!"}
    
        # requests.get 호출을 mock_data로 대체
        with patch("my_module.requests.get", return_value=mock_data):
            result = my_module.get_message()
    
        assert result == "Hello, World!"
    • my_module.get_message()는 실제로 requests.get을 호출하여 메시지를 가져오는 함수이다.
    • with patch("my_module.requests.get", return_value=mock_data)
      -> my_module 내부에서 사용되는 requests.get 함수 호출을 가상의 동작으로 대체
    • 테스트에서는 unittest.mock의 patch를 사용하여 requests.get 호출을 모킹한다. 이렇게 함으로써 실제 외부 API 호출 없이 테스트를 수행할 수 있다.
    • mock_data는 requests.get의 반환 값을 모방하는 Mock 데이터이다.
    • 테스트 함수 내에서 patch를 사용하여 requests.get의 호출에 대한 리턴 값을 mock_data로 대체한다.

    예제를 통해, 실제 외부 API 호출 없이 get_message 함수를 테스트할 있다.

     

     

     

    References

    파이썬 클린 코드 마리아노 아나야 저자, 김창수 번역

    ㄴ 번역이 굉장히 매끄러워서 글이 잘 읽힙니다. 좋은 책 추천

     

     

    반응형

    댓글