안녕하세요 한헌종입니다.
오늘은 python 에서 테스트 코드를 작성할 때 자주 쓰이는 pytest 를 정리해볼 거에요.
간단한 예제들을 위주로 설명해보도록 할게요.


pytest 가 뭔가요?

pytest 는 테스트 코드를 효율적으로 작성할 수 있게 해주는 모듈입니다.
테스트에 필요한 여러가지 다양한 기능들이 있어서 저도 애용하고 있는 모듈이죠.
테스트에 쓰이는 다른 모듈로는 unittest 같은 모듈도 좋지만 pytest 는 좀더 쉽고 다양한 기능을 제공합니다.

pytest 는 다음과 같이 설치할 수 있습니다.

$ python -m pip install pytest

pytest 로는 어떤걸 할 수 있나요?

pytest 는 기본적인 테스트 기능에 추가적으로 다음 기능들이 제공됩니다:

  1. parametrization 을 통한 여러 테스트 케이스 관리
  2. exception 에 대한 테스트
  3. fixture 를 사용한 object 제공으로 dependency 해결 및 간단한 코드 작성
  4. mark 를 통한 테스트 함수 관리

아래에서 어떤 기능들인지, 뭐가 가능하다는 건지 하나씩 확인해볼게요.


간단한 테스트 코드

먼저 pytest 로 어떻게 파이썬 코드를 테스트 할 수 있는지 알아볼게요.
test_1.py 라는 코드를 작성해볼까요?

# test_1.py
import pytest

def test_get_sum():
    num1 = 3
    num2 = 5
    expected = 8
    assert expected == get_sum(num1, num2)

def get_sum(num1: int, num2: int) -> int:
    return num1 + num2

먼저 테스트하고자 하는 함수 get_sum 을 만들었습니다.
두 숫자를 받아서 이들의 합을 반환하는 아주 간단한 함수죠.
이 함수가 제대로 작동하는지 테스트하는게 목표입니다.

그 위에는 이 함수를 테스트할 수 있는 테스트 함수인 test_get_sum 을 만들었어요.
pytest 를 사용할 때, 테스트 함수는 꼭 앞에 test_ 로 시작하거나 뒤에 _test 로 끝나야 합니다. (그래야 pytest 가 테스트 함수라고 인식해요)
테스트 코드 안에서는 assert 를 사용해서 실제 함수의 반환값이 예상되는 값과 같은지를 확인합니다.

위 코드를 작성하고 아래와 같이 코드를 실행하면 됩니다.

$ pytest test_1.py
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/.../temp_pytest
collected 1 item                                                      

test_1.py .                                                    [100%]

========================== 1 passed in 0.01s =========================

꽤 간단하게 테스트 코드를 짤 수 있었습니다.
결과를 보면 test_1.py 라는 함수에 “.” 점 하나가 나왔죠?
이는 이 테스트 코드에서 테스트 하나를 발견했고, 그게 통과(“.”) 했다는 뜻입니다.
100% 는 해당 코드의 모든 테스트 함수를 실행했다는 뜻이에요.
그리고 테스트 1개가 pass 즉 통과했고, 0.01초가 걸렸다고 나오고 있군요.

위에서는 pytest [테스트 코드 이름] 으로 실행했는데, 사실 다른 방법으로도 실행할 수 있어요.
아래처럼 python -m pytest [테스트 코드 이름] 으로 실행 가능하죠.
만약 테스트 코드의 이름이 test_~~~.py 혹은 ~~~_test.py 라는 식으로 지었다면, 코드 이름이 아니라 ‘위치’ 만 지정해줘도 됩니다.
그러면 해당 ‘위치’에 있는 모든 테스트 코드가 다 실행됩니다.

$ python -m pytest test_1.py 
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/.../temp_pytest
collected 1 item                                                      

test_1.py .                                                     [100%]

========================== 1 passed in 0.01s ==========================

$ pytest .
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/.../temp_pytest
collected 2 items                                                     

some_test.py .                                                  [ 50%]
test_1.py .                                                     [100%]

========================== 2 passed in 0.01s ==========================

parametrize: 여러 테스트 케이스를 한번에 작성하는 방법

parametrize 라는 기능을 살펴볼까요?
여기서 만든 get_sum 이라는 함수를 예로 들어볼게요.
get_sum 함수가 잘 작동하는지 여러가지 경우의 수를 테스트해보고 싶은 상황이에요.
만약 위와 같은 식으로 테스트 코드를 작성한다고 하면, 각 케이스마다 함수를 하나씩 다 작성해야 할까요?
그럼 너무 비효율적이겠죠.

