目次

前のトピックへ

Mysetup パターン: アプリケーションに特化したテストフィクスチャ

次のトピックへ

カスタムマーカーを使う

パラメーターテスト

py.test は、簡単にパラメーターをテスト関数へ渡せます。パラメーターテストを行うための組み込みの仕組みを使ったサンプルを紹介します。

シンプルな “デコレーター” によるパラメーターテスト

バージョン 2.2 で追加.

組み込みの pytest.mark.parametrize デコレーターは、直接、テスト関数の引数へパラメーターを渡せます。入力値を処理して、その結果として期待される出力値を比較したいテスト関数のサンプルを紹介します:

# test_expectation.py の内容
import pytest
@pytest.mark.parametrize(("input", "expected"), [
    ("3+5", 8),
    ("2+4", 6),
    ("6*9", 42),
])
def test_eval(input, expected):
    assert eval(input) == expected

テスト関数が3回呼び出され、そのテスト関数へ2つの引数をパラメーターとして渡します。実行してみましょう:

$ py.test -q
collecting ... collected 3 items
..F
================================= FAILURES =================================
____________________________ test_eval[6*9-42] _____________________________

input = '6*9', expected = 42

    @pytest.mark.parametrize(("input", "expected"), [
        ("3+5", 8),
        ("2+4", 6),
        ("6*9", 42),
    ])
    def test_eval(input, expected):
>       assert eval(input) == expected
E       assert 54 == 42
E        +  where 54 = eval('6*9')

test_expectation.py:8: AssertionError
1 failed, 2 passed in 0.01 seconds

期待した通り、入力値/出力値の組み合わせの1つだけがこの単純なテスト関数を失敗させます。

関数のグループをマークする方法は様々なやり方があるのに注意してください。詳細は 属性をもつテスト関数のマーク を参照してください。

コマンドラインからパラメーターの組み合わせを作成

別のパラメーターでテストを実行したいときに、そのパラメーターの範囲はコマンドライン引数によって決まるものとしましょう。最初の簡単な (何もしない) テストを書いてみます:

# test_compute.py の内容

def test_compute(param1):
    assert param1 < 4

次のようなテスト設定を追加します:

# conftest.py の内容

def pytest_addoption(parser):
    parser.addoption("--all", action="store_true",
        help="run all combinations")

def pytest_generate_tests(metafunc):
    if 'param1' in metafunc.fixturenames:
        if metafunc.config.option.all:
            end = 5
        else:
            end = 2
        metafunc.parametrize("param1", range(end))

これは --all を指定しない場合、2回だけテストを実行します:

$ py.test -q test_compute.py
collecting ... collected 2 items
..
2 passed in 0.01 seconds

2回だけテストを実行するので、ドットが2つ表示されます。では、全テストを実行してみましょう:

$ py.test -q --all
collecting ... collected 5 items
....F
================================= FAILURES =================================
_____________________________ test_compute[4] ______________________________

param1 = 4

    def test_compute(param1):
>       assert param1 < 4
E       assert 4 < 4

test_compute.py:3: AssertionError
1 failed, 4 passed in 0.02 seconds

期待した通り param1 の全ての範囲値を実行すると、最後の1つがエラーになります。

“testscenarios” の手早い移行

Robert Collins による標準ライブラリの unittest フレームワークのアドオンである test scenarios で設定されたテストを実行するために手早い移行方法を紹介します。pytest の Metafunc.parametrize() へ渡す正しい引数を作成するために少しだけコーディングが必要です:

# test_scenarios.py の内容

def pytest_generate_tests(metafunc):
    idlist = []
    argvalues = []
    for scenario in metafunc.cls.scenarios:
        idlist.append(scenario[0])
        items = scenario[1].items()
        argnames = [x[0] for x in items]
        argvalues.append(([x[1] for x in items]))
    metafunc.parametrize(argnames, argvalues, ids=idlist)

scenario1 = ('basic', {'attribute': 'value'})
scenario2 = ('advanced', {'attribute': 'value2'})

class TestSampleWithScenarios:
    scenarios = [scenario1, scenario2]

    def test_demo(self, attribute):
        assert isinstance(attribute, str)

