python 2.6: 僕と unittest と時々 unittest2

testing.mysqld に @skipIfNotInstalled というデコレータを追加しました。
名前の通り、MySQL がインストールされていない場合にテストをスキップするというものです。
もちろん testing.postgresql にも入っています。(testing.cassandra はこのあと手がけます)

MySQL が必要なテストが限られているのであれば、個々人の開発環境に MySQL がなくてもテストが実行できる方が幸せですもんね。

僕の python2.6 には skipIf がない

@skipIfNotInstalled は内部で unittest.skipIf を使っているのですが、
unittest.skipIf は python2.7 から導入されたものであるため python 2.6 では利用することができません。
あたらしい unittest では、他にもいくつも便利な機能が導入されているので、python 2.6 以前でテストコードを書く際は unittest2 を利用することをオススメします。

そこで登場するのが unittest2 パッケージです。
unittest2 は python 2.x 系にあたらしい unittest の機能を提供するバックポートパッケージの役割を持っています。
そのため、python2.6 でも unittest2.skipIf を使えばテストのスキップを実現できるということです。

なお、unittest2 は python 2.x 用のパッケージであるため python 3.x 系では利用できません。
そのため、どちらの環境でもテストコードを動かすためには次のようにして unittest と unittest2 を切り替えるコードを書く必要があります。

import sys
if sys.version_info < (2, 7):
    import unittest2 as unittest
else:
    import unittest


@unittest.skipIf(...)
class MyTestCase(unittest.TestCase):
   ...

unittest と unittest2 が混在した世界

さて、sys.version_info を使って unittest と unittest2 を切り替えることに成功したのですが、
このふたつを混在させるとトラブルが生じます。

今回作った @skipIfNotInstalled を使ってみましょう。

import unittest
import testing.mysqld


@testing.mysqld.skipIfNotInstalled
class MyTestCase(unittest.TestCase):
    def setUp(self):
        self.mysqld = testing.mysqld.Mysqld(my_cnf={'skip-networking': None})

    ...

一見なんの変哲もないテストコードなのですが、一点問題があります。
このコードを python 2.6 で動かした時、@skipIfNotInstalled は内部的に unittest2 の skipIf を呼んでいるのですが、
このテストコードでは python 2.6 に含まれる unittest (2 ではない) を使ってテストが実装されているのです。
ここで食い違いが発生するため、テストを動作させるとテストがスキップされずにエラーが発生します。

手元では python2.6 setup.py test や nose を使ってテストを実行させる際に以下のエラーが発生しました。

Traceback (most recent call last):
  File "/Users/tkomiya/work/schema2rst/nose-1.3.0-py2.6.egg/nose/case.py", line 267, in setUp
    try_run(self.test, names)
  File "/Users/tkomiya/work/schema2rst/nose-1.3.0-py2.6.egg/nose/util.py", line 469, in try_run
    return func()
TypeError: setUp() takes exactly 1 argument (0 given)

この問題は unittest と unittest2 の食い違いによって起きているため、
python 2.6 の場合には次のように unittest2 を利用すると問題が解決します。

import sys
if sys.version_info < (2, 7):
    import unittest2 as unittest
else:
    import unittest

import testing.mysqld


@testing.mysqld.skipIfNotInstalled
class MyTestCase(unittest.TestCase):
    def setUp(self):
        self.mysqld = testing.mysqld.Mysqld(my_cnf={'skip-networking': None})

    ...

なんでこんなこと起きてるのさ?

unittest, unittest2 では次のような関数でテストのスキップを実装しています。

def skip(reason):
    """
    Unconditionally skip a test.
    """
    def decorator(test_item):
        if not (isinstance(test_item, type) and issubclass(test_item, TestCase)):
            @wraps(test_item)
            def skip_wrapper(*args, **kwargs):
                raise SkipTest(reason)
            test_item = skip_wrapper

        test_item.__unittest_skip__ = True
        test_item.__unittest_skip_why__ = reason
        return test_item
    return decorator

ぱっと見では分かりづらいのですが

  • テスト対象(test_item)に __unittest_skip__ フラグを立てている
  • テスト対象が TestCase クラスのサブクラスではない場合(関数やメソッドなど)は skip_wrapper 関数でラッピングしている

という実装になっています。

ここで問題になるのは「テスト対象が TestCase クラスのサブクラスではない場合」という部分です。
TestCase クラスを継承している場合はこの条件に一致しないため、
skip_wrapper 関数でラッピングされず skip() 関数からはクラスそのものが返ります。

この条件は unittest2 では「テスト対象が unittest2.TestCase クラスのサブクラスではない場合」という風に解釈されるため、
unittest.TestCase (2 ではない)を継承している場合はこの条件に「一致してしま」います。
そのため、skip() 関数の返り値が関数になってしまいます。

先ほど引用したエラーで「TypeError: setUp() takes exactly 1 argument (0 given)」というエラーが出ているのは、
この違いによって setUp() が関数として扱われてしまっている(メソッドではなくなった)ために起きてしまったものでした。

まとめ

  • python2.6 でテストを書く際は unittest2 おすすめ
  • でも、unittest と unittest2 が混在するとハマることがある。とりあえず skipIf まわり。
  • 僕の午前中を返せ。