データ駆動テストを nose と pytest でやってみた
pytest で初めてテストを書いてみました。
今度こそ帰るー、py.test を使って初めてテストを書いた、評判通りすごく良い
@t2y noseと比べた感想とか聴きたいです。
2012-02-07 19:55:04 via web to @t2y
@methane @t2y テストがこけたときまともなレポートをはくのがpy.testのいいところ
2012-02-07 19:56:34 via twicca to @methane
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 の方が使い勝手が良いと私は思いました。
あわせてやってみよう。
追記
ちょっと勘違いしたみたい。
@imagawa_yakata @t2y さんのBlogのコードでは、 yield ok_, is_prime(num) の時点で is_prime が呼び出されているので、 yield is_prime, num じゃないとダメですね。
2012-02-15 02:14:38 via web to @imagawa_yakata
*1:UxUを用いたデータ駆動テストの記述 - ククログ(2009-10-30)
*2:Python用単体テストフレームワークPikzie 0.9.7リリース - ククログ(2010-05-25)
*4:pytest: Simple "decorator" parametrization of a test function
*6:pytest: Extended xUnit style setup fixtures
*7:pytest: Injecting objects into test functions (funcargs)
*8:http://anders.conbere.org/blog/2011/05/03/setup_and_teardown_methods_with_py.test/