pytest 에는 parametrize 라는 기능이 있어서, 테스트 케이스를 여러번 쓸 필요 없이 한 함수에 여러 테스트 케이스를 적용해볼 수 있답니다.
다음과 같이 작성하면 됩니다.

# test_2.py
import pytest

@pytest.mark.parametrize(
    "num1,num2,expected",
    [
        (1, 2, 3),
        (2, 3, 5),
        (10, 15, 25),
        ("1", "2", 3),
    ],
)
def test_get_sum(num1, num2, expected):
    assert expected == get_sum(num1, num2)

def get_sum(num1: int, num2: int) -> int:
    return num1 + num2

테스트 함수 위에 @pytest.mark.parametrize 라는 데코레이터를 사용하면 됩니다.
그리고 내부에는 두 가지 인자를 넣으면 되는데요,
첫 번째 인자를 보면 num1, num2, expected 라는 이름들로, 테스트 함수 내에서 쓰이게 될 변수의 이름이 적혀 있어요.
두 번째 인자는 “테스트 케이스” 들이 리스트로 들어가 있는 것입니다.
마지막으로, test_get_sum 의 인자로 해당 num1, num2, expected 를 넣어주면 됩니다.

이 코드를 해석해보자면 다음과 같습니다.
첫 번째 테스트에는 num1 = 1, num2 = 2, expected = 3 으로 테스트하고,
두 번째 테스트에는 num1 = 2, num2 = 3, expected = 5 으로 테스트하고,
세 번째 테스트에는 num1 = 10, num2 = 15, expected = 25 으로 테스트하고,
네 번째 테스트에는 num1 = “1”, num2 = “2”, expected = 3 으로 테스트하자.

이를 실행해보면 다음과 같은 결과가 나옵니다:

$ pytest test_2.py
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/.../temp_pytest
collected 4 items                                                     

test_2.py ...F                                                  [100%]

============================== FAILURES ===============================
________________________ test_get_sum[1-2-31] _________________________

num1 = '1', num2 = '2', expected = 3

    @pytest.mark.parametrize(
        "num1,num2,expected",
        [
            (1, 2, 3),
            (2, 3, 5),
            (10, 15, 25),
            ("1", "2", 3),
        ],
    )
    def test_get_sum(num1, num2, expected):
>       assert expected == get_sum(num1, num2)
E       AssertionError: assert 3 == '12'
E        +  where '12' = get_sum('1', '2')

test_2.py:14: AssertionError
======================= short test summary info =======================
FAILED test_2.py::test_get_sum[1-2-31] - AssertionError: assert 3 ==...
===================== 1 failed, 3 passed in 0.16s =====================

테스트 결과를 보면 “…F” 라고 되어 있죠?
이는 테스트 네 개 중 세개는 통과 (“.”) 되었고, 하나는 실패 (“F”) 했다는 뜻입니다.

그리고 아래쪽에는 실패한 케이스에 대한 설명이 나오고 있네요.
인자로 “1” 과 “2” 를 넣었더니, 당연하게도 문자로 인식되어서 “+” 의 결과가 “12” 가 되었고, 이는 기대하고 있는 값인 3 과는 다르다~ 라는 설명이 나와있습니다.

이런식으로 함수 하나에 대해 여러 테스트 케이스를 실행할 때는 parametrize 를 사용하면 훨씬 간편하답니다.
테스트 케이스가 몇십 개가 되더라도 효율적으로 테스트 함수를 작성할 수 있겠죠?


pytest.raises: exception 에 대한 테스트를 작성하는 방법

테스트를 하다 보면 exception 에 대한 테스트를 해야 할 때가 있습니다.
위의 get_sum 을 예로 들면, “숫자” 가 아닌 경우에는 exception 을 제기해야 하는 바로 그 경우죠.
이런 경우 우리가 원하는 exception이 제대로 나오는지 확인할 수 있을까요?

pytest 는 이를 테스트할 수 있는 간단한 방법을 제공합니다.
다음 코드를 보시죠:

# test_3.py
import pytest

def test_get_sum():
    num1 = "1"
    num2 = 2
    expected = 3
    with pytest.raises(TypeError):
        assert expected == get_sum(num1, num2)

def get_sum(num1: int, num2: int) -> int:
    if not isinstance(num1, int) or not isinstance(num2, int):
        raise TypeError
    return num1 + num2

새로 만든 get_sum 함수는 두 인자가 모두 int 타입인지 확인하는 단계가 추가됐어요.
만약 두 인자 중 하나라도 int 타입이 아니라면 TypeError 를 내게 되어 있죠.
이 TypeError 가 제대로 나는지 확인하기 위해 test_get_sum_raise_type_error 를 작성했습니다.
with pytest.raises([exception 종류]): 라는 구문은 이 구문 안의 함수가 지정해준 exception 을 내는지를 확인해줍니다.
만약 지정해준 exception 이 아닌 다른 exception 을 낸다면 fail 이 뜰겁니다.

