pytest - 효율적인 테스트 코드 작성을 위한 모듈
안녕하세요 한헌종입니다.
오늘은 python 에서 테스트 코드를 작성할 때 자주 쓰이는 pytest 를 정리해볼 거에요.
간단한 예제들을 위주로 설명해보도록 할게요.
pytest 가 뭔가요?
pytest 는 테스트 코드를 효율적으로 작성할 수 있게 해주는 모듈입니다.
테스트에 필요한 여러가지 다양한 기능들이 있어서 저도 애용하고 있는 모듈이죠.
테스트에 쓰이는 다른 모듈로는 unittest 같은 모듈도 좋지만 pytest 는 좀더 쉽고 다양한 기능을 제공합니다.
pytest 는 다음과 같이 설치할 수 있습니다.
$ python -m pip install pytest
pytest 로는 어떤걸 할 수 있나요?
pytest 는 기본적인 테스트 기능에 추가적으로 다음 기능들이 제공됩니다:
- parametrization 을 통한 여러 테스트 케이스 관리
- exception 에 대한 테스트
- fixture 를 사용한 object 제공으로 dependency 해결 및 간단한 코드 작성
- 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 에 수월하게 입문하셨길 바랍니다.
그럼 다음시간에 만나요!