pytest 어서션(assert)으로 테스트 기대값 검증하기
pytest 어서션(assert)으로 테스트 기대값 검증하기
pytest에서는 검증을 위해 별도 assert 라이브러리를 쓸 필요 없이 파이썬 표준 assert 문을 그대로 사용해요. 테스트가 실패하면 pytest가 해당 표현식의 값을 자동으로 분석해 추적 정보를 함께 보여줘서, 어떤 값이 왜 달랐는지 바로 눈에 들어온답니다. 여기서는 어서션 작성법, 근사값·예외·경고 검증, 그리고 어서션 내부 동작까지 하나씩 살펴볼게요.
출처: How to write and report assertions in tests — pytest 공식 문서
본문
assert 문으로 기대값 확인하기
pytest는 테스트에서 기대값이나 검증 대상을 확인할 때 표준 파이썬 assert를 쓰게 해줘요. 예를 들어 다음과 같이 작성할 수 있어요.
# content of test_assert1.py
def f():
return 3
def test_function():
assert f() == 4
함수가 특정 값을 반환하는지를 확인하는 코드예요. 이 어서션이 실패하면 함수 호출의 반환값을 함께 보여줍니다.
$ pytest test_assert1.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item
test_assert1.py F [100%]
================================= FAILURES =================================
______________________________ test_function _______________________________
def test_function():
> assert f() == 4
E assert 3 == 4
E + where 3 = f()
test_assert1.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_assert1.py::test_function - assert 3 == 4
============================ 1 failed in 0.12s =============================
pytest는 호출·속성·비교·이항·단항 연산자 같은 가장 흔한 부분 표현식들의 값을 분석해서 보여줘요. 그래서 상용구 코드 없이도 관용적인 파이썬 구문을 쓰면서 인트로스펙션 정보를 잃지 않을 수 있답니다.
어서션에 메시지를 붙이면 다음과 같이 써요.
assert a % 2 == 0, "value was odd, should be even"
이 메시지는 트레이스백의 어서션 인트로스펙션 옆에 함께 출력됩니다.
근사값 비교 어서션
부동소수점 값(또는 부동소수점 배열)을 비교할 때는 작은 반올림 오차가 흔해요. assert abs(a - b) < tol이나 numpy.isclose 대신 pytest.approx를 쓸 수 있습니다.
import pytest
import numpy as np
def test_floats():
assert (0.1 + 0.2) == pytest.approx(0.3)
def test_arrays():
a = np.array([1.0, 2.0, 3.0])
b = np.array([0.9999, 2.0001, 3.0])
assert a == pytest.approx(b)
pytest.approx는 스칼라·리스트·딕셔너리·NumPy 배열 모두에서 동작해요. NaN이 포함된 비교도 지원합니다.
예상 예외 어서션
예외 발생을 어서션하려면 pytest.raises를 컨텍스트 매니저처럼 쓰면 돼요.
import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0
실제 예외 정보에 접근해야 한다면 다음처럼 as excinfo를 붙일 수 있어요.
def test_recursion_depth():
with pytest.raises(RuntimeError) as excinfo:
def f():
f()
f()
assert "maximum recursion" in str(excinfo.value)
excinfo는 실제 발생한 예외를 감싼 pytest.ExceptionInfo 인스턴스예요. 주요 속성은 .type, .value, .traceback이에요.
참고로 pytest.raises는 표준 except 문처럼 예외 타입 뿐 아니라 그 서브클래스도 매칭해요. 정확히 특정 타입만 발생하는지 확인하려면 명시적으로 검사해야 합니다.
def test_foo_not_implemented():
def foo():
raise NotImplementedError
with pytest.raises(RuntimeError) as excinfo:
foo()
assert excinfo.type is RuntimeError
NotImplementedError는 RuntimeError의 서브클래스라 pytest.raises 호출 자체는 성공해요. 하지만 뒤따르는 assert 문이 문제를 잡아내는 구조랍니다.
예외 메시지 매칭
match 키워드 인자를 컨텍스트 매니저에 넘기면, 예외의 문자열 표현에 정규식이 매칭되는지 검사할 수 있어요(unittest의 TestCase.assertRaisesRegex와 비슷해요).
import pytest
def myfunc():
raise ValueError("Exception 123 raised")
def test_match():
with pytest.raises(ValueError, match=r".* 123 .*"):
myfunc()
주의할 점이 두 가지 있어요.
match인자는re.search로 매칭되기 때문에, 위 예에서match='123'만 해도 동작해요.match인자는 PEP-678의__notes__에도 매칭됩니다.
예상 예외 그룹 어서션
BaseExceptionGroup이나 ExceptionGroup을 기대할 때는 pytest.RaisesGroup을 쓸 수 있어요.
def test_exception_in_group():
with pytest.RaisesGroup(ValueError):
raise ExceptionGroup("group msg", [ValueError("value msg")])
with pytest.RaisesGroup(ValueError, TypeError):
raise ExceptionGroup("msg", [ValueError("foo"), TypeError("bar")])
match 인자는 그룹 메시지를 검사하고, check 인자는 그룹을 넘겨받는 임의의 콜러블을 받아 그 값이 True를 반환해야 성공해요.
def test_raisesgroup_match_and_check():
with pytest.RaisesGroup(BaseException, match="my group msg"):
raise BaseExceptionGroup("my group msg", [KeyboardInterrupt()])
with pytest.RaisesGroup(
Exception, check=lambda eg: isinstance(eg.__cause__, ValueError)
):
raise ExceptionGroup("", [TypeError()]) from ValueError()
RaisesGroup은 except*와 달리 구조와 언랩된 예외에 엄격해서, flatten_subgroups나 allow_unwrapped 인자를 설정해야 할 수 있어요.
def test_structure():
with pytest.RaisesGroup(pytest.RaisesGroup(ValueError)):
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),))
with pytest.RaisesGroup(ValueError, flatten_subgroups=True):
raise ExceptionGroup("1st group", [ExceptionGroup("2nd group", [ValueError()])])
with pytest.RaisesGroup(ValueError, allow_unwrapped=True):
raise ValueError
포함된 예외에 대한 더 자세한 정보를 지정하려면 pytest.RaisesExc를 사용해요.
def test_raises_exc():
with pytest.RaisesGroup(pytest.RaisesExc(ValueError, match="foo")):
raise ExceptionGroup("", (ValueError("foo")))
둘 다 컨텍스트 매니저 밖에서 매칭하고 싶을 때 쓸 수 있는 pytest.RaisesGroup.matches와 pytest.RaisesExc.matches 메서드를 제공해요. .__context__나 .__cause__를 확인할 때 유용합니다.
def test_matches():
exc = ValueError()
exc_group = ExceptionGroup("", [exc])
if RaisesGroup(ValueError).matches(exc_group):
...
# helpful error is available in `.fail_reason` if it fails to match
r = RaisesExc(ValueError)
assert r.matches(e), r.fail_reason
pytest.RaisesGroup과 pytest.RaisesExc의 자세한 내용과 예시는 각 클래스 문서를 참고해요.
ExceptionInfo.group_contains()
이 헬퍼는 특정 예외가 있는지 확인하기엔 쉽지만, 그룹에 다른 어떤 예외도 없다는 걸 확인하기엔 매우 부적합해요. 다음 코드는 통과해버립니다.
class EXTREMELYBADERROR(BaseException):
"""This is a very bad error to miss"""
def test_for_value_error():
with pytest.raises(ExceptionGroup) as excinfo:
excs = [ValueError()]
if very_unlucky():
excs.append(EXTREMELYBADERROR())
raise ExceptionGroup("", excs)
# This passes regardless of if there's other exceptions.
assert excinfo.group_contains(ValueError)
# You can't simply list all exceptions you *don't* want to get here.
excinfo.group_contains()는 기대한 예외 외에 다른 예외가 없다는 것을 보장하는 데 쓸 수 있는 좋은 방법이 없어요. 그런 경우엔 pytest.RaisesGroup을 쓰는 편이 낫습니다.
excinfo.group_contains()는 ExceptionGroup의 일부로 반환된 예외를 검사하는 데도 쓸 수 있어요.
def test_exception_in_group():
with pytest.raises(ExceptionGroup) as excinfo:
raise ExceptionGroup(
"Group message",
[
RuntimeError("Exception 123 raised"),
],
)
assert excinfo.group_contains(RuntimeError, match=r".* 123 .*")
assert not excinfo.group_contains(TypeError)
선택적 match 키워드 인자는 pytest.raises와 같은 방식으로 동작해요. 기본적으로 group_contains()는 중첩된 ExceptionGroup의 어느 레벨이든 재귀적으로 매칭 예외를 찾습니다. 특정 레벨에서만 매칭하고 싶다면 depth 키워드 인자를 지정하면 돼요. 최상위 ExceptionGroup에 직접 포함된 예외는 depth=1로 매칭됩니다.
def test_exception_in_group_at_given_depth():
with pytest.raises(ExceptionGroup) as excinfo:
raise ExceptionGroup(
"Group message",
[
RuntimeError(),
ExceptionGroup(
"Nested group",
[
TypeError(),
],
),
],
)
assert excinfo.group_contains(RuntimeError, depth=1)
assert excinfo.group_contains(TypeError, depth=2)
assert not excinfo.group_contains(RuntimeError, depth=2)
assert not excinfo.group_contains(TypeError, depth=1)
pytest.raises의 대체 형태(레거시)
pytest.raises에는 실행할 함수와 *args, **kwargs를 넘기는 대체 형태가 있어요. pytest.raises는 그 인자들로 함수를 실행해 주어진 예외가 발생하는지 어서션합니다.
def func(x):
if x <= 0:
raise ValueError("x needs to be larger than zero")
pytest.raises(ValueError, func, x=-1)
이 형태는 with 문이 파이썬 언어에 추가되기 전에 만들어진 원래 pytest.raises API예요. 지금은 거의 쓰이지 않고, 컨텍스트 매니저 형태(with 사용)가 더 읽기 쉬운 것으로 여겨집니다.
xfail 마크와 pytest.raises
pytest.mark.xfail에 raises 인자를 지정할 수도 있어요. 이러면 어떤 예외든 발생하기만 하면 되는 게 아니라 더 구체적인 방식으로 테스트가 실패하는지 확인합니다.
def f():
raise IndexError()
@pytest.mark.xfail(raises=IndexError)
def test_f():
f()
이러면 IndexError나 그 서브클래스가 발생해서 실패할 때만 "xfail"로 처리됩니다.
raises인자를 쓴pytest.mark.xfail은 아직 고쳐지지 않은 버그를 문서화할 때(테스트가 "어떻게 되어야 하는지"를 서술하는 경우)나 의존성의 버그에 더 어울려요.pytest.raises는 대부분의 경우처럼 직접 발생시키는 예외를 검증할 때 더 낫습니다.
pytest.RaisesGroup도 쓸 수 있어요.
def f():
raise ExceptionGroup("", [IndexError()])
@pytest.mark.xfail(raises=RaisesGroup(IndexError))
def test_f():
f()
예상 경고 어서션
특정 경고를 발생시키는지 확인하려면 pytest.warns를 사용해요.
상황에 민감한 비교 활용
pytest는 비교를 만났을 때 상황에 민감한 정보를 풍부하게 제공해요. 예를 들어:
# content of test_assert2.py
def test_set_comparison():
set1 = set("1308")
set2 = set("8035")
assert set1 == set2
이 모듈을 실행하면:
$ pytest test_assert2.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item
test_assert2.py F [100%]
================================= FAILURES =================================
___________________________ test_set_comparison ____________________________
def test_set_comparison():
set1 = set("1308")
set2 = set("8035")
> assert set1 == set2
E AssertionError: assert {'0', '1', '3', '8'} == {'0', '3', '5', '8'}
E
E Extra items in the left set:
E '1'
E Extra items in the right set:
E '5'
E Use -v to get more diff
test_assert2.py:4: AssertionError
========================= short test summary info ==========================
FAILED test_assert2.py::test_set_comparison - AssertionError: assert {'0'...
============================ 1 failed in 0.12s =============================
특별한 비교가 이뤄지는 몇 가지 경우가 있어요.
- 긴 문자열 비교: 컨텍스트 diff 표시
- 긴 시퀀스 비교: 첫 실패 인덱스 표시
- 딕셔너리 비교: 다른 항목 표시
더 많은 예시는 reporting demo를 참고하세요.
실패 어서션에 대한 나만의 설명 정의하기
pytest_assertrepr_compare 훅을 구현하면 실패한 어서션에 나만의 자세한 설명을 추가할 수 있어요.
예를 들어 conftest.py에 Foo 객체에 대한 대체 설명을 제공하는 훅을 추가해볼게요.
# content of conftest.py
from test_foocompare import Foo
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, Foo) and isinstance(right, Foo) and op == "==":
return [
"Comparing Foo instances:",
f" vals: {left.val} != {right.val}",
]
이제 이 테스트 모듈이 있고:
# content of test_foocompare.py
class Foo:
def __init__(self, val):
self.val = val
def __eq__(self, other):
return self.val == other.val
def test_compare():
f1 = Foo(1)
f2 = Foo(2)
assert f1 == f2
테스트 모듈을 실행하면 conftest 파일에 정의된 사용자 정의 출력을 볼 수 있어요.
$ pytest -q test_foocompare.py
F [100%]
================================= FAILURES =================================
_______________________________ test_compare _______________________________
def test_compare():
f1 = Foo(1)
f2 = Foo(2)
> assert f1 == f2
E assert Comparing Foo instances:
E vals: 1 != 2
test_foocompare.py:12: AssertionError
========================= short test summary info ==========================
FAILED test_foocompare.py::test_compare - assert Comparing Foo instances:
1 failed in 0.12s
테스트 함수에서 None 아닌 값 반환하기
테스트 함수가 None이 아닌 값을 반환하면 pytest.PytestReturnNotNoneWarning이 발생해요. 이는 초보자가 bool(예: True나 False)을 반환하면 테스트 통과 여부가 결정될 거라고 착각하는 흔한 실수를 막아줍니다.
예시:
@pytest.mark.parametrize(
["a", "b", "result"],
[
[1, 2, 5],
[2, 3, 8],
[5, 3, 18],
],
)
def test_foo(a, b, result):
return foo(a, b) == result # Incorrect usage, do not do this.
pytest는 반환값을 무시하기 때문에, 이 테스트가 반환값에 근거해 실패할 일이 없다는 게 놀랍게 느껴질 수 있어요. 올바른 수정은 return 문을 assert로 바꾸는 겁니다.
@pytest.mark.parametrize(
["a", "b", "result"],
[
[1, 2, 5],
[2, 3, 8],
[5, 3, 18],
],
)
def test_foo(a, b, result):
assert foo(a, b) == result
어서션 인트로스펙션 상세 동작
실패 어서션에 대한 상세 보고는 assert 문을 실행 전에 다시 작성(rewrite)해서 이뤄져요. 다시 작성된 assert 문은 인트로스펙션 정보를 실패 메시지에 넣어줍니다. pytest는 테스트 수집 과정이 직접 발견한 테스트 모듈만 다시 작성하므로, 테스트 모듈이 아닌 지원 모듈의 assert는 다시 작성되지 않습니다.
import하는 모듈에 대해 수동으로 어서션 재작성을 켜려면, import하기 전에 register_assert_rewrite를 호출하면 돼요(루트 conftest.py에 두기 좋은 위치예요). 더 자세한 내용은 Benjamin Peterson이 쓴 Behind the scenes of pytest's new assertion rewriting 글을 참고하세요.
어서션 재작성은 파일을 디스크에 캐시한다
pytest는 다시 작성된 모듈을 캐싱을 위해 디스크에 써요. 이 동작을 끄려면(예: 파일을 자주 옮기는 프로젝트에서 오래된 .pyc 파일을 남기지 않으려는 경우) conftest.py 맨 위에 다음을 추가하면 됩니다.
import sys
sys.dont_write_bytecode = True
어서션 인트로스펙션의 이점은 그대로 누리고, 단지 .pyc 파일을 디스크에 캐시하지 않을 뿐이에요. 또한 읽기 전용 파일시스템이나 zipfile처럼 .pyc 파일을 새로 쓸 수 없으면 재작성이 조용히 캐싱을 건너뜁니다.
어서션 재작성 끄기
pytest는 import 훅으로 테스트 모듈을 재작성하면서 새 pyc 파일을 써요. 대부분 투명하게 동작하지만, import 메커니즘을 직접 다루고 있다면 그 import 훅이 간섭할 수 있어요. 이 경우 두 가지 선택지가 있습니다.
- 특정 모듈만 재작성을 끄려면 해당 모듈의 docstring에
PYTEST_DONT_REWRITE문자열을 추가하세요. - 모든 모듈의 재작성을 끄려면
--assert=plain옵션을 사용하세요.