データ駆動テストを nose と pytest でやってみた

pytest で初めてテストを書いてみました。

nose と比べて、データ駆動テスト *1 *2 の違いが大きかったのでまとめてみます。

準備

以下の素数判定を行うテスト対象関数があるとします。

PRIME = {2: True, 3: True, 4: False, 5: True, 6: False, 7: True}

def is_prime(num):
    return PRIME[num]

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

Python 2.7 でテストしています。

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

最も簡単なデータ駆動テスト

nose でデータ駆動テストを行う場合、ジェネレーターでテストケースを生成します *3 。以下のようにループを使ったテストコードになります。

from nose.tools import ok_

def test_is_prime():
    for num in [3, 4, 5]:
        yield ok_, is_prime(num)

実行結果。

(test)$ nosetests -v test_nose-data-driven.py 
test_nose-data-driven.test_is_prime(True,) ... ok
test_nose-data-driven.test_is_prime(False,) ... FAIL
test_nose-data-driven.test_is_prime(True,) ... ok

======================================================================
FAIL: test_nose-data-driven.test_is_prime(False,)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py", line 197, in runTest
    self.test(*self.arg)
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py", line 25, in ok_
    assert expr, msg
AssertionError

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

3, 4, 5 という入力データに対して、num = 4 のときにテストが失敗します。num = 4 のときにテストが失敗しても 3 と 5 のテストは実行されているので、一回の実行で全ての入力データに対するテストが行えて便利です。もっとたくさんの入力データを取るテストの場合、何度も実行する手間隙が省けます。

しかし、テストが失敗したとき、この結果レポートでは、テストコードのどこで、どんな入力データに対して失敗したのか分かりません。パッと見て何のエラーなのかよく分かりません。

ここで pytest でも同じことをやってみます。

pytest でデータ駆動テストを行う場合、デコレーターで入力データを与えます *4 。ループを書かなくて良いのでテストコードもすっきりしますね。

import pytest

@pytest.mark.parametrize("num", [3, 4, 5])
def test_is_prime(num):
    assert is_prime(num)

実行結果。

(test)$ py.test -v test_pytest-data-driven.py 
============================= test session starts ==============================

test_pytest-data-driven.py:10: test_is_prime[3] PASSED
test_pytest-data-driven.py:10: test_is_prime[4] FAILED
test_pytest-data-driven.py:10: test_is_prime[5] PASSED

================================= FAILURES =================================
_______________________________ test_is_prime[4] _______________________________

num = 4

    @pytest.mark.parametrize("num", [3, 4, 5])
    def test_is_prime(num):
>       assert is_prime(num)
E       assert is_prime(4)

test_pytest-data-driven.py:12: AssertionError
====================== 1 failed, 2 passed in 0.02 seconds ======================

nose の結果レポートと比べると、驚くほど懇切丁寧なレポートです。テストコードの、どこで、どんな入力データに対してテストが失敗したのか一目瞭然です。

この結果レポートの違いを見るだけでも pytest を使う価値があります。

デバッガを使う

pytest はデコレーターで入力データを渡せましたが、この違いはデバッグのやり方にも影響します。nose と pytest 共にテストが失敗したときに pdb デバッガを起動するオプションがあります。

まずは nose でテストが失敗したときにデバッガを起動します。"--pdb-failure" オプションを指定します。

(test)$ nosetests -v --pdb-failure test_nose-data-driven.py
test_nose-data-driven.test_is_prime(True,) ... ok
test_nose-data-driven.test_is_prime(False,) ... > /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py(25)ok_()
-> assert expr, msg

(Pdb) locals()
{'msg': None, 'expr': False}

(Pdb) w
  /opt/local/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/case.py(327)run()
-> testMethod()
  /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py(197)runTest()
-> self.test(*self.arg)
> /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py(25)ok_()
-> assert expr, msg

(Pdb) u
> /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py(197)runTest()
-> self.test(*self.arg)

(Pdb) locals()
{'self': test_nose-data-driven.test_is_prime(False,)}

(Pdb) u
> /opt/local/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/case.py(327)run()
-> testMethod()

(Pdb) locals()
{'self': test_nose-data-driven.test_is_prime(False,), 
 'orig_result': <nose.result.TextTestResult run=2 errors=0 failures=0>,
 'testMethod': <bound method FunctionTestCase.runTest of test_nose-data-driven.test_is_prime(False,)>,
 'success': False, 'result': <nose.result.TextTestResult run=2 errors=0 failures=0>}

デバッガを起動したものの、これは test_is_prime() のコンテキストではありません。そのため、入力値 (num) の情報もありません。

次に pytest でテストが失敗したときにデバッガを起動します。"--pdb" オプションを指定します。

