*이 문서는 ptest documentation중 Usages and Examples의 번역입니다.

커맨드 라인 옵션에 따라 테스트 함수 인수를 바꾸는 방법

커맨드 라인의 옵션에 따라 다르게 동작하는 테스트 함수를 만들고 싶을 때 사용하는 간단한 패턴은 다음과 같다:

# test_sample.py
def test_answer(cmdopt):
  if cmdopt == "type1":
    print("first")
  elif cmdopt == "type2"
    print("second")
  assert 0 # 무엇이 출력되었는지 확인하기 위해

이 코드가 작동하게 하기 위해서는 cmdopt를 fixture function을 통해 제공해야 한다.

# conftest.py
import pytest
def pytest_addoption(parser):
  parser.addoption("--cmdopt", action="store", default="type1",
      help="my option: type1 or type2")

@pytest.fixture
def cmdopt(request):
  return request.config.getoption("--cmdopt")

이제 커맨드라인으로 옵션을 주어 실행하면 다음과 같이 동작함을 확인할 수 있다.

$py.test -q --cmdopt=type2
F
==================================================== FAILURES =====================================================
___________________________________________________ test_answer ___________________________________________________

cmdopt = 'type2'

    def test_answer(cmdopt):
      if cmdopt == "type1":
        print("first")
      elif cmdopt == "type2":
        print("second")
>     assert 0
E     assert 0

test_sample.py:6: AssertionError
---------------------------------------------- Captured stdout call -----------------------------------------------
second
1 failed in 0.01 seconds

동적으로 커맨드라인 옵션 추가하기

위에서 확인했듯 addopts를 통해 정적으로 커맨드라인 옵션을 추가할 수 있다. 하지만 이 뿐 만 아니라 커맨드라인 옵션이 처리되기 전에 커맨드라인 옵션을 직접 수정할 수 있다.

