pytest 파라미터화로 같은 테스트를 여러 입력으로 돌리기

pytest 파라미터화로 같은 테스트를 여러 입력으로 돌리기

같은 로직을 여러 가지 입력에 대해 검증해야 할 때, 테스트 함수를 복사해서 입력만 바꾸는 건 금방 지저분해져요. pytest는 하나의 테스트 함수를 여러 파라미터 조합으로 반복 실행하는 파라미터화를 지원해서, 입력 목록만 늘리면 같은 코드로 모든 경우를 검증할 수 있어요. 여기서는 @pytest.mark.parametrize로 테스트 함수를 파라미터화하는 방법과, 커스텀 파라미터화를 위한 pytest_generate_tests 훅까지 살펴볼게요.

출처: pytest 공식 문서 - How to parametrize fixtures and test functions

@pytest.mark.parametrize: 테스트 함수 파라미터화

pytest는 여러 수준에서 파라미터화를 지원해요. 픽스처 함수는 pytest.fixture()params로, 테스트 함수는 @pytest.mark.parametrize 데코레이터로, 그리고 완전히 자신만의 방식이 필요하면 pytest_generate_tests 훅으로 구현할 수 있어요.

가장 흔한 경우는 @pytest.mark.parametrize로 테스트 함수에 입력·기대값 쌍을 여러 개 넘기는 거예요. 아래 테스트는 입력 문자열이 기대값으로 평가되는지 확인해요.

# content of test_expectation.py
import pytest


@pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
def test_eval(test_input, expected):
    assert eval(test_input) == expected

이 데코레이터가 (test_input, expected) 튜플 세 개를 정의하므로, test_eval은 그 셋을 순서대로 써서 세 번 실행돼요. 실행해 보면 세 값 중 마지막 ("6*9", 42)만 실패하는 걸 확인할 수 있어요. 실패한 경우가 무엇인지는 테스트 아이디(test_eval[6*9-42])와 traceback에 찍힌 test_input·expected 값으로 바로 알 수 있어요.

$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 3 items

test_expectation.py ..F                                              [100%]

================================= FAILURES =================================
____________________________ test_eval[6*9-42] _____________________________

test_input = '6*9', expected = 42

    @pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
    def test_eval(test_input, expected):
>       assert eval(test_input) == expected
E       AssertionError: assert 54 == 42
E        +  where 54 = eval('6*9')

test_expectation.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_expectation.py::test_eval[6*9-42] - AssertionError: assert 54...
======================= 1 failed, 2 passed in 0.12s ========================

파라미터 값은 테스트에 그대로(복사 없이) 전달된다는 점을 기억해야 해요. 리스트나 딕셔너리를 파라미터로 넘기고 테스트가 그 값을 수정하면, 그 변경이 다음 테스트 호출에도 반영되기 때문이에요.

클래스·모듈 단위로 파라미터화하기

파라미터화 마커는 테스트 함수뿐 아니라 클래스나 모듈에도 적용할 수 있어요. 클래스에 붙이면 그 클래스의 모든 테스트 메서드가 같은 파라미터 조합으로 실행돼요.

import pytest


@pytest.mark.parametrize("n,expected", [(1, 2), (3, 4)])
class TestClass:
    def test_simple_case(self, n, expected):
        assert n + 1 == expected

    def test_weird_simple_case(self, n, expected):
        assert (n * 1) + 1 == expected

모듈 안의 모든 테스트를 파라미터화하고 싶다면, pytestmark 전역 변수에 마커를 할당하면 돼요.

import pytest

pytestmark = pytest.mark.parametrize("n,expected", [(1, 2), (3, 4)])


class TestClass:
    def test_simple_case(self, n, expected):
        assert n + 1 == expected

    def test_weird_simple_case(self, n, expected):
        assert (n * 1) + 1 == expected

파라미터 셋마다 다른 마커 붙이기

파라미터 셋 중 특정 하나만 다르게 처리하고 싶다면 pytest.param()으로 그 셋에 마커를 붙일 수 있어요. 예를 들어 아직 실패가 예상되는 입력에 pytest.mark.xfail을 붙이면, 그 셋은 "기대된 실패(xfailed)"로 표시돼요.

# content of test_expectation.py
import pytest


@pytest.mark.parametrize(
    "test_input,expected",
    [("3+5", 8), ("2+4", 6), pytest.param("6*9", 42, marks=pytest.mark.xfail)],
)
def test_eval(test_input, expected):
    assert eval(test_input) == expected

이렇게 하면 이전에 실패하던 셋이 이제 xfailed로 처리돼요.

$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 3 items

test_expectation.py ..x                                              [100%]

======================= 2 passed, 1 xfailed in 0.12s =======================

데코레이터 쌓기: 파라미터 조합 만들기

파라미터화 데코레이터를 여러 개 쌓으면, 각각의 값들이 모든 조합으로 실행돼요. 아래 예시는 x가 0/1, y가 2/3이므로 네 가지 조합이 실행돼요.

import pytest


@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_foo(x, y):
    pass

pytest_generate_tests: 커스텀 파라미터화

자신만의 파라미터화 방식을 만들거나, 픽스처의 파라미터·범위를 동적으로 정하고 싶다면 pytest_generate_tests 훅을 쓰면 돼요. 이 훅은 테스트 함수를 수집할 때 호출되고, 인자로 받은 metafunc 객체를 통해 metafunc.parametrize()를 호출할 수 있어요.

예를 들어 커맨드라인 옵션으로 문자열 입력을 받아 테스트하고 싶다고 해볼게요. 먼저 stringinput 픽스처 인자를 받는 테스트를 작성하고,

# content of test_strings.py


def test_valid_string(stringinput):
    assert stringinput.isalpha()

conftest.py에서 커맨드라인 옵션을 추가하고 그 값을 파라미터로 연결해요.

# content of conftest.py


def pytest_addoption(parser):
    parser.addoption(
        "--stringinput",
        action="append",
        default=[],
        help="list of stringinputs to pass to test functions",
    )


def pytest_generate_tests(metafunc):
    if "stringinput" in metafunc.fixturenames:
        metafunc.parametrize("stringinput", metafunc.config.getoption("stringinput"))

이제 입력 두 개를 주면 테스트가 두 번 실행되고, 옵션을 주지 않으면 파라미터 목록이 비어 있어서 테스트가 스킵돼요. metafunc.parametrize를 여러 번 호출할 때는 각 셋의 파라미터 이름이 중복되면 안 된다는 점도 기억해 두면 좋아요.

더 알아보기 (Learn more)