한번 확인해볼까요?

$ pytest test_3.py       
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/.../temp_pytest
collected 1 item                                                      

test_3.py .                                                     [100%]

========================== 1 passed in 0.01s ==========================

만약 다른 종류의 exception 을 기대한다면 어떻게 될까요?
다음 코드로 테스트 해볼게요.

# test_4.py
import pytest

def test_get_sum():
    num1 = "1"
    num2 = 2
    expected = 3
    with pytest.raises(KeyError):
        assert expected == get_sum(num1, num2)

def get_sum(num1: int, num2: int) -> int:
    if not isinstance(num1, int) or not isinstance(num2, int):
        raise TypeError
    return num1 + num2

이 코드는 KeyError 가 나는지 확인하도록 되어있죠?
코드를 실행해보면 다음과 같이 나올 거에요:

$ pytest test_4.py
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/.../temp_pytest
collected 1 item                                                      

test_4.py F                                                     [100%]

============================== FAILURES ===============================
____________________________ test_get_sum _____________________________

    def test_get_sum():
        num1 = "1"
        num2 = 2
        expected = 3
        with pytest.raises(KeyError):
>           assert expected == get_sum(num1, num2)

test_4.py:10: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

num1 = '1', num2 = 2

    def get_sum(num1: int, num2: int) -> int:
        if not isinstance(num1, int) or not isinstance(num2, int):
>           raise TypeError
E           TypeError

test_4.py:15: TypeError
======================= short test summary info =======================
FAILED test_4.py::test_get_sum - TypeError
========================== 1 failed in 0.14s ==========================

코드에 적어놓은 KeyError 가 아니라 TypeError 가 났다고 하네요.
이렇게 pytest.raises 를 사용하면 원하는 함수의 exception 을 실제로 확인해볼 수 있습니다.


fixture: 여러 테스트 함수에서 반복적으로 쓰이는 데이터를 관리하는 방법

여러 테스트 함수에 특정 데이터가 반복적으로 쓰이는 경우가 있죠?
이렇게 반복적으로 쓰이는 걸 매번 테스트 함수에서 정의하려면 번거로울 수 있어요.

pytest 에서는 이를 관리할 수 있는 fixture 라는 기능을 제공합니다.
fixture 를 사용하면 여러 테스트 함수에서 사용되는 공통된 인자를 관리할 수 있습니다.
(parametrize 가 ‘공통된 테스트 함수에서 사용되는 여러 인자’ 를 관리하는 기능인 것과 대조되는군요)
fixture 를 사용하면 데이터 간의 dependency 도 쉽게 관리할 수 있습니다.

다음 예제로 쉽게 설명해볼게요.
나이 정보를 담고있는 dictionary 형태의 데이터를 반복적으로 쓰는 함수들을 테스트하는 상황입니다.

# test_5.py
import pytest

def test_sum_age():
    age_data = {"Will": 32, "Kay": 28, "Derrick": 26, "Ken": 36}
    expected = 122
    assert expected == sum_age(age_data)

def test_get_age_by_name():
    age_data = {"Will": 32, "Kay": 28, "Derrick": 26, "Ken": 36}
    name = "Ken"
    expected = 36
    assert expected == get_age_by_name(age_data, name)

def sum_age(age_data: dict) -> int:
    return sum([age for age in age_data.values()])

def get_age_by_name(age_data: dict, name: str) -> int:
    return age_data[name]

위 코드를 보면, sum_age 함수와 get_age_by_name 함수 모두 age_data 를 공통적으로 쓰고 있군요.
그런데 만약 age_data 가 매우 크다면 반복적으로 쓰는게 번거로울 수 있겠죠? 코드도 길어지구요.
그리고 혹시나 반복적으로 작성하면서 실수가 생길 수도 있구요.

이를 fixture 로 한번 해결해봅시다. 다음 코드를 한번 보세요.

# test_6.py
import pytest

@pytest.fixture()
def age_data():
    return {"Will": 32, "Kay": 28, "Derrick": 26, "Ken": 36}

def test_sum_age(age_data):
    expected = 122
    assert expected == sum_age(age_data)

def test_get_age_by_name(age_data):
    name = "Ken"
    expected = 36
    assert expected == get_age_by_name(age_data, name)

def sum_age(age_data: dict) -> int:
    return sum([age for age in age_data.values()])

def get_age_by_name(age_data: dict, name: str) -> int:
    return age_data[name]

