Pytest Fixture 관련 공부

    Fixture

    Fixture는 테스트가 시작하기 전 필요한 인자를 제공하거나, 상태를 정의하는데 사용한다. 예를 들어 다음과 같은 역할에 사용할 수 있다. 

    • SUT를 생성과 협력 관계의 인스턴스를 미리 설정된 조건으로 생성하고 제공한다. 
    • SUT, 혹은 통제할 수 없는 외부 인스턴스의 행동을 미리 지정한다. (Mocking 형태)

     


    Basic Fixture 생성해보기

    class Fruit:
        def __init__(self, name):
            self.name = name
    
        def __eq__(self, other):
            return self.name == other.name
    
    @pytest.fixture
    def fruit_apple():
        return Fruit('apple')
    
    @pytest.fixture
    def fruit_banana():
        return Fruit('banana')
    
    @pytest.fixture
    def fruit_basket(fruit_apple, fruit_banana):
        return [fruit_apple, fruit_banana]
    
    
    def test_my_fruits_in_basket(fruit_apple, fruit_banana, fruit_basket):
        assert fruit_apple in fruit_basket
        assert fruit_banana in fruit_basket
    • Fixture를 이용해 필요한 인스턴스를 준비할 수 있다.
    • 심지어 Fixture끼리 의존 관계를 가지고 새로운 Fixture를 만들 수도 있다.
    • 테스트 실행 시점에 Fixture가 데코레이팅 된 함수의 이름과 같은 파라메터에 함수 실행 결과가 주입된다. 

    Basic Fixture를 생성해보면서 위와 같은 사실을 알 수 있다. 나는 여기서 한 가지 의문이 생겼다. 테스트 코드 내에서 함수를 직접 호출해서 인스턴스를 생성하는 것과 비교해, Fixture를 이용해 파라메터로 주입해주는 것은 어떤 이점을 가질 수 있을까? 여기까지는 그리 큰 이점은 없다고 봐도 될 것 같다. 

     


    Fixture는 어떻게 공급되는가?

    @pytest.fixture
    def fruit_apple():
        print('fruit_apple called.')
        return Fruit('apple')
    
    @pytest.fixture
    def fruit_banana():
        print('fruit_banana called.')
        return Fruit('banana')
    
    @pytest.fixture
    def fruit_basket(fruit_apple, fruit_banana):
        print('fruit_basket called.')
        return [fruit_apple, fruit_banana]
    
    
    def test_my_fruits_in_basket(fruit_apple, fruit_banana, fruit_basket):
        print('test1 called')
        assert fruit_apple in fruit_basket
        assert fruit_banana in fruit_basket
    
    def test_my_fruits_in_basket2(fruit_apple, fruit_banana, fruit_basket):
        print('test2 called')
        assert fruit_apple in fruit_basket
        assert fruit_banana in fruit_basket

    Pytest가 실행되기 직전 함수 시그니쳐에 있는 파라메터의 이름으로 Fixture를 검색한다. 검색된 함수를 실행하고, 실행 결과를 파라메터로 넣어주게 된다. 

    test_basic_fixture.py::test_my_fruits_in_basket 
    
    fruit_apple called.
    fruit_banana called.
    fruit_basket called.
    test1 called
    PASSED
    test_basic_fixture.py::test_my_fruits_in_basket2 
    
    fruit_apple called.
    fruit_banana called.
    fruit_basket called.
    test2 called
    PASSED

    선언된 Fixture와 테스트 코드가 다음과 같을 때, 테스트를 실행하면 결과는 위와 같다. 테스트 결과는 다음을 보여준다.

    1. 매 테스트 케이스가 실행될 때 마다 Fixture로 선언된 함수가 호출되었다. 
    2. 한번 생성된 Fixture 함수는 다시 호출되지는 않는다.

    이것은 여러 Fixture 함수에서 특정 Fixture가 여러 번 거론되더라도, 단 하나의 인스턴스만 생성되어서 데이터가 주입되는 것을 의미한다. 

    Fixture는 이런 식으로 fixturefunc, request를 인자로 전달해 Fixture Function을 호출하고 그 결과를 반환한다. 

     


    Auto-use Fixture

    Auto-use Fixture는 모든 테스트 케이스가 자동으로 Fixture 실행을 요청하도록 하는 Fixture다.  일반적으로 Fixture는 함수 시그니처에 명시적으로 Fixture의 이름을 표현함으로써 요청(Request)한다. 그러나 Auto Use Fixture의 경우, 함수 시그니처에 명시적으로 선언하지 않더라도 해당 Fixture 함수가 모든 테스트가 실행되는 시점에 실행된다. 아래 예제 코드를 살펴보면 의미를 명확히 이해할 수 있다.

    @pytest.fixture
    def empty_list():
        return []
    
    @pytest.fixture(autouse=True)
    def append_number(empty_list):
        return empty_list.append(10)
    
    
    def test_hello_test(empty_list):
        assert len(empty_list) > 0
        assert 10 in empty_list
    • test_hello_test() 함수에서는 append_number라는 Fixture를 선언하지 않았다.
      • append_number가 AutoUse Fixture가 아니라면 empty_list Fixture는 []이 된다.
      • append_number가 AutoUse Fixture이기 때문에 empty_list Fixture는 [10]이 된다. 

     


    Fixture Error

    Fixture는 서로 의존관계를 가지며 새로운 Fixture를 만들 수 있다. 그런데 테스트 시작 전, Fixture 함수들이 실행되다가 실패하는 경우가 있다. 예를 들어 아래처럼 ZeroDevisionError가 발생할 경우, 테스트는 어떻게 될까?

    import pytest
    
    
    @pytest.fixture
    def fixture_a():
        return 1
    
    @pytest.fixture
    def fixture_b(fixture_a):
        _a = 1/0
        return 2
    
    def test_fixture_termination(fixture_b):
        assert 1 == 1

    아래 이미지에서 볼 수 있듯이, 테스트 결과는 '실패'로 나온다. 테스트가 실패했다는 것이라기 보다는 'Fixture'를 빌딩하는데 실패했다는 의미다. 

    이처럼 특정 테스트를 실행하는데 많은 Fixture과 관련되어 복잡해지면 Fixture 빌딩 과정에서 에러가 발생할 수 있다. 이 경우 '테스트가 실패했는지' 정확히 알 수 없다. 그냥 Fixture를 생성하는데 실패했기 때문에 Fixture가 정상적으로 생성되었을 때, 테스트에 문제가 있는지 없는지는 알 수 없다. 

    여기서 중요한 부분은 다음과 같다.

    1. 테스트에 Fixture 의존 관계가 복잡해질수록 Fixture 빌드에 실패할 가능성이 높아진다.
    2. Fixture 빌드에 실패하면 테스트의 성공 유무와 관계없이 항상 테스트는 False가 된다. 가성 False가 많아진다. 

    따라서 각 테스트 케이스마다 최소한의 Fixture 연관관계를 가지는 것이 중요해진다. 

     


    Fixture의 Scope

    Fixture는 총 5개의 Scope을 가지고, 각 Scope의 대소관계는 아래와 같다. 

    Function < Class < Module < Package < Session 
    • Function : 각 테스트 함수마다 Fixture 생성
    • Class : 각 테스트 클래스마다 Fixture 생성
    • Module : 각 테스트 모듈마다 Fixture 생성
    • Package : 각 테스트 패키지마다 Fixture 생성
    • Session : 테스트 실행 세션 전체에 Fixture 생성. 

    Module Scope은 test_auto_use_fixture.py 처럼 하나의 파이썬 모듈을 의미하고, Package Scope은 fixture-test라는 패키지 전체를 의미한다. 그리고 Session Scope은 py.test로 테스트를 실행했을 때, 단 하나의 Fixture만 생성되는 것을 의미한다. 

    test_scope.py::TestOne::test_one PASSED                                  [ 50%]
    
    id(class_scope)=4365688720 
    id(session_scope)=4365395152 
    id(function_scope)=4365688272 
    id(package_scope)=4365689296
    id(module_scope)=4365689936
    
    test_scope.py::TestOne::test_two PASSED                                  [100%]
    
    id(class_scope)=4365688720 
    id(session_scope)=4365395152 
    id(function_scope)=4365692176 
    id(package_scope)=4365689296
    id(module_scope)=4365689936

    실제로 Fixture Scope 별로 ID를 찍어보면 가장 작은 Function Scope만 서로 다른 인스턴스가 생긴 것을 확인할 수 있다.

     


    TearDown Fixture

    생성된 Fixture가 다음 테스트 코드에 영향을 주지 않도록 자원을 안전하게 정리하는 것도 필요하다. Fixture의 자원을 제거하는 방법은 두 가지가 있다.

    1. yield를 이용한 Fixture 생성 -> yield 이후에 TearDown 코드 작성
    2. Finalizer를 추가 -> 가급적 사용 X

    가급적이면 Finalizer보다 yield를 사용하는 것이 깔끔한 방법이다. Fixture를 생성하는 도중에 예외가 발생하더라도, Finalizer는 반드시 Fixture의 TearDown을 시도하기 때문이다. 

    import pytest
    
    def print_f(msg):
        print('fixture_' + msg)
    
    @pytest.fixture
    def fixture_a():
        print_f('a_before')
        yield 1 
        print_f('a_tear_down') # Tear Down하는 부분
    
    def test_fixture_termination(fixture_a):
        assert 1

    위 예시코드처럼 yield 키워드에서 Fixture 결과를 반환한다. 그리고 테스트가 끝날 때에는 yield 뒷쪽의 코드가 실행되므로, yield문 뒤에 Tear Down 코드를 작성해주면 된다.

     

    Fixture 간의 Tear Down 순서

    Fixture의 Tear Down 순서는 호출 순서의 정반대로 진행된다. 예를 들어 Fixture 생성 순서가 A -> B -> C라면, Tear Down 순서는 C -> B -> A가 된다. 

     


    안전한 Fixture Structure

    안전한 Fixture 구조의 핵심은 테스트 전/후로 State의 변경이 없도록 하는 것이다. 따라서 최소한 아래 세 가지를 고려해서 Fixture 코드를 작성해야한다. 

    1. 하나의 Fixture 함수에서는 하나의 State만 변경한다. 
    2. Fixture 함수마다 반드시 변경한 State를 원래대로 돌리는 TearDown 영역을 작성한다. 
    3. 실패할 여지가 있는 Fixture 함수는 다른 성공적인 Fixture 함수와 분리해야한다. 그렇게 작성하면 특정 Fixture 함수가 실패하더라도, 앞서 실행되었던 성공적인 Fixture 함수는 정상적으로 TearDown된다. 

    예를 들어 다음 Step의 Fixture를 준비한다고 해보자.

    1. Admin API를 이용해 User 생성
    2. 셀레니움을 이용해 브라우저를 띄움.
    3. 로그인 페이지로 이동
    4. 생성된 User로 로그인
    5. 랜딩 페이지의 이름을 검증.

    각각을 하나의 Fixture 함수로 만들고, 각각 Tear Down을 추가하면 안정적인 Fixture 구조를 만들 수 있다. Admin API로 생성된 User를 Fixture로 제공하고, 테스트가 끝났을 때 User를 삭제하는 Tear Down 구문을 추가하면 된다. Fixture Dependency 특성상 1 -> 2 -> 3 -> ... 순서로 호출이 될 것이고, 만약 Fixture 2번이 생성 도중에 실패했다하더라도 1번의 Fixture는 정상적으로 Tear Down 될 것이다. 

     


    Fixture Sharing

    하나의 패키지, 모듈 안에서 Fixture는 공유해서 사용할 수 있다. 아래 코드를 살펴보자.

    tests/
        __init__.py
    
        conftest.py
            # content of tests/conftest.py
            import pytest
    
            @pytest.fixture
            def order():
                return []
    
            @pytest.fixture
            def top(order, innermost):
                order.append("top")
    
        test_top.py
            # content of tests/test_top.py
            import pytest
    
            @pytest.fixture
            def innermost(order):
                order.append("innermost top")
    
            def test_order(order, top):
                assert order == ["innermost top", "top"]
    
        subpackage/
            __init__.py
    
            conftest.py
                # content of tests/subpackage/conftest.py
                import pytest
    
                @pytest.fixture
                def mid(order):
                    order.append("mid subpackage")
    
            test_subpackage.py
                # content of tests/subpackage/test_subpackage.py
                import pytest
    
                @pytest.fixture
                def innermost(order, mid):
                    order.append("innermost subpackage")
    
                def test_order(order, top):
                    assert order == ["mid subpackage", "innermost subpackage", "top"]

    test/conftest.py에서 선언한 Fixture는 하위 패키지인 subpackage에서도 동일하게 사용할 수 있다. 예를 들면 test/conftest.py에 order라는 Fixture를 선언하면, 하위 패키지인 tests/subpackage/conftest.py에서도 사용할 수 있게 된다. 

    테스트는 위쪽(원 바깥쪽)으로 올라가서 픽스처를 검색할 수 있지만 아래쪽(원 안쪽)으로 내려가서 검색을 계속할 수는 없다. 따라서 tests/subpackage/test_subpackage.py::test_order는 tests/subpackage/test_subpackage.py에 정의된 가장 안쪽 픽스처를 찾을 수 있지만, tests/test_top.py에 정의된 것은 한 단계 내려가야(원 안쪽 단계) 찾을 수 있기 때문에 사용할 수 없게 될 것이다.

     


    Fixture 인스턴스화 순서

    Fixture가 인스턴스화 되는 순서는 다음 세 가지 Factor만 의존하고, 나머지는 랜덤하다. 따라서 Fixture의 생성 순서를 지정해야하는 요구사항이 있다면 아래 세 가지를 참고해서 만들어야 한다. 그 외에는 Fixture의 순서를 생각하지 않도록 하는 것이 좋다. 

    1. Scope
    2. Dependencies
    3. autouse

    Fixture끼리 Dependency를 가진다면, 참조되는 Fixture의 Scope가 더 넓어서 미리 생성되어야 한다. 그렇지 않으면 Scope Mismatch Exception이 발생한다. 

    여기에는 몇 가지 참고할만한 원칙이 있다.

    1. 넓은 Scope의 Fixture가 먼저 실행됨.
    2. 같은 Scope의 Fixture라면 Dependency를 참고해 실행됨.
    3. AutoUse Fixture는 해당 Scope에서 가장 먼저 실행됨. 
    4. AutoUse Fixture가 참조하는 Fixture는 Autouse가 아니더라도, 사실상 AutoUse Fixture처럼 동작함. (아래에서 자세히 설명)

     

    Scope

    Scope 관점에서 인스턴스가 생성되는 순서는 다음과 같다. 아래 코드를 확인해보면 

    session 
    package 
    module 
    class 
    function
    
    
    class Hello:
        pass
    
    @pytest.fixture(scope="session")
    def my_order_fixture():
        print('my_order_fixture')
        return []
    
    @pytest.fixture(scope="class")
    def class_scope(my_order_fixture):
        my_order_fixture.append('class')
        return Hello()
    
    @pytest.fixture(scope="session")
    def session_scope(my_order_fixture):
        my_order_fixture.append('session')
        return Hello()
    
    @pytest.fixture(scope="function")
    def function_scope(my_order_fixture):
        my_order_fixture.append('function')
        return Hello()
    
    @pytest.fixture(scope="package")
    def package_scope(my_order_fixture):
        my_order_fixture.append('package')
        return Hello()
    
    @pytest.fixture(scope="module")
    def module_scope(my_order_fixture):
        my_order_fixture.append('module')
        return Hello()
    
    def test_anything(
            class_scope,
            session_scope,
            function_scope,
            package_scope,
            module_scope,
            my_order_fixture
    ):
    
        for k in my_order_fixture:
            print(f'{k} \n')
        assert 1 == 1

     


    Fixture Scope / Dependency

    만약 Fixture끼리 Dependency를 가진다면, Scope도 잘 맞춰줘야한다. 예를 들어 B Fixture가 A Fixture가 필요하다고 가정하자. 그리고 B Fixture는 Session Scope, A Fixture는 Function Scope인 경우라면 Scope Missmatch 에러가 발생한다.

    class Hello: pass
    
    @pytest.fixture(scope="class")
    def fixture_a():
        return Hello()
    
    @pytest.fixture(scope="session")
    def fixture_b(fixture_a):
        return Hello()
    
    def test_anything(fixture_a, fixture_b):
        assert 1 == 1

    위 테스트 코드가 하나의 예시가 될 수 있다. 

    이 코드를 실행하면 ScopeMismatch 에러가 발생하는 것을 확인할 수 있다. 

     


    AutoUse Fixture가 참고하는 Fixture는 AutoUse Fixture처럼 동작함.

    AutoUse Fixture가 참조하는 Fixture는 같은 Test Scope 내에서는 AutoUse Scope처럼 동작하지만, 다른 Test Scope에서는 그렇게 동작하지 않는다. 아래 코드를 예시로 이해할 수 있다. 

    import pytest
    
    
    @pytest.fixture
    def order():
        return []
    
    
    @pytest.fixture
    def c1(order):
        order.append("c1")
    
    
    @pytest.fixture
    def c2(order):
        order.append("c2")
    
    
    class TestClassWithAutouse:
        @pytest.fixture(autouse=True)
        def c3(self, order, c2):
            order.append("c3")
    
        def test_req(self, order, c1):
            assert order == ["c2", "c3", "c1"]
    
        def test_no_req(self, order):
            assert order == ["c2", "c3"]
    
    
    class TestClassWithoutAutouse:
        def test_req(self, order, c1):
            assert order == ["c1"]
    
        def test_no_req(self, order):
            assert order == []

    위 테스트 코드는 아래 구조로 볼 수 있다.

    1. TestClassWithAutoUse 클래스 내에서는 c3 autoUse가 존재하고, c3가 c2를 참조하므로 c2도 autoUse처럼 동작함. 
    2. TestClassWithouAutoUse 클래스 내에는 autoUse가 존재하지 않으므로, c2는 autoUse처럼 동작하지 않음. 

     

     

     

     


    Parameterized Fixture

    pytest가 제공하는 BuiltIn fixture에는 request Fixture가 있다. Request Fixture는 Pytest에서 사용되는 RequestContext에 접근할 수 있도록 해준다. 이 Fixture를 활용하면 Fixture 자체도 Parameterized 형태로 사용할 수 있다.

    @pytest.fixture(params=[1, 2, 3, 4, 5])
    def my_fixture(request):
        print(f'{request.param=}')
        return request.param
    
    
    def test_hello(my_fixture):
        print('\n')
        print(f'{my_fixture=}')

    fixture를 선언할 때 params에 값을 전달해줄 수 있는데, params에 들어간 값이 request Fixture의 param으로 들어온다. 그리고 Params에 선언된 순서대로 Iteration하면서 Fixture가 생성된다.

    '''
    collecting ... collected 5 items
    
    test_request_fixture.py::test_hello[1] 
    request.param=1
    PASSED                            [ 20%]
    my_fixture=1
    
    test_request_fixture.py::test_hello[2] 
    request.param=2
    PASSED                            [ 40%]
    my_fixture=2
    
    test_request_fixture.py::test_hello[3] 
    request.param=3
    PASSED                            [ 60%]
    my_fixture=3
    
    test_request_fixture.py::test_hello[4] 
    request.param=4
    PASSED                            [ 80%]
    my_fixture=4
    
    test_request_fixture.py::test_hello[5] 
    request.param=5
    PASSED                            [100%]
    my_fixture=5
    '''

    위 테스트 결과를 살펴보면 하나의 테스트지만, 다음과 같이 Parameterized 되었지만 값이 하나씩 들어오며 테스트 코드가 Params의 갯수만큼 실행된 것을 알 수 있다. 

     


    참고

    댓글

    Designed by JB FACTORY