(test)$ py.test -v --pdb test_pytest-data-driven.py 
... (snip)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /Users/t2y/work/repo/misc/data_driven_test/test_pytest-data-driven.py(12)test_is_prime()
-> assert is_prime(num)

(Pdb) locals()
{'@py_format4': 'assert False\n{False = is_prime(4)\n}', 'num': 4, '@py_assert2': False}

(Pdb) w
... (snip)
> /Users/t2y/work/repo/misc/data_driven_test/test_pytest-data-driven.py(12)test_is_prime()
-> assert is_prime(num)

test_is_prime() のコンテキストであることから 'num': 4 が確認できました。

より複雑なテストで失敗したとして、テスト関数のコンテキストにおける情報をみたいときに

import pdb; pdb.set_trace()

のようにテストコードを修正しなくても pytest なら "--pdb" オプションを指定してデバッグできます。

やはり pytest 良いですね。

テストフィクスチャを使ったデータ駆動テスト

wikipedia:XUnit スタイルの setup/teardown を使ったデータ駆動テストもやってみます。

nose も pytest もクラス内の setup()/teardown() メソッドを認識して、テスト関数の前後で実行してくれます *5 *6 。テストコード内の print() で出力したメッセージを表示するには、どちらも "-s" オプションを指定します。

実行順序を分かりやすくするためにデバッグメッセージを出力するように変更します。

def is_prime(num):
    print("called is_prime: {0}".format(num))
    return PRIME[num]

まずは pytest からテストします。

class TestPrimeNumber(object):
    def setup(self):
        print("\ncalled setup")

    def teardown(self):
        print("\ncalled teardown")

    @pytest.mark.parametrize("num", [3, 4, 5])
    def test_is_prime(self, num):
        assert is_prime(num)

    def test_function(self):
        print("called test function")
        assert True

実行結果。

(test)$ py.test -v -s test_pytest-data-driven.py 
============================= test session starts ==============================
collected 4 items 

test_pytest-data-driven.py:23: TestPrimeNumber.test_is_prime[3] 
called setup
called is_prime: 3
PASSED
called teardown

test_pytest-data-driven.py:23: TestPrimeNumber.test_is_prime[4] 
called setup
called is_prime: 4
FAILED
called teardown

test_pytest-data-driven.py:23: TestPrimeNumber.test_is_prime[5] 
called setup
called is_prime: 5
PASSED
called teardown

test_pytest-data-driven.py:27: TestPrimeNumber.test_function 
called setup
called test function
PASSED
called teardown
================================= FAILURES =================================
_______________________ TestPrimeNumber.test_is_prime[4] _______________________

self = <test_pytest-data-driven.TestPrimeNumber object at 0x1017ddc50>, num = 4

    @pytest.mark.parametrize("num", [3, 4, 5])
    def test_is_prime(self, num):
>       assert is_prime(num)
E       assert is_prime(4)

test_pytest-data-driven.py:25: AssertionError
====================== 1 failed, 3 passed in 0.02 seconds ======================

前節の普通の関数と同じ感覚でテストが記述できて、その結果レポートも期待したものが表示されます。良いですね。

次に nose でテストします。

class TestPrimeNumber(object):
    def setup(self):
        print("\ncalled setup")

    def teardown(self):
        print("called teardown")

    def test_is_prime(self):
        for num in [3, 4, 5]:
            yield ok_, is_prime(num)

    def test_function(self):
        print("called test function")
        ok_(True)

やはり先ほどと同様に記述して、一見テストも実行できるのですが、、、

(test)$ nosetests -v -s test_nose-data-driven.py 
... (snip)
test_nose-data-driven.TestPrimeNumber.test_function ... 
called setup
called test function
called teardown
ok

called is_prime: 3
test_nose-data-driven.TestPrimeNumber.test_is_prime(True,) ... 
called setup
called teardown
ok
... (snip)

is_prime()setup() の前に実行されています。このテストコードは意図した順番で実行されません。昔、これではまりました (> <)

nose のテストジェネレーターはテストケースを生成するものなので、正しくは以下のように記述します。

def test_factory(): 
    class FactoryTestCase(object): 
        def __init__(self, num):
            self.num = num

        def __call__(self): 
            ok_(is_prime(self.num))

        def setup(self): 
            print("\ncalled setup")

        def teardown(self): 
            print("called teardown")

    for num in [3, 4, 5]:
        yield FactoryTestCase(num)

実行結果。

(test)$ nosetests -v -s test_nose-data-driven.py 
test_nose-data-driven.test_factory ... 
called setup
called is_prime: 3
called teardown
ok
test_nose-data-driven.test_factory ... 
called setup
called is_prime: 4
FAIL
called teardown
test_nose-data-driven.test_factory ... 
called setup
called is_prime: 5
called teardown
ok
======================================================================
FAIL: test_nose-data-driven.test_factory
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py", line 197, in runTest
    self.test(*self.arg)
  File "/Users/t2y/work/repo/misc/data_driven_test/test_nose-data-driven.py", line 41, in __call__
    ok_(is_prime(self.num))
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py", line 25, in ok_
    assert expr, msg
