tox と pytest で Python 2/3 両対応のテストを実行する

pytest *1 に関連して tox も一緒に覚えておくと良さそうです。


tox については id:Ehren の入門記事が分かりやすいです。

複数の Python バージョン毎に virtualenv で仮想環境を作成して、そこに自分のパッケージと必要なライブラリ等をインストールして、それぞれのバージョン毎にテストをまとめて実行してくれます。例えば、Python 2/3 の両対応を考えたときに、自分で環境を切り分ける手間隙を軽減できて、かなり便利です。ここで言う Python 2/3 両対応は 2.6/2.7 と 3.x の対応を指します *2

環境

(test)$ pip freeze
distribute==0.6.24
py==1.4.7
pytest==2.2.3
tox==1.3
virtualenv==1.7
wsgiref==0.1.2

bitbucket にこの記事で紹介するテストコードを置きました。興味のある方は試してみてください。

設定

wikipedia:エラトステネスの篩 を実装した eratosthenes.py を含むパッケージを作成してテストを行います。

カレントディレクトリの構成は以下になります。

(test)$ tree .
.
├── setup.py
├── src
│      ├── eratosthenes.py
│      └── tests
│              └── test_eratosthenes.py
└── tox.ini

Python のパッケージングや仮想環境の作成方法については Python Hack-a-thon 4 ハンズオン 中級コース がとても参考になります。

setup.py の設定

tox を使ってテストするには Python のパッケージを作成しないといけません。

ここではテストすることが目的なので、パッケージ名とバージョンのみの設定をもつ setup スクリプトを記述します。

$ vi setup.py
# -*- coding: utf-8 -*-
from setuptools import setup
setup(
    name="mytoxtest",
    version="0.1.0",
)
tox.ini の設定

tox の設定ファイルは ini 形式で記述します。

$ vi tox.ini
[tox]
envlist = py26, py27, py32

[testenv]
deps = pytest
commands = py.test --doctest-module -v src/eratosthenes.py src/tests

[tox] セクションには、テスト対象の Python バージョンやワークディレクトリの位置などを設定します *3

[testenv] セクションには、依存するライブラリと実行するテストコマンドを記述しています。ここでは pytest を使っていますが、コマンドラインで実行できるものであれば何でも構いません。

pytest のおさらい

pytest で doctest とテストスクリプトを実行します。

commands = py.test --doctest-module -v src/eratosthenes.py src/tests

まずは指定した Python バージョンでテストを実行します。"-e" オプションを指定すると、envlist で指定した任意の Python バージョンでのみ実行します。試しに実行します。

(test)$ tox -e py27
_________________________________ [tox sdist] __________________________________
[TOX] ***creating sdist package
[TOX] /Users/t2y/work/repo/misc/tox_test
$ /Users/t2y/.virtualenvs/test/bin/python setup.py sdist --formats=zip --dist-dir .tox/dist >.tox/log/0.log
[TOX] ***copying new sdistfile to '/Users/t2y/.tox/distshare/mytoxtest-0.1.0.zip'
______________________________ [tox testenv:py27] ______________________________
[TOX] ***reusing existing matching virtualenv py27
[TOX] ***upgrade-installing sdist
[TOX] /Users/t2y/work/repo/misc/tox_test/.tox/py27/log
$ ../bin/pip install --download-cache=/Users/t2y/work/repo/misc/tox_test/.tox/_download 
  /Users/t2y/work/repo/misc/tox_test/.tox/dist/mytoxtest-0.1.0.zip -U --no-deps >5.log
[TOX] /Users/t2y/work/repo/misc/tox_test
$ .tox/py27/bin/py.test --doctest-module -v src/eratosthenes.py src/tests
============================= test session starts ==============================
collected 173 items 

src/eratosthenes.py: [doctest] PASSED
src/tests/test_eratosthenes.py: [doctest] PASSED
src/tests/test_eratosthenes.py:21: test_sieve_of_eratosthenes[10-3] FAILED
src/tests/test_eratosthenes.py:21: test_sieve_of_eratosthenes[30-10] PASSED
src/tests/test_eratosthenes.py:21: test_sieve_of_eratosthenes[997-168] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[2] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[3] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[5] PASSED
... (snip)
src/tests/test_eratosthenes.py:29: test_is_prime[983] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[991] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[997] PASSED

================================= FAILURES =================================
_______________________ test_sieve_of_eratosthenes[10-3] _______________________