これはすぐ実行できる完全な自己完結型サンプルです:

$ py.test test_scenarios.py
=========================== test session starts ============================
platform linux2 -- Python 2.7.1 -- pytest-2.2.4
collecting ... collected 2 items

test_scenarios.py ..

========================= 2 passed in 0.01 seconds =========================

ただテストを (実行せずに) 集めるだけなら、テスト関数の変数として ‘advanced’ と ‘basic’ もうまく表示されます:

$ py.test --collect-only test_scenarios.py
=========================== test session starts ============================
platform linux2 -- Python 2.7.1 -- pytest-2.2.4
collecting ... collected 2 items
<Module 'test_scenarios.py'>
  <Class 'TestSampleWithScenarios'>
    <Instance '()'>
      <Function 'test_demo[basic]'>
      <Function 'test_demo[advanced]'>

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

パラメーター化されたリソースの遅延セットアップ

テスト関数へのパラメーター渡しはコレクション時に発生します。実際にテストを実行するときのみ、DB コネクションやサブプロセスといった高価なリソースをセットアップするのは良い考えです。そういったテストを行う簡単なサンプルが次になります。最初のテストは db オブジェクトを要求します:

# test_backends.py の内容

import pytest
def test_db_initialized(db):
    # ダミーテスト
    if db.__class__.__name__ == "DB2":
        pytest.fail("deliberately failing for demo purposes")

test_db_initialized 関数の2回実行するようにテスト設定を追加します。さらに実際のテスト実行時にデータベースオブジェクトを作成するファクトリー関数も実装します:

# conftest.py の内容

def pytest_generate_tests(metafunc):
    if 'db' in metafunc.fixturenames:
        metafunc.parametrize("db", ['d1', 'd2'], indirect=True)

class DB1:
    "one database object"
class DB2:
    "alternative database object"

def pytest_funcarg__db(request):
    if request.param == "d1":
        return DB1()
    elif request.param == "d2":
        return DB2()
    else:
        raise ValueError("invalid internal test config")

コレクション時に先ほどの設定がどうなるかを最初に見てみましょう:

$ py.test test_backends.py --collect-only
=========================== test session starts ============================
platform linux2 -- Python 2.7.1 -- pytest-2.2.4
collecting ... collected 2 items
<Module 'test_backends.py'>
  <Function 'test_db_initialized[d1]'>
  <Function 'test_db_initialized[d2]'>

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

それからテストを実行します:

$ py.test -q test_backends.py
collecting ... collected 2 items
.F
================================= FAILURES =================================
_________________________ test_db_initialized[d2] __________________________

db = <conftest.DB2 instance at 0x1d4eb00>

    def test_db_initialized(db):
        # ダミーテスト
        if db.__class__.__name__ == "DB2":
>           pytest.fail("deliberately failing for demo purposes")
E           Failed: deliberately failing for demo purposes

test_backends.py:6: Failed
1 failed, 1 passed in 0.01 seconds

最初の db == "DB1" による実行が成功したのに対して、2番目の db == "DB2" は失敗しました。 pytest_funcarg__db ファクトリーは、セットアップフェーズのときにそれぞれの DB 値をインスタンス化しました。一方 pytest_generate_tests は、コレクションフェーズのときに2回の test_db_initialized 呼び出しを生成しました。

クラス設定毎のテストメソッドのパラメーター渡し

Michael Foord の unittest parameterizer とよく似ていますが、それよりもずっと少ないコードでパラメーターを渡す仕組みを実装する pytest_generate_function 関数のサンプルがあります:

# ./test_parametrize.py の内容
import pytest

def pytest_generate_tests(metafunc):
    # それぞれのテスト関数毎に1回呼び出される
    funcarglist = metafunc.cls.params[metafunc.function.__name__]
    argnames = list(funcarglist[0])
    metafunc.parametrize(argnames, [[funcargs[name] for name in argnames]
            for funcargs in funcarglist])