AssertionError
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)

何となくテスト関数が少し分かり難くなった気がします。

さらに補足として、テストジェネレーターは unittest.TestCase のサブクラスでは動作しません。

関数単位のテストフィクスチャを使ったデータ駆動テスト

前節では、テスト関数の入力データに対するそれぞれの呼び出し毎に setup()/teardown() が呼ばれていました。似て非なる例として、テスト関数に対して一回だけ呼び出すテストをやってみます。

nose だと関数に対する with_setup() デコレーターで指定します。テストジェネレーターで実行するときは、やはり FactoryTestCase を定義しないといけません。

from nose.tools import with_setup

def mysetup():
    print("called mysetup")

def myteardown():
    print("called myteardown")

@with_setup(mysetup, myteardown)
def test_is_prime_with_setup_teardown():
    class FactoryTestCase(object): 
        def __init__(self, num):
            self.num = num

        def __call__(self): 
            ok_(is_prime(self.num))

    for num in [3, 4, 5]:
        yield FactoryTestCase(num)

実行結果。

(test)$ nosetests -v -s test_nose-data-driven.py 
called mysetup
test_nose-data-driven.test_is_prime_with_setup_teardown ...
called is_prime: 3
ok
test_nose-data-driven.test_is_prime_with_setup_teardown ...
called is_prime: 4
FAIL
test_nose-data-driven.test_is_prime_with_setup_teardown ...
called is_prime: 5
ok
called myteardown
... (snip)

mysetup()/myteardown() が一回だけ呼ばれていますね。

次に pytest です。pytest は、もう少し汎用的な仕組みで、テストモジュール内の、全ての関数に対してフックする setup_function()/teardown_function() を定義する方法があります。

これまでの例と違う点として、このテストは pytest.mark.parametrize に入力値のリストを渡して、テスト関数内にループを記述しています。

def setup_function(function):
    print("\ncalled setup: {0}".format(function))

def teardown_function(function):
    print("\ncalled teardown: {0}".format(function))

@pytest.mark.parametrize("nums", ([3, 4, 5],))
def test_is_prime2(nums):
    for num in nums:
        assert is_prime(num)

実行結果。

(test)$ py.test -v -s test_pytest-data-driven.py 
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.2.3 -- /Users/t2y/.virtualenvs/test/bin/python
collected 1 items 

test_pytest-data-driven.py:39: test_is_prime_with_setup_teardown[.0] 
called setup: <function test_is_prime_with_setup_teardown at 0x1017d0758>
called is_prime: 3
called is_prime: 4
FAILED
called teardown: <function test_is_prime_with_setup_teardown at 0x1017d0758>
... (snip)

実行できましたが、このテスト方法はループ内に assert 文を記述しているので num = 4 で失敗するとテストが終了します。さらに setup_function()/teardown_function() は全ての関数に対してフックしてしまうので、使い勝手が悪いかもしれません。

もう1つの方法として、テスト関数の引数に対してフックする方法があります *7 *8 。これは nose にはない仕組みで、ちょっと驚きました。

def mysetup():
    print("\ncalled mysetup")
    return [3, 4, 5]

def myteardown(nums):
    print("\ncalled myteardown: {0}".format(nums))

def pytest_funcarg__nums(request):
    return request.cached_setup(setup=mysetup, teardown=myteardown)

def test_is_prime_funcarg_setup_teardown(nums):
    for num in nums:
        assert is_prime(num)

"nums" という引数に対してフックする pytest_funcarg__nums() を定義して、その中で任意の setup/teardown 関数を指定します。そして、mysetup() がテスト関数の入力データになる "nums" を返します。

実行結果は先ほど同じです。こちらの方が任意の関数に対しては使いやすそうですが、テストコードが分かり難くなってしまうので使い方は限定されるように思います。

(test)$ py.test -v -s test_pytest-data-driven.py 
... (snip)
test_pytest-data-driven.py:55: test_is_prime_funcarg_setup_teardown 
called mysetup
called is_prime: 3
called is_prime: 4
FAILED
called myteardown: [3, 4, 5]

まとめ

データ駆動テストを nose と pytest で行うときの違いをまとめました。

複数の入力データを与えるテストを行う場合、どちらのライブラリも機能的には同じようにテストできますが、結果レポートの分かりやすさ・デバッグのしやすさを考慮すると pytest の方が使い勝手が良いと私は思いました。

あわせてやってみよう。

追記

ちょっと勘違いしたみたい。