본문으로 바로가기
728x90
반응형

목차


    개요

    pytest는 python에서 테스트 코드를 작성하기 위해 많이 사용되는 라이브러리입니다.

     

    unittest와는 조금 다르게 테스트 코드를 작성하기에 간결하고 여러 기능을 지원하기 때문에 필자는 자주 이용하는 편입니다.

    이전부터 가끔씩 django에서 pytest를 써왔는데 pytest를  최근 자주 사용하게 되어 pytest에서 애용했던 항목들을 기록해두고자 합니다.

     

    Installation

    pytest를 django에 사용하기 위해 다음과 같이 ‘pytest-django’라는 라이브러리를 설치해야합니다.

    pip install pytest-django
    

    pytest-django는 pytest.ini를 통해 pythonpath를 설정할 수 있게 해줍니다. python.ini위치는 프로젝트의 루트 디렉토리입니다.

    ╰─$ tree -L 2
    .
    ├── Makefile
    ├── README.md
    **├── pytest.ini**
    ├── src
    │   ├── __init__.py
    │   ├── __pycache__
    │   ├── application
    │   ├── config
    │   └── manage.py
    

    pytest.ini에 어떤 옵션을 지정할지는 환경마다 다르기 떄문에 특정할 수는 없지만 제 경우에는 다음과 같이 설정했습니다.

    [pytest]
    addopts = -v --reuse-db --no-migrations
    DJANGO_SETTINGS_MODULE = src.config.settings
    pythonpath = . src
    

    가장 기본적인 셋팅이기 떄문에 자세한 설명은 생략하겠습니다.

     

    Test Layout

    저는 django를 주로 많이 이용하는데 django project의 기본 layout을 src layout으로 두고 사용합니다. 그러다 보니 디렉터리 구조가 다음과 같은 구조를 가지게 됩니다.

    ╰─$ tree -L 2
    .
    ├── Makefile
    ├── README.md
    ...
    ├── src
    │   ├── __init__.py
    │   ├── __pycache__
    │   ├── application
    │   ├── config
    │   └── manage.py
    **├── tests
    │   ├── __init__.py
    │   ├── __pycache__
    │   ├── conftest.py
    │   └── unit**
    

    위 구조에서 테스트 코드는 src 내부의 application 디렉토리에 위치하는 것이 아닌 src 외부에 tests 라는 폴더에 위치하게 됩니다. 이렇게 구성한 경우 테스트 피라미드처럼 테스트 디렉토리를 만들 수 있게 됩니다.

    ├── src
    │   ├── __init__.py
    │   ├── __pycache__
    │   ├── application
    │   ├── config
    │   └── manage.py
    ├── tests
    │   ├── __init__.py
    │   ├── __pycache__
    │   ├── conftest.py
    **│   ├── src
    │   ├──── e2e
    │   ├──── integration
    │   ├──── unit**
    │   └── unit
    

     

    conftest.py

    pytest에는 test가 수행되기 전 참고하는 파일이 있습니다. ‘conftest.py’라는 파일입니다. pytest는 테스트가 수행되기 전 conftest.py 파일에 정의된 fixture들을 인식하게 됩니다. fixture는 테스트 코드 수행 전 사용할 helper 정도로 정의할 수 있습니다.

    django_db_setup

    테스트에 사용할 db를 어떤 것을 사용할지 설정할 수 있는 fixture입니다. pytest 자체에는 포함되어있지 않고 pytest-django를 설치하면 사용이 가능합니다.

    import pytest
    
    @pytest.fixture
    def django_db_setup():
        """Avoid creating/setting up the test database"""
        from django.conf import settings
        settings.DATABASES['default']['NAME'] = 'testdb'
    

    django에서 test를 실행하게 될 경우 기본적으로는 test db를 생성하고 이렇게 생성된 testdb에 테스트를 수행하게됩니다. 이것도 나쁘진 않지만 데이터베이스 정보를 초기화하는 과정에서 시간을 잡아먹습니다 시간을 조금이라도 아끼기 위해 테스트용 데이터베이스를 미리 만들어두고 위와 같이 django_db_setup()에 db를 명시해주면 해당 db를 통해 테스트 코드를 실행할 수 있습니다.

     

    자주 사용한 Fixture 들

    conftest.py에 정의한 fixture들 외에 pytest혹은 pytest-django에서 테스트 코드에 적용 가능한 fixture들이 존재합니다. 아래 항목들은 테스트 코드를 사용할 때 자주 사용했던 fixture 들입니다.

    pytest.mark.skip: 특정 테스트 건너뛰기

    상황에 따라 특정 test는 건너뛰어야 할 수도 있습니다. 이 경우에는 다음과 같이. pytest.mark.skip 데코레이터를 사용할 수 있습니다.

    @pytest.mark.skip(reason="Skip reasson")
    def test_function(self):
    		print("skip method")
    

    위와 같이 지정한 경우 테스트 수행 결과에 대해서는 다음과 같이 ‘s’표기된다.

    ===================================== test session starts ======================================
    platform darwin -- Python 3.11.2, pytest-7.2.2, pluggy-1.0.0
    rootdir: /Users/jako/private/git-repo/study-python-src/Research
    collected 2 items
    
    pytest_demo.py .s                                         ƒlon[100%]
    


    pytest.mark.parametize: 매개변수화

    쉽게 말하면 파라미터에 미리 지정된 값을 넣어 테스트를 할 수 있게 해주는 fixture입니다. 코드를 보면 이해가 더 빠를 것입니다.

    @pytest.mark.parametrize(
        'param',
        [(1, 2), ]
    )
    def test_target_function(param):
        a, b = param
        assert target_function(a, b) == 3
    

    위와 같이 선언한 경우 test_target_function의 param에 1과 2라는 값을 넣어 test_target_function을 실행합니다.


    pytest.mark.usefixture: fixture 데코레이팅

    임의로 fixture 데코레이팅이라고 명명했지만 이 fixture는 어떤 테스트를 수행할 때 지정된 fixture를 수행하게 만들어줍니다. 사용법은 아래와 같습니다.

    import pytest
    
    @pytest.fixture
    def im_fixture():
        print("Hello")
        yield
        print("Bye")
    
    @pytest.mark.usefixtures('im_fixture')
    def test_function():
        print("it's test_function")
    
        assert 1 == 2
    

    위와 같이 사용하며 test_function은 다음과 같은 결과를 보여줍니다.

    test_example.py F                                                                                                             [100%]
    
    ============================================================= FAILURES ==============================================================
    ___________________________________________________________ test_function ___________________________________________________________
    
        @pytest.mark.usefixtures('im_fixture')
        def test_function():
            print("it's test_function")
    
    >       assert 1 == 2
    E       assert 1 == 2
    
    test_example.py:19: AssertionError
    ------------------------------------------------------- Captured stdout setup -------------------------------------------------------
    Hello
    ------------------------------------------------------- Captured stdout call --------------------------------------------------------
    it's test_function
    ----------------------------------------------------- Captured stdout teardown ------------------------------------------------------
    Bye
    ====================================================== short test summary info ======================================================
    FAILED test_example.py::test_function - assert 1 == 2
    

    즉, 어떤 테스트 코드를 수행하기 전 pytest.mark.usefixtures에 지정된 fixture를 불러다 사용해줍니다. 사실 이는 다음과 같이 사용하는 형태와 차이는 없습니다.

    import pytest
    
    @pytest.fixture
    def im_fixture():
        print("Hello")
        yield
        print("Bye")
    
    def test_function(im_fixture):
        print("it's test_function")
    
        assert 1 == 2
    
    ============================================================= FAILURES ==============================================================
    ___________________________________________________________ test_function ___________________________________________________________
    
    im_fixture = None
    
        def test_function(im_fixture):
            print("it's test_function")
    
    >       assert 1 == 2
    E       assert 1 == 2
    
    test_example.py:14: AssertionError
    ------------------------------------------------------- Captured stdout setup -------------------------------------------------------
    Hello
    ------------------------------------------------------- Captured stdout call --------------------------------------------------------
    it's test_function
    ----------------------------------------------------- Captured stdout teardown ------------------------------------------------------
    Bye
    ====================================================== short test summary info =========================
    

    필자는 pytest.mark.usefixtures를 명시해주면 데코레이트처럼 사용할 수 있으니 테스트 함수의 파라미터가 fixture와 섞이지 않게 해주는 효과를 가진다고 봅니다. 그렇기 떄문에 fixture 데코레이팅이라고 명명했습니다.

    pytest.mark.django_db: db 접근 허용하기

    특정 테스트 코드에서 db에 접근해야된다면 pytest.mark.djanog라는 fixture를 이용할 수 있습니다. 사용 형태는 다음과 같습니다.

    @pytest.mark.django_db
    class TestSomeClass: ...
    

    주의할 점은 테스트 코드가 데이터베이스에 commit을 수행하는 동작이 포함되어있다면 transaction을 명시해주어야 합니다.

    @pytest.mark.django_db(transaction=True)
    class TestSomeClass: ...
    



    Fixture 분리하기

    앞서 pytest는 테스트를 수행하기 전 conftest.py 의 내용을 읽어들인다고 기술했습니다. 이렇게 된 경우 fixture들의 위치가 다 conftest.py에 정의될 수 있습니다. 여러 fixture가 conftest.py에만 정의되어있다면 가독성이 상당히 떨어질 것입니다. 그렇기 때문에 fixture들을 적절히 구조화 시켜야 될 필요가 있습니다.

    conftest.py 에 pytest_plugins 라는 변수를 선언해 fixture를 선언한 위치를 명시해주면 해당 위치에 선언된 fixture를 사용할 수 있게 됩니다.

    # conftest.py
    import pytest
    
    ...
    
    pytest_plugins = [
        'tests.fixtures.some_fixture01',
        'tests.fixtures.some_fixture02',
    ]
    

    pytest_plugins에 명시된 설정은 프로젝트 구조에서는 다음과 같은 모습을 갖춥니다.

    ├── src
    │   ├── __init__.py
    │   ├── __pycache__
    │   ├── application
    │   ├── config
    │   └── manage.py
    ├── tests
    │   ├── __init__.py
    │   ├── __pycache__
    │   ├── conftest.py
    │   ├── fixtures
    **│   ├──── some_fixture01.py
    │   ├──── some_fixture02.py**
    │   ├── src
    │   ├──── e2e
    │   ├──── integration
    │   ├──── unit
    │   └── unit
    

     

    Assert 사용하기

    pytest의 assert는 unittest의 assert와는 다르게 python의 assert만을 이용합니다. unittest의 상황에 맞는 적절한 assert 메서드를 기억할 필요가 없다는 장점이 존재하며 pytest에서 assert를 사용할 때 아래 사실만 기억하면 될 듯합니다.

    Assert에 Message 지정하기

    assert가 실패할 경우 다음과 같이 문자를 지정해 어떤 이유로 실패했는지를 직접 명시할 수 있다.

    def test_target_function():
        assert target_function(1, 2) == 4, "value is 4"
    

    위 테스트 코드는 다음과 같은 결과를 보여줍니다.

    =================================== short test summary info ====================================
    FAILED pytest_demo.py::test_target_function - AssertionError: value is 4
    

    명시하지 않은 경우는 다음과 같습니다.

    =================================== short test summary info ====================================
    FAILED pytest_demo.py::test_target_function - assert 3 == 4
    


    Exception을 테스트할 때는?

    테스트 대상 함수가 exception을 일으키는 경우 pytest에서는 다음과 같이 사용할 수 있습니다.

    def target_function_cause_zerodivsion(a, b):
        return a / 0 # ZeroDivisionError
    
    def test_target_function_cause_zerodivsion():
        with pytest.raises(ZeroDivisionError):
            assert target_function_cause_zerodivsion(1, 2)
    


    사전 동작과 사후 동작 설정하기

    어떤 테스트 코드에서 수행 전후로 동작해야 될 내용이 있다면 다음과 같이 사용할 수 있습니다.

    Class에서 사용하기

    class로 테스트 코드 작성 시 특정 메소드가 수행되기 전 ‘수행 전’, ‘수행 후’ 실행할 메서드를 지정할 수 있습니다.

    class TestSome:
    
        def setup_method(self):
            print("setup method")
    
        def test_method(self):
            print("test method")
            assert 1 == 2
    
        def teardown_method(self):
            print("teardown method")
    
    test_example.py F                                                                                                             [100%]
    
    ============================================================= FAILURES ==============================================================
    _______________________________________________________ TestSome.test_memthod _______________________________________________________
    
    self = <PytestExample.test_example.TestSome object at 0x1055937f0>
    
        def test_memthod(self):
    >       assert 1 == 2
    E       assert 1 == 2
    
    test_example.py:17: AssertionError
    ------------------------------------------------------- Captured stdout setup -------------------------------------------------------
    setup method
    ----------------------------------------------------- Captured stdout teardown ------------------------------------------------------
    teardown method
    ====================================================== short test summary info ======================================================
    FAILED test_example.py::TestSome::test_memthod - assert 1 == 2
    


    function에서 사용하기

    function으로 테스트 코드 작성시 xxx_method가 xxx_funtion으로 변경됩니다.

    def target_function(a: int, b: int):
        return a + b
    
    def setup_function():
        print("execute setup")
    
    def test_target_function():
        assert target_function(1, 2) == 4
    
    def teardown_function():
        print("execute teardown")
    


    View 테스트하기

    어떤 View를 테스트하고자 한다면 각 client fixture를 활용할 수 있습니다.

    def test_method(self, client):        
        response = client.get(path="",  data="")
        response = client.post(path="",  data="")
        response = client.put(path="",  data="")
        response = client.delete(path="",  data="")
    

    path에는 django view의 주소를 data에는 해당 view에서 처리해야될 입력 값을 json으로 입력해주면 됩니다. 예를 들면 다음과 같은 형태일 것입니다.

    def test_method(self, client):
        response = client.post(
            path=reverse("app:some_view_name"),
            data={'key1' :"value1"},
        )
        response_data = response.json()
    

    만약 어떤 view에 path paramter가 필요한 경우 다음과 같이 명시할 수 있습니다.

    def test_method(self, client):
        response = client.post(
            path=reverse(
                "app:some_view_name",
                kwargs={'path_parameter': 'value'}
            ),
            data={'key1': "value1"},
        )
        response_data = response.json()
    

     

     

     


    안녕하세요 jako입니다.

    해당 글을 통해 유용한 정보를 얻으셨길 바랍니다.

    경험과 지식의 공유를 통해 조금 더 양질의 정보를 생성하기위한 뉴스레터를 만들었습니다.

    블로그에는 기재되지 않을 유용한 정보 또한 뉴스레터에 담아 발행하고자합니다. 

     링크를 클릭하여 뉴스레터를 구독해주세요.

    양질의 정보와 함께 찾아뵙겠습니다.


     

    728x90
    반응형