class TestClass:
    # テストメソッドのために複数の引数セットを指定するディクショナリ
    params = {
        'test_equals': [dict(a=1, b=2), dict(a=3, b=3), ],
        'test_zerodivision': [dict(a=1, b=0), ],
    }

    def test_equals(self, a, b):
        assert a == b

    def test_zerodivision(self, a, b):
        pytest.raises(ZeroDivisionError, "a/b")

テストジェネレーターは、それぞれのテストメソッドへどの引数セットを渡すかを特定するクラスレベルの定義を調べます。実行してみましょう:

$ py.test -q
collecting ... collected 3 items
F..
================================= FAILURES =================================
________________________ TestClass.test_equals[1-2] ________________________

self = <test_parametrize.TestClass instance at 0x10d2e18>, a = 1, b = 2

    def test_equals(self, a, b):
>       assert a == b
E       assert 1 == 2

test_parametrize.py:18: AssertionError
1 failed, 2 passed in 0.01 seconds

複数リソースでの間接的なパラメーター渡し

別々の Python インタープリターで実行し、シリアライズ化を検証するのにパラメーターテストを使う、実際の世界でのサンプルを解説します。次の3つの引数を全組み合わせで実行する test_basic_objects 関数を定義します。

  • python1 : 1番目の Python インタープリター、オブジェクトをファイルへ pickle-dump するために実行
  • python2 : 2番目の Python インタープリター、ファイルからオブジェクトを pickle-load するために実行
  • obj : ダンプしたり読み込むためのオブジェクト
"""
module containing a parametrized tests testing cross-python
serialization via the pickle module.
"""
import py, pytest

pythonlist = ['python2.4', 'python2.5', 'python2.6', 'python2.7', 'python2.8']

def pytest_generate_tests(metafunc):
    # we parametrize all "python1" and "python2" arguments to iterate
    # over the python interpreters of our list above - the actual
    # setup and lookup of interpreters in the python1/python2 factories
    # respectively.
    for arg in metafunc.fixturenames:
        if arg in ("python1", "python2"):
            metafunc.parametrize(arg, pythonlist, indirect=True)

@pytest.mark.parametrize("obj", [42, {}, {1:3},])
def test_basic_objects(python1, python2, obj):
    python1.dumps(obj)
    python2.load_and_is_true("obj == %s" % obj)

def pytest_funcarg__python1(request):
    tmpdir = request.getfuncargvalue("tmpdir")
    picklefile = tmpdir.join("data.pickle")
    return Python(request.param, picklefile)

def pytest_funcarg__python2(request):
    python1 = request.getfuncargvalue("python1")
    return Python(request.param, python1.picklefile)

class Python:
    def __init__(self, version, picklefile):
        self.pythonpath = py.path.local.sysfind(version)
        if not self.pythonpath:
            py.test.skip("%r not found" %(version,))
        self.picklefile = picklefile
    def dumps(self, obj):
        dumpfile = self.picklefile.dirpath("dump.py")
        dumpfile.write(py.code.Source("""
            import pickle
            f = open(%r, 'wb')
            s = pickle.dump(%r, f)
            f.close()
        """ % (str(self.picklefile), obj)))
        py.process.cmdexec("%s %s" %(self.pythonpath, dumpfile))

    def load_and_is_true(self, expression):
        loadfile = self.picklefile.dirpath("load.py")
        loadfile.write(py.code.Source("""
            import pickle
            f = open(%r, 'rb')
            obj = pickle.load(f)
            f.close()
            res = eval(%r)
            if not res:
                raise SystemExit(1)
        """ % (str(self.picklefile), expression)))
        print (loadfile)
        py.process.cmdexec("%s %s" %(self.pythonpath, loadfile))

もし全ての Python インタープリターがインストールされていない場合、実行してもスキップされます。インストール済みの場合、全ての組み合わせが実行されます (5つのインタープリター * 5つのインタープリター * 3つのシリアライズ/デシリアライズするオブジェクト):

. $ py.test -rs -q multipython.py
collecting ... collected 75 items
............sss............sss............sss............ssssssssssssssssss
========================= short test summary info ==========================
SKIP [27] /home/hpk/p/pytest/doc/example/multipython.py:36: 'python2.8' not found
48 passed, 27 skipped in 1.71 seconds