Python その2 Advent Calendar 2015の11日目の記事です。
はじめに
本記事では、pytestを使用した際に得たTipsを逆引き形式でまとめている。
また以下のリポジトリに本記事の内容を含んだサンプルプロジェクト(Python3.5.0/pytest2.8.4で確認)を置いているため、合わせて参考にして頂ければ。
https://github.com/FGtatsuro/pytest_sample
pytestの特徴
pytestはその名のとおり、Pythonで書かれたテストライブラリ。同様のライブラリとしては、unittestやnoseがある。
上記2つのツールに精通していないため、それらと比較した形での評価は下せないが、個人的には以下のような点が特徴的だと感じた。
- 独自のassertメソッド(ex.
assertEquals
)を定義せずに、Python標準のassert文によりアサーションする。 - テストのライフサイクル(収集->実行->レポート)に適切なフックポイントがあり、独自処理を比較的簡単に定義できる。
- 実行するテストをフィルタリングする機能が、デフォルトで非常に充実している。前述したフックポイントを活用することにより、独自のフィルタリング機能の実装も可能である。
pytestのTips
オプションのデフォルト値を指定する
setup.cfg
にpytestに与えるオプションのデフォルト値を指定できる。
# 指定できるオプションは、py.test --help参照
[pytest]
addopts = -v
(参考) http://pytest.org/latest/customize.html?highlight=setup%20cfg#adding-default-options
テストにタイムアウトを設定する
pytest本体にはタイムアウト機能がないが、pytest-timeoutプラグインによりサポートできる。
$ pip install pytest-timeout
タイムアウトの設定方法は幾つかあり、以下のように組み合わせることもできる。
- デフォルトのタイムアウト時間を
setup.cfg
に指定する。 - それより長い時間が必要なテストについては、デコレータで個別に指定する。
[pytest]
addopts = -v
timeout = 5
import time
@pytest.mark.timeout(10)
def test_timeout():
time.sleep(8)
実行時のログを標準出力に表示する
結合テストなどでHTTPアクセスが発生する場合、テスト実行時のHTTPリクエスト/レスポンスが標準出力で確認できた方が都合が良い(場合が多い。少なくともテスト実装時は)。
requestsを例に取ると、以下のようにハンドラを定義することで、その目的を達成できる。
import requests
# _loggingは自分で定義したモジュール
from ._logging import get_logger
logger = get_logger(__name__)
class ResponseHandler(object):
def __call__(self, resp, *args, **kwargs):
logger.debug('### Request ###')
logger.debug('Method:{0}'.format(resp.request.method))
logger.debug('URL:{0}'.format(resp.request.url))
logger.debug('Header:{0}'.format(resp.request.headers))
logger.debug('Body:{0}'.format(resp.request.body))
logger.debug('### Response ###')
logger.debug('Status:{0}'.format(resp.status_code))
logger.debug('Header:{0}'.format(resp.headers))
logger.debug('Body:{0}'.format(resp.text))
class HttpBinClient(object):
'''
The client for https://httpbin.org/
'''
base = 'https://httpbin.org'
def __init__(self):
self.session = requests.Session()
# 作成したハンドラの登録
# http://docs.python-requests.org/en/latest/user/advanced/?highlight=response#event-hooks
self.session.hooks = {'response': ResponseHandler()}
def ip(self):
return self.session.get('{0}/ip'.format(self.base))
しかしpytestのデフォルトの設定では、テスト実行時の標準出力の内容はpytestにキャプチャされる(テスト結果のレポートに使用される)ため、このままではハンドラの出力は確認できない。
確認するためには、capture
オプションの値をno
にする必要がある。
$ py.test --capture=no
=========================================================================================== test session starts ============================================================================================
...
tests/test_calc.py::test_add
PASSED
tests/test_client.py::test_ip
DEBUG:sample.client:2015-12-10 11:26:17,265:### Request ###
DEBUG:sample.client:2015-12-10 11:26:17,265:Method:GET
DEBUG:sample.client:2015-12-10 11:26:17,265:URL:https://httpbin.org/ip
DEBUG:sample.client:2015-12-10 11:26:17,265:Header:{'Accept-Encoding': 'gzip, deflate', 'Connection': 'keep-alive', 'User-Agent': 'python-requests/2.8.1', 'Accept': '*/*'}
DEBUG:sample.client:2015-12-10 11:26:17,265:Body:None
DEBUG:sample.client:2015-12-10 11:26:17,265:### Response ###
DEBUG:sample.client:2015-12-10 11:26:17,265:Status:200
DEBUG:sample.client:2015-12-10 11:26:17,265:Header:{'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Server': 'nginx', 'Access-Control-Allow-Credentials': 'true', 'Content-Length': '33', 'Connection': 'keep-alive', 'Date': 'Thu, 10 Dec 2015 02:26:17 GMT'}
DEBUG:sample.client:2015-12-10 11:26:17,315:Body:{
"origin": xxxxx
}
...
毎回のオプション指定が面倒であれば、デフォルト値を指定してしまうのもよい。
[pytest]
addopts = -v --capture=no
timeout = 5
(参考) http://pytest.org/latest/capture.html?highlight=capture
Jenkinsと連携させる
junit-xml
オプションにより、XUnit形式のテストレポートを指定したファイルに出力できる。これにより、Jenkinsとの連携は簡単に行える。
[pytest]
addopts = -v --capture=no --junit-xml=results/results.xml
timeout = 5
Jenkinsでテスト結果を確認する際には、個々のテストメソッドとその際の標準出力が確認できたほうが都合が良い。前述のように、capture
オプションのデフォルト値をno
にしている場合はテストレポートに標準出力が含まれないため、実行時に値を上書きしてやるとよい。
$ py.test --capture=sys
=========================================================================================== test session starts ============================================================================================
...
tests/test_calc.py::test_add PASSED
# HTTPリクエスト/レスポンスのログは出なくなる
tests/test_client.py::test_ip PASSED
tests/test_timeout.py::test_timeout PASSED
...
# テストレポートにHTTPリクエスト/レスポンスのログが含まれる
$ cat results/results.xml
<?xml version="1.0" encoding="utf-8"?><testsuite errors="0" failures="0" name="pytest" skips="0" tests="3" time="9.110"><testcase classname="tests.test_calc" file="tests/test_calc.py" line="5" name="test_add" time="0.00024390220642089844"><system-out>
</system-out></testcase><testcase classname="tests.test_client" file="tests/test_client.py" line="7" name="test_ip" time="0.9390749931335449"><system-out>
DEBUG:sample.client:2015-12-10 12:29:33,753:### Request ###
DEBUG:sample.client:2015-12-10 12:29:33,754:Method:GET
DEBUG:sample.client:2015-12-10 12:29:33,754:URL:https://httpbin.org/ip
DEBUG:sample.client:2015-12-10 12:29:33,754:Header:{'Connection': 'keep-alive', 'User-Agent': 'python-requests/2.8.1', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*'}
DEBUG:sample.client:2015-12-10 12:29:33,754:Body:None
DEBUG:sample.client:2015-12-10 12:29:33,754:### Response ###
DEBUG:sample.client:2015-12-10 12:29:33,754:Status:200
DEBUG:sample.client:2015-12-10 12:29:33,754:Header:{'Content-Type': 'application/json', 'Date': 'Thu, 10 Dec 2015 03:29:34 GMT', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Content-Length': '33', 'Access-Control-Allow-Credentials': 'true', 'Server': 'nginx'}
DEBUG:sample.client:2015-12-10 12:29:33,811:Body:{
"origin": "124.33.163.178"
}
</system-out></testcase><testcase classname="tests.test_timeout" file="tests/test_timeout.py" line="8" name="test_timeout" time="8.001494884490967"><system-out>
</system-out></testcase></testsuite>%
setuptools経由でテストの実行を行う
setuptoolsと連携することで、実行環境を汚さずに(=pip freeze
の内容を変更せずに)テストの実行を行える。これは自分以外にテストを実行してもらう機会があり、かつ実行者がvirtualenv
で環境を切るほどにPythonを使用していない場合に、無用なトラブルを避けるという意味で有用だと考える。
setup.py
の例を以下に示す。
import os
import sys
from setuptools import setup, find_packages
from setuptools.command.test import test as TestCommand
# Pytestを実行するコマンドの実装
class PyTest(TestCommand):
# pytestのオプションを指定する際は--pytest-args='{options}'を使用する
user_options = [
('pytest-args=', 'a', 'Arguments for pytest'),
]
def initialize_options(self):
TestCommand.initialize_options(self)
self.pytest_target = []
self.pytest_args = []
def finalize_options(self):
TestCommand.finalize_options(self)
self.test_args = []
self.test_suite = True
def run_tests(self):
import pytest
errno = pytest.main(self.pytest_args)
sys.exit(errno)
version = '0.1'
# setup.py内でpytestのimportが必要
setup_requires=[
'pytest'
]
install_requires=[
'requests',
]
tests_require=[
'pytest-timeout',
'pytest'
]
setup(name='pytest_sample',
...
setup_requires=setup_requires,
install_requires=install_requires,
tests_require=tests_require,
# 'test'を「Pytestを実行するコマンド」と紐付ける
cmdclass={'test': PyTest},
)
setup.py
の変更後は、python setup.py test
によりテストが実行できる。
$ python setup.py test
...
tests/test_calc.py::test_add
PASSED
tests/test_client.py::test_ip
DEBUG:sample.client:2015-12-10 12:54:20,426:### Request ###
DEBUG:sample.client:2015-12-10 12:54:20,426:Method:GET
DEBUG:sample.client:2015-12-10 12:54:20,426:URL:https://httpbin.org/ip
DEBUG:sample.client:2015-12-10 12:54:20,426:Header:{'Connection': 'keep-alive', 'User-Agent': 'python-requests/2.8.1', 'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate'}
DEBUG:sample.client:2015-12-10 12:54:20,426:Body:None
DEBUG:sample.client:2015-12-10 12:54:20,426:### Response ###
DEBUG:sample.client:2015-12-10 12:54:20,426:Status:200
DEBUG:sample.client:2015-12-10 12:54:20,426:Header:{'Server': 'nginx', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true', 'Connection': 'keep-alive', 'Content-Length': '33', 'Date': 'Thu, 10 Dec 2015 03:54:20 GMT', 'Content-Type': 'application/json'}
DEBUG:sample.client:2015-12-10 12:54:20,484:Body:{
"origin": "124.33.163.178"
}
PASSED
tests/test_timeout.py::test_timeout
PASSED
...
pytestのオプションを与えたい場合は、上記の実装で定義した--pytest-args
オプションを使う。
$ python setup.py test --pytest-args='--capture=sys'
...
tests/test_calc.py::test_add PASSED
tests/test_client.py::test_ip PASSED
tests/test_timeout.py::test_timeout PASSED
...
(参考) http://pytest.org/latest/goodpractises.html
(補足)これを書くにあたりドキュメントを読み返してみたところ、同様の事を行うライブラリの存在を知った。これを使うのもいいかもしれない。
https://pypi.python.org/pypi/pytest-runner
テストを並列に実行する
pytest-xdistプラグインにより、テストを並列に実行できる。
# 2プロセスで並列実行
$ py.test -n 2
別プロセスでの実行になるため、実行する環境であらかじめライブラリの依存関係を解決しなくてはならない。そのため、実行時に依存関係を解決するsetuptools経由での実行とは相性が良くない。
前回の実行で失敗したテストのみ、再実行する
lf
オプションにより、前回の実行で失敗したテストのみ再実行することができる。
なお失敗したテストの情報は、テストを実行したディレクトリ直下の.cache/v/cache/lastfailed
に記録される。他のツールと連携する場合(ex. 失敗したテストがあった場合のみ再実行する)は、このファイルを直接参照すると良いだろう。
$ py.test --capture=sys
collected 3 items
...
tests/test_calc.py::test_add PASSED
tests/test_client.py::test_ip PASSED
tests/test_timeout.py::test_timeout FAILED
...
# 失敗したテストの情報
$ cat .cache/v/cache/lastfailed
{
"tests/test_timeout.py::test_timeout": true
}
# テストの修正...
# 失敗したテストのみ再実行
$ py.test --capture=sys --lf
実行するテストをフィルタリングする
pytestは実行するテストを様々な条件でフィルタリングできる。
まずは流すモジュール/メソッドを指定する方法から。
# モジュールを指定する
$ py.test tests/test_calc.py
# モジュール内のメソッドを指定する
$ py.test tests/test_calc.py::test_add
文字列にマッチするモジュール/メソッドのみ実行することも、
# 'calc'という文字列を含むモジュール/メソッドを実行する
$ py.test -k calc
デコレータによるマークでフィルタリングすることもできる。and/or
をサポートしているため、複数のマークがついている/ついていない、といった条件も指定可能となる。
# マーク付け: pytest.mark.<任意の文字列を指定可能>
@pytest.mark.slow
@pytest.mark.httpaccess
def test_ip():
...
@pytest.mark.slow
@pytest.mark.timeout(10)
def test_timeout():
...
# @pytest.mark.slowがついているテストを実行する
$ py.test -m slow
# @pytest.mark.slow/@pytest.mark.httpaccessの両方がついているテストを実行する
$ py.test -m 'slow and httpaccess'
# @pytest.mark.slowがついているが、@pytest.mark.httpaccessがついていないテストを実行する
$ py.test -m 'slow and not httpaccess'
(参考) https://pytest.org/latest/example/markers.html#mark-examples
おわりに
この他にも様々な機能があるため、興味を持った方は公式ドキュメントを一読してみると良いかと。
明日12日は@shinyorkeさんです。