맨 위 함수를 보면, @pytest.fixture 라는 데코레이터를 써서 age_data 를 만들었습니다.
이 함수의 반환값은 이 함수의 이름으로 접근할 수 있게 됩니다.
이제 이 코드의 모든 테스트 함수들은 age_data 를 인자로 받아서 쓰면 되는 거죠. (마치 global 변수처럼요)
한번만 만들어 두면 여러 함수에서 쓰일 수 있고, 이렇게 만든 데이터는 어디에서나 동일할 것이기 때문에 안심하고 효율적으로 테스트를 작성할 수 있게 된 거에요.
또한, 만약 이렇게 만들어야 하는 데이터가 오랜 시간이 걸리는 것이라면, 한 번만 만들어두는게 여러 테스트에서 사용하기에 효율적이겠죠?
fixture 를 사용하면 이렇게 여러 방면에서 효율적으로 쓸 수 있습니다!


mark: 테스트 함수에 표시를 남겨서 테스트할 함수를 지정하는 방법

여러 테스트 함수를 작성하다 보면, 이중 특정 테스트 함수만 확인하고 싶을 때가 있습니다.
pytest.mark 를 사용하면 이런 과정을 좀 더 효율적으로 할 수 있습니다.
다음 코드를 한번 확인해보세요:

# test_7.py
import pytest

@pytest.mark.group1
def test_get_sum_1():
    assert 3 == get_sum(1, 2)

@pytest.mark.group1
def test_get_sum_2():
    assert 7 == get_sum(3, 4)

@pytest.mark.group2
def test_get_sum_3():
    with pytest.raises(TypeError):
        assert 3 == get_sum("1", 2)

@pytest.mark.group2
def test_get_sum_4():
    with pytest.raises(TypeError):
        assert 3 == get_sum(1, "2")

def get_sum(num1: int, num2: int) -> int:
    if not isinstance(num1, int) or not isinstance(num2, int):
        raise TypeError
    return num1 + num2

위 코드를 보면 테스트 함수가 4개 있습니다.
각 테스트 함수의 위에는 @pytest.mark.[마크 이름] 이 적혀있구요.
이 mark 를 지정해두면 테스트 코드를 실행시킬 때 특정 mark 에 해당하는 것들만 진행할 수 있습니다.
다음과 같이 실행합니다.

$ pytest test_7.py -m group1
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/.../temp_pytest, configfile: pytest.ini
collected 4 items / 2 deselected / 2 selected                         

test_7.py ..                                                    [100%]

=================== 2 passed, 2 deselected in 0.01s ===================

pytest 를 실행할 때 -m 옵션을 주고 특정 mark “group1” 을 지정했어요.
이렇게 하면 group1 이라는 mark 가 있는 테스트 함수만 실행됩니다.
결과를 보시면 4 개의 테스트를 찾았는데, 이 중 2 개만 선택되었다는 알림이 나옵니다.
이런식으로 특정 테스트 함수만 실행하는 것이 가능한거죠!

또한, 특정 함수를 스킵하는 것도 가능합니다.
@pytest.mark.skip 을 데코레이터로 쓰면 됩니다.
다음 코드를 보시죠.

# test_8.py
import pytest

def test_get_sum_1():
    assert 3 == get_sum(1, 2)

def test_get_sum_2():
    assert 7 == get_sum(3, 4)

@pytest.mark.skip
def test_get_sum_3():
    assert 3 == get_sum("1", 2)

def get_sum(num1: int, num2: int) -> int:
    if not isinstance(num1, int) or not isinstance(num2, int):
        raise TypeError
    return num1 + num2

총 3 개의 테스트 함수가 있는데, 이 중 세 번째 함수 위에 @pytest.mark.skip 을 달아놨어요.
이제 이 함수는 테스트에서 빠질 거고, 나머지 두 개만 실행될겁니다.
실행 장면을 한번 확인해보세요:

pytest test_8.py
========================= test session starts =========================
platform darwin -- Python 3.7.3, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/hanheonjong/Documents/Study/temp_pytest, configfile: pytest.ini
collected 3 items                                                     

test_8.py ..s                                                   [100%]

==================== 2 passed, 1 skipped in 0.01s =====================

결과를 보시면, 세 개의 테스트를 찾았고, 이 중 1 개는 skipped 했다고 나오죠?
이런식으로 mark 를 잘 사용하면 효율적으로 테스트 코드를 사용하는게 가능합니다!


네, 오늘은 pytest 를 사용한 테스트 코드 작성하는 것을 아주 간단하게 알아봤습니다.
좀 더 깊이 들어가면 pytest 를 사용해 훨씬 복잡한 테스트를 작성하는 게 가능합니다.
pytest 문서를 한번 확인해보시면 더욱 이해가 잘 되실 거에요!

제가 작성한 글을 통해 pytest 에 수월하게 입문하셨길 바랍니다.
그럼 다음시간에 만나요!