逆引きpytest

[Python その2 Advent Calendar 2015](http://qiita.com/advent-calendar/2015/yet_another_python)の11日目の記事です。

# はじめに

本記事では、pytestを使用した際に得たTipsを逆引き形式でまとめている。
また以下のリポジトリに本記事の内容を含んだサンプルプロジェクト(Python3.5.0/pytest2.8.4で確認)を置いているため、合わせて参考にして頂ければ。
https://github.com/FGtatsuro/pytest_sample

# pytestの特徴

[pytest](http://pytest.org/latest/contents.html)はその名のとおり、Pythonで書かれたテストライブラリ。同様のライブラリとしては、[unittest](http://pytest.org/latest/contents.html)や[nose](https://nose.readthedocs.org/en/latest/)がある。

上記2つのツールに精通していないため、それらと比較した形での評価は下せないが、個人的には以下のような点が特徴的だと感じた。

- 独自のassertメソッド(ex. `assertEquals`)を定義せずに、Python標準のassert文によりアサーションする。
- テストのライフサイクル(収集->実行->レポート)に適切なフックポイントがあり、独自処理を比較的簡単に定義できる。
- 実行するテストをフィルタリングする機能が、デフォルトで非常に充実している。前述したフックポイントを活用することにより、独自のフィルタリング機能の実装も可能である。

# pytestのTips

## オプションのデフォルト値を指定する

`setup.cfg`にpytestに与えるオプションのデフォルト値を指定できる。

```ini:setup.cfg
# 指定できるオプションは、py.test --help参照
[pytest]
addopts = -v
```

(参考) http://pytest.org/latest/customize.html?highlight=setup%20cfg#adding-default-options

## テストにタイムアウトを設定する

pytest本体にはタイムアウト機能がないが、[pytest-timeout](https://pypi.python.org/pypi/pytest-timeout)プラグインによりサポートできる。

```bash:pytest-timeoutプラグインのインストール
$ pip install pytest-timeout
```

タイムアウトの設定方法は幾つかあり、以下のように組み合わせることもできる。

- デフォルトのタイムアウト時間を`setup.cfg`に指定する。
- それより長い時間が必要なテストについては、デコレータで個別に指定する。

```ini:デフォルトのタイムアウト時間
[pytest]
addopts = -v
timeout = 5
```

```py3:アノテーションによるタイムアウト時間の指定
import time

@pytest.mark.timeout(10)
def test_timeout():
    time.sleep(8)
```

## 実行時のログを標準出力に表示する

結合テストなどでHTTPアクセスが発生する場合、テスト実行時のHTTPリクエスト/レスポンスが標準出力で確認できた方が都合が良い(場合が多い。少なくともテスト実装時は)。
[requests](http://docs.python-requests.org/en/latest/)を例に取ると、以下のようにハンドラを定義することで、その目的を達成できる。

```py3: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`にする必要がある。

```bash:--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
}
...
```

毎回のオプション指定が面倒であれば、デフォルト値を指定してしまうのもよい。

```ini:captureオプションのデフォルト値
[pytest]
addopts = -v --capture=no
timeout = 5
```

(参考) http://pytest.org/latest/capture.html?highlight=capture

## Jenkinsと連携させる

`junit-xml`オプションにより、XUnit形式のテストレポートを指定したファイルに出力できる。これにより、Jenkinsとの連携は簡単に行える。

```ini:XUnit形式のレポート出力
[pytest]
addopts = -v --capture=no --junit-xml=results/results.xml
timeout = 5
```

Jenkinsでテスト結果を確認する際には、個々のテストメソッドとその際の標準出力が確認できたほうが都合が良い。前述のように、`capture`オプションのデフォルト値を`no`にしている場合はテストレポートに標準出力が含まれないため、実行時に値を上書きしてやるとよい。

```bash:--capture=sysを実行時に指定
$ 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:{&apos;Connection&apos;: &apos;keep-alive&apos;, &apos;User-Agent&apos;: &apos;python-requests/2.8.1&apos;, &apos;Accept-Encoding&apos;: &apos;gzip, deflate&apos;, &apos;Accept&apos;: &apos;*/*&apos;}
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:{&apos;Content-Type&apos;: &apos;application/json&apos;, &apos;Date&apos;: &apos;Thu, 10 Dec 2015 03:29:34 GMT&apos;, &apos;Connection&apos;: &apos;keep-alive&apos;, &apos;Access-Control-Allow-Origin&apos;: &apos;*&apos;, &apos;Content-Length&apos;: &apos;33&apos;, &apos;Access-Control-Allow-Credentials&apos;: &apos;true&apos;, &apos;Server&apos;: &apos;nginx&apos;}
DEBUG:sample.client:2015-12-10 12:29:33,811:Body:{
  &quot;origin&quot;: &quot;124.33.163.178&quot;
}

</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`の例を以下に示す。

```py3:setup.py(setuptoolsとpytestの連携)
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`によりテストが実行できる。

```bash:setuptools経由でのテスト実行
$ 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`オプションを使う。

```bash:setuptools経由でのテスト実行(オプション付き)
$ 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](https://pypi.python.org/pypi/pytest-xdist)プラグインにより、テストを並列に実行できる。

```bash:テストの並列実行
# 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は実行するテストを様々な条件でフィルタリングできる。

まずは流すモジュール/メソッドを指定する方法から。

```bash:モジュール/メソッドの指定
# モジュールを指定する
$ py.test tests/test_calc.py
# モジュール内のメソッドを指定する
$ py.test tests/test_calc.py::test_add
```

文字列にマッチするモジュール/メソッドのみ実行することも、

```bash:文字列マッチングによるフィルタリング
# 'calc'という文字列を含むモジュール/メソッドを実行する
$ py.test -k calc
```

デコレータによるマークでフィルタリングすることもできる。`and/or`をサポートしているため、複数のマークがついている/ついていない、といった条件も指定可能となる。

```bash:マークによるフィルタリング
# マーク付け: 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

# おわりに

この他にも様々な機能があるため、興味を持った方は[公式ドキュメント](https://pytest.org/latest/contents.html)を一読してみると良いかと。

明日12日は@shinyorkeさんです。