max_num = 10, expected = 3

    @pytest.mark.parametrize(("max_num", "expected"), [
        (10, 3),
        (30, 10),
        (PRIMES[-1], len(PRIMES)),
    ])
    def test_sieve_of_eratosthenes(max_num, expected):
>       assert expected == len(list(sieve_of_eratosthenes(max_num)))
E       assert 3 == 4
E        +  where 4 = len([2, 3, 5, 7])
E        +    where [2, 3, 5, 7] = list(<generator object sieve_of_eratosthenes at 0x10185c7d0>)
E        +      where <generator object sieve_of_eratosthenes at 0x10185c7d0> = sieve_of_eratosthenes(10)

src/tests/test_eratosthenes.py:27: AssertionError
===================== 1 failed, 172 passed in 0.35 seconds =====================
[TOX] ERROR: InvocationError: '.tox/py27/bin/py.test --doctest-module -v src/eratosthenes.py src/tests'
________________________________ [tox summary] _________________________________
[TOX] ERROR: py27: commands failed

おっと、test_sieve_of_eratosthenes() のテストに失敗したようです。

pytest の結果レポートを表示させるためにあえて失敗させました。詳細は省きますが、len(list(...)) の内部処理が展開されていて、10までの素数は 2, 3, 5, 7 の 4 つ値が返り値だと分かります。このテストは、@pytest.mark.parametrize でパラメーター指定した値が誤っていました。

テストを修正して再実行します。全てパスすると "congratulations :)" と表示されます。

(test)$ tox -e py27
________________________________ [tox sdist] _________________________________
[TOX] ***creating sdist package
... (snip)
========================= 173 passed in 0.34 seconds =========================
_______________________________ [tox summary] ________________________________
[TOX] py27: commands succeeded
[TOX] congratulations :)

tox を使ってテストする

ある Python バージョンで成功したので、他の Python バージョンでもテストを実行してみましょう。"-e" オプションを指定せずに実行すると envlist で定義した全てのバージョンでテストが実行されます。

$ tox
(test)$ tox 
________________________________ [tox sdist] _________________________________
[TOX] ***creating sdist package
[TOX] /Users/t2y/work/repo/misc/tox_test
$ /Users/t2y/.virtualenvs/test/bin/python setup.py sdist --formats=zip --dist-dir .tox/dist >.tox/log/0.log
[TOX] ***copying new sdistfile to '/Users/t2y/.tox/distshare/mytoxtest-0.1.0.zip'
_____________________________ [tox testenv:py26] _____________________________
[TOX] ***reusing existing matching virtualenv py26
[TOX] ***upgrade-installing sdist
...
... (snip)
...
_____________________________ [tox testenv:py27] _____________________________
[TOX] ***reusing existing matching virtualenv py27
[TOX] ***upgrade-installing sdist
...
... (snip)
...
_____________________________ [tox testenv:py32] _____________________________
[TOX] ***reusing existing matching virtualenv py32
[TOX] ***upgrade-installing sdist
...
... (snip)
...
_______________________________ [tox summary] ________________________________
[TOX] py26: commands succeeded
[TOX] py27: commands succeeded
[TOX] py32: commands succeeded
[TOX] congratulations :)
(test)$ 

Python 2.6, 2.7, 3.2 で全てのテストが成功しました、本当に簡単ですね o(^ ^)o

インデックスサーバーの切り替え

tox.ini で indexserver を指定して、任意の pypi サーバーを設定できます。

[tox]
envlist = py26, py27, py32
indexserver =
    testrun = http://pypi.testrun.org
    pypi = http://pypi.python.org/simple

[testenv]
deps =
 :testrun:pytest
 :pypi:sphinx

pytest や tox の tox.ini を見ると、http://pypi.python.org/ に加えて http://pypi.testrun.org/ も設定されていました。ローカルの pypi ミラーサーバー、もしくは開発版のパッケージを置いておくサーバーなどに活用できます。ローカルに pypi サーバーを構築する方法はまた別の機会に書きます。

まとめ

tox は本当に簡単で便利なツールです。

Python 3 未対応のパッケージに出会ったとき、tox.ini を追加してテスト環境を構築できれば、互換性を保持した上で Python 3 対応を行う敷居が格段に低くなります。さらに pytest の結果レポートの分かりやすさと組み合わせて、失敗するテストを直すのがおもしろくなってきたりします。また tox と pytest 共に開発者が Holger Krekel 氏なので、今後もお互いの親和性を保って拡張されていくように期待できます。

pytest と tox を使って Python 3 対応を本格的に始めてみてはどうでしょうか。