# conftest.py
import sys
def pytest_cmdline_preparse(args):
  if 'xdist' in sys.modules: # pytest-xdist 플러그인
    import multiprocessing
    num = max(multiprocessing.cpu_count() // 2, 1)
    args[:] = ["-n", str(num)] + args

이렇게 할 경우, xdist plugin 이 설치되어 있다면 언제나 CPU 개수의 절반에 가까운 개수의 subprocess를 이용해서 테스틀 수행할 수 있을 것이다.

커맨드라인 옵션에 따른 테스트 생략

conftest.py를 수정해서 --runslow라는 옵션을 추가, slow라는 마커를 붙인 테스트를 건너뛸 수 있도록 해보자.

# conftest.py
import pytest
def pytest_addoption(parser):
  parser.addoption('--runslow', action='store_true',
      help='run slow tests')

def pytest_runtest_setup(item):
  if 'slow' in item.keywords and not item.config.getoption('--runslow'):
    pytest.skip('need --runslow option to run')
# test_module.py
import pytest
slow = pytest.mark.slow

def test_func_fast():
  pass

@slow
def test_func_slow():
  pass

이제 커맨드라인 옵션 --runslow를 주지 않고 수행하면 테스트가 skip됨을 확인할 수 있다.

한편 옵션을 추가할 경우에는 다음과 같이 확실히 수행된다.

$py.test
=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern3, inifile:
collected 2 items

test_module.py .s

======================================= 1 passed, 1 skipped in 0.00 seconds =======================================
$py.test --runslow
=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern3, inifile:
collected 2 items

test_module.py ..

============================================ 2 passed in 0.00 seconds =============================================

assertion 보조 함수 작성

테스트 함수에서 테스트 보조 함수를 호출할 경우, pytest.fail 마커를 통해 메시지와 함께 테스트를 실패하도록 할 수 있다. 그리고 __tracebackhide__ 옵션을 보조 함수 내에서 설정할 경우 traceback 메시지를 숨길 수 있다.
예제를 보자.

# test_checkconfig.py
import pytest
def checkconfig(x):
  __tracebackhide__ = True
  if not hasattr(x, "config"):
    pytest.fail("not configured: %s" % (x,))

def test_something():
  checkconfig(42)

이제 __tracebackhide__옵션이 pytest가 traceback 메시지를 보여주지 않도록 했기 때문에 checkconfig함수는 --fulltrace 커맨드라인 옵션이 명시되었을 때에만 traceback 메시지를 보여줄 것이다.

$py.test -q
F
==================================================== FAILURES =====================================================
_________________________________________________ test_something __________________________________________________

    def test_something():
>           checkconfig(42)
E           Failed: not configured: 42

test_checkconfig.py:8: Failed
1 failed in 0.00 seconds

스크립트가 pytest를 통해 호출되었는지 확인하기

보통 테스트 환경에서 스크립트가 평소와 다르게 동작하도록 작성하는 것은 좋지 않은 방법이지만, 만일 이게 꼭 필요하다면 이렇게 작성할 수 있다.

# conftest.py
def pytest_configure(config):
  import sys
  sys._called_from_test = True

def pytest_unconfigure(config):
  del sys._called_from_test

이제 스크립트에서는 이렇게 확인하면 된다.

if hasattr(sys, '_called_from_test'):
  print('called from within a test run!')
else:
  print('called normally')

여기서는 sys모듈을 이용했지만, 이런 flag들을 관리하는 모듈을 따로 만들어서 사용하는 것이 더 좋은 방법이다.

테스트 결과 보고 헤더에 정보 추가하기

추가적인 정보를 pytest 수행 결과에 추가하는 것은 쉽다.

# conftest.py
def pytest_report_header(config):
  return "project deps: mylib-1.1"
$py.test
=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern6, inifile:
project deps: mylib-1.1
collected 0 items

================================================  in 0.00 seconds =================================================

여러 줄의 메시지를 출력하고 싶다면 한 줄을 한 개의 문자열로 하는 리스트를 사용하면 된다. 그리고 옵션에 따라 출력되는 정보의 양을 조절하고 싶다면 config.option.verbose를 이용하면 된다.

# conftest.py
def pytest_report_header(config):
  if config.option.verbose > 0:
    return ["info1: did you know that ...", "did you?"]

이제 -v 옵션을 주어 실행했을 때에만 메시지가 출력된다.

$py.test -v
=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2 -- /home/kcy1019/.pyenv/versions/soma6/bin/python3.4
rootdir: /home/kcy1019/pytest-exercise/pattern6, inifile:
info1: did you know that ...
did you?
collected 0 items

================================================  in 0.00 seconds =================================================

테스트 수행 시간 프로파일링

수행시간이 길고 규모가 큰 테스트 스위트가 있을 때 어떤 테스트들이 가장 오래걸리는지 알고싶을 것이다. 일단 인공적으로 만들어보자.

# test_some_are_slow.py
import time

def test_funcfast():
  pass
def test_funcslow1():
  time.sleep(0.1)
def test_funcslow2():
  time.sleep(0.2)

이제 --duration옵션을 이용해서 프로파일링을 해보자.

=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern7, inifile:
collected 3 items

test_some_are_slow.py ...

============================================ slowest 3 test durations =============================================
0.20s call     test_some_are_slow.py::test_funcslow2
0.10s call     test_some_are_slow.py::test_funcslow1
0.00s setup    test_some_are_slow.py::test_funcfast
============================================ 3 passed in 0.31 seconds =============================================

증분 테스팅 - 단계별로 테스트하기

가끔 테스트를 작성하다 보면 테스트가 몇 개의 단계로 이루어져 있는 경우가 있다. 이 경우 어떤 한 단계가 실패할 경우 이후의 단계들도 실패하는 것이 당연하기 때문에 수행하는 것에 의미가 없고, 그것들의 traceback 역시 의미가 없다. 이럴 때 사용하기 좋은 incremental 마커를 정의하는 conftest.py의 예제를 살펴보자

# conftest.py
import pytest
def pytest_runtest_makereport(item, call):
  if "incremental" in item.keywords:
    if call.excinfo is not None:
      parent = item.parent
      parent._previousfailed = item

def pytest_runtest_setup(item):
  if "incremental" in item.keywords:
    previousfailed = getattr(item.parent, "_previousfailed", None)
    if previousfailed is not None:
      pytest.xfail("previous test failed (%s)" % previousfailed.name)

이 두 hook 구현은 incremental 마커를 가진 클래스 내의 테스트를 중지하기 위해 동작할 것이다.

# test_step.py
import pytest

@pytest.mark.incremental
class TestUserHandling:
  def test_login(self):
    pass
  def test_modification(self):
    assert 0
  def test_deletion(self):
    pass

def test_normal():
  pass

이제 테스트를 수행해보면

$py.test -rx
=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern8, inifile:
collected 4 items

test_step.py .Fx.

==================================================== FAILURES =====================================================
_______________________________________ TestUserHandling.test_modification ________________________________________

self = <test_step.TestUserHandling object at 0x7ff2c7d4a320>

    def test_modification(self):
>       assert 0
E       assert 0

test_step.py:8: AssertionError
============================================= short test summary info =============================================
XFAIL test_step.py::TestUserHandling::()::test_deletion
  reason: previous test failed (test_modification)
================================== 1 failed, 2 passed, 1 xfailed in 0.01 seconds ==================================

test_modification이 실패했기 때문에 test_deletion이 수행되지 않고 ‘expected failure’로 보고됨을 확인할 수 있다.

package/directory 레벨 fixture

중첩된 디렉토리 구조를 가지고 있을 때, fixture를 conftest.py에 작성함으로써 scope를 ‘디렉토리에서만 사용되도록’ 설정할 수 있다. 당연히 autouse를 포함한 모든 형태의 fixture를 사용할 수 있다.
db라는 fixutre를 현재 디렉토리 안에서 사용 가능하게 하는 예제를 보자.

# a/conftest.py
import pytest
class DB:
  pass

@pytest.fixture(scope="session")
def db():
  return DB()

테스트 함수를 같은 디렉토리에 만들고

# a/test_db.py
def test_a1(db):
  assert 0, db # 값을 보기 위해

또 만들어보자

# a/test_db2.py
def test_a2(db):
  assert 0, db # 값을 보기 위해

다음에는 a와 형제인 디렉토리에서 테스트를 만들어서 db를 볼 수 없음을 확인하자

# b/test_error.py
def test_b1(db):
  pass

이제 a와 b의 부모인 디렉토리에서 테스틀 수행해보면

$py.test
================================ test session starts =================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern9, inifile:
collected 3 items

a/test_db.py F
a/test_db2.py F
b/test_error.py E

======================================= ERRORS =======================================
____________________________ ERROR at setup of test_root _____________________________
file /home/kcy1019/pytest-exercise/pattern9/b/test_error.py, line 2
  def test_root(db):  # no db here, will error out
        fixture 'db' not found
        available fixtures: capfd, monkeypatch, recwarn, capsys, tmpdir, pytestconfig
        use 'py.test --fixtures [testpath]' for help on them.

/home/kcy1019/pytest-exercise/pattern9/b/test_error.py:2
====================================== FAILURES ======================================
______________________________________ test_a1 _______________________________________

db = <conftest.DB object at 0x7fc9c187f5c0>

    def test_a1(db):
>       assert 0, db  # to show value
E       AssertionError: <conftest.DB object at 0x7fc9c187f5c0>
E       assert 0

a/test_db.py:3: AssertionError
______________________________________ test_a2 _______________________________________

db = <conftest.DB object at 0x7fc9c187f5c0>

    def test_a2(db):
>       assert 0, db  # to show value
E       AssertionError: <conftest.DB object at 0x7fc9c187f5c0>
E       assert 0

a/test_db2.py:3: AssertionError
========================= 2 failed, 1 error in 0.01 seconds ==========================

dbscopesession이기 때문에 test_a1test_a2가 같은 DB 인스턴스를 공유함을 알 수 있고, test_bdb fixture를 볼 수 없음을 확인할 수 있다. 그리고 이건 주의할 점인데, autouse를 설정하지 않은 fixture의 경우 scopesession이라고 해도 아무런 함수도 해당 fixture를 요구하지 않을 경우 fixture 함수는 수행되지 않는다.

결과 보고와 실패의 후처리

만일 결과 보고를 후처리하고싶고 실행 환경에 대해 접근하고 싶다면 ‘report’ 객체가 생성되는 부분에 hook을 구현하면 된다. 여기서 실패한 테스트 호출을 모두 출력하고, 후처리에 필요하다면 테스트에서 사용된 fixture에 접근하기도 한다. 일단 간단한 예제를 통해 failures파일에 간단한 정보를 적어보자.

# conftest.py

import pytest
import os.path

@pytest.mark.tryfirst
def pytest_runtest_makereport(item, call, __multicall__):
  # report 객체를 얻기 위해 다른 모든 hook을 수행한다.
  rep = __multicall__.execute()
  
  # 실제로 실패한 테스트만 확인하고, setup이나 teardown은 확인하지 않는다.
  if rep.when == 'call' and rep.failed:
    mode = 'a' if os.path.exists('failure') else 'w'
    with open('failures', mode) as f:
      # fixture에도 접근해보자
      if 'tmpdir' in item.funcargs:
        extra = ' (%s)' % item.funcargs['tmpdir']
      else:
        extra = ''

      f.write(rep.nodeid + extra + '\n')
  return rep

이제 만약 테스트에 실패할 경우

# test_module.py
def test_fail1(tmpdir):
  assert 0
def test_fail2():
  assert 0
$py.test
=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern10, inifile:
collected 2 items

test_module.py FF

==================================================== FAILURES =====================================================
___________________________________________________ test_fail1 ____________________________________________________

tmpdir = local('/tmp/pytest-4/test_fail10')

    def test_fail1(tmpdir):
>       assert 0
E       assert 0

test_module.py:3: AssertionError
___________________________________________________ test_fail2 ____________________________________________________

    def test_fail2():
>       assert 0
E       assert 0

test_module.py:5: AssertionError
============================================ 2 failed in 0.01 seconds =============================================

failures 파일이 생길 것이고, 이를 열어보면 우리가 출력한 내용이 적혀있다.

$cat failures
test_module.py::test_fail1 (/tmp/pytest-4/test_fail10)
test_module.py::test_fail2

테스트 결과를 fixture에서 사용하기

테스트 결과 보고를 fixture finalizer에서 사용 가능하게 하기 위한 간단한 local plugin의 구현을 보자.

# conftest.py
import pytest

@pytest.mark.tryfirst
def pytest_runtest_makereport(item, call, __multicall__):
  # report 객체를 만들기 위해 다른 모든 hook을 수행한다.
  rep = __multicall__.execute()

  # call의 각 phase-"setup","call","teardown"-를 report의 속성으로 설정한다.

  setattr(item, "rep_" + rep.when, rep)
  return rep

@pytest.fixture
def something(request):
  def fin():
    # scope로 기본값인 'function'을 이용했기 때문에 request.node는 item이다.
    if request.node.rep_setup.failed:
      print('setting up a test is failed!', request.node.nodeid)
    elif request.node.rep_setup.passed:
      if request.node.rep_call.failed:
        print('executing test is failed', request.node.nodeid)
  request.addfinalizer(fin)

이제 실패하는 테스트가 존재할 경우

# test_module.py
import pytest

@pytest.fixture
def other():
  assert 0

def test_setup_fails(something, other):
  pass

def test_call_fails(something):
  assert 0

def test_fail2():
  assert 0
$py.test -s
=============================================== test session starts ===============================================
platform linux -- Python 3.4.2 -- py-1.4.30 -- pytest-2.7.2
rootdir: /home/kcy1019/pytest-exercise/pattern11, inifile:
collected 3 items

test_module.py Esetting up a test failed! test_module.py::test_setup_fails
Fexecuting test failed test_module.py::test_call_fails
F

===================================================== ERRORS ======================================================
_______________________________________ ERROR at setup of test_setup_fails ________________________________________

    @pytest.fixture
    def other():
>       assert 0
E       assert 0

test_module.py:7: AssertionError
==================================================== FAILURES =====================================================
_________________________________________________ test_call_fails _________________________________________________

something = None

    def test_call_fails(something):
>       assert 0
E       assert 0

test_module.py:13: AssertionError
___________________________________________________ test_fail2 ____________________________________________________

    def test_fail2():
>       assert 0
E       assert 0

test_module.py:16: AssertionError
======================================== 2 failed, 1 error in 0.01 seconds ========================================

이렇게 fixture finalizer가 동작한다. 이를 이용해 더욱 자세한 정보를 보고하도록 할 수도 있다.

pytest runner와 cx_freeze의 통합

배포를 위해 cx_freeze와 같은 툴로 어플리케이션을 freeze할 경우, 테스트 러너도 패키지에 추가해서 freeze된 어플리케이션을 이용해서 테스트를 수행할 수 있도록 하는 것이 좋다.

이렇게 함으로써 의존성이 충족되지 않은 경우와 같은 패키징 에러를 미리 확인할 수 있고, 유저들에게 테스트 파일을 보내서 그들의 기기에서 수행이 가능한지의 정보를 확인할 수 있다. 특히, 재현이 어려운 버그와 같은 귀중한 정보를 얻어낼 수도 있다.

하지만 안타깝게도 cx_freeze는 그런 에러들을 자동으로 확인할 수 없는데, 이는 pytest가 모듈을 동적으로 로드하기 때문이다. 그래서 이런 경우에는 pytest.freeze_includes()를 이용, 명확히 선언해야 한다.

# setup.py
from cx_Freeze import setup, Executable
import pytest

setup(
  name="app_main",
  executables=[Executable("app_main.py")],
  options={"build_exe":
    {
    'includes': pytest.freeze_includes()
    }
  },
  # ... 다른 옵션들
)

만일 테스트를 수행하기 위해 다른 실행 파일을 추가하는 것이 싫다면, 프로그램에서 flag를 체크한 다음 pytest에게 제어를 넘기도록 할 수도 있다.

# app_main.py
import sys

if len(sys.argv) > 1 and sys.argv[1] == '--pytest':
  import pytest
  sys.exit(pytest.main(sys.argv[2:]))
else:
  # 정상적인 어플리케이션 실행: argv는 여기서 parse될 수 있다.
  # ...

이렇게 하면 커맨드라인 옵션으로 테스트를 freeze된 어플리케이션에서 수행할 수 있게 되어 편리하다.
$./app_main --pytest --verbose --tb=long --junit-xml=results.xml test-suite/