Python の新ユニットテストフレームワーク (or unittest2)

これは Python3 Advent Calendar の記事です。夢はテストエンジニアです!ということでユニットテストについて書きます。

Python3 縛りとのことですが、この新ユニットテストフレームワークは Python 3.2 以降と 2.7 以降が対象です。これ以前のバージョンでこの新ユニットテストフレームワークを利用したい場合は、それぞれ unittest2py3k (3 系)、 unittest2 (2 系) というバックポートが用意されています。新ユニットテストは mock や IronPython 等の開発者としても知られている Michael Foord 氏を中心に開発されました。

>>> Python とユニットテストの歴史

Python のユニットテストは、1999 年 xUnit ファミリーの PyUnit として開発され、2001 年に公開された Python 2.1 から unittest として標準ライブラリとなりました。それ以降、アップグレードといえば assert* メソッドの追加や削除といった感じ。PyCon 2010 での Michael Foord 氏のプレゼンテーションによると "Python には革新的なテストインフラが数多くありますが、unittest は標準ライブラリという理由により最も利用されているテストフレームワークです。しかし、他のテストフレームワークが革新的な進歩を遂げている中、unittest は遅れを取っています"。

しかしついに、ユニットテストは Python 3.2, 2.7 で革新されることになりました。それも Python らしく "後方互換" がかなり意識されています。これも Michael Forrd 氏の言葉を借りると "これは革命ではなく、進化です"。

>>> どこが "進化" したのか

新ユニットテストフレームワークには以下の機能の追加や更新が行われています。

  • 便利な assert* メソッドの追加
  • 名称の統一、重複の排除
  • コマンドラインからの制御をより便利に / 或いはディスカバリ
  • テストのスキップ
  • モジュールレベル、クラスレベルのテストフィクスチャ

などなどです。各進化の詳細について解説していきたいと思います。

>>> 便利な assert* メソッドの追加

追加されたメソッドには以下が含まれています。

  • 3.1 以降に追加されたメソッドも含んでいます
  • 3.2 / 3.3 で非推奨 / 廃止されたメソッドは含んでいません
  • 3.2 / 3.3 で名称変更されたものは新名称で記載しています
  • 利用できるメソッドはバージョンによって違うので、確認する必要があります
メソッド 検証内容 Ver.
assertIsNone / assertIsNotNone(x, msg=None) x is [not] None >= 3.1
assertIs / assertIsNot(a, b) a is [not] b >= 3.1
assertIn / assertNotIn(a, b) a [not] in b >= 3.1
assertIsInstance / assertNotIsInstance(a, b) [not] isinstance(a, b) >= 3.2
assertGreater / assertGreaterEqual(a, b)
assertLess / assertLessEqual(a, b)
a >[=] b
a <[=] b
>= 3.1
assertAlmostEqual / assertNotAlmostEqual(a, b) round(a-b, 7) [!|=]= 0
※ 仕様変更 (delta を追加)
>= 3.2
assertRegex / assertNotRegex(s, re) [not] re.search(s) >= 3.1 (not は >= 3.2)
assertCountEqual(a, b) 配列の個数と値 >= 3.2
assertMultiLineEqual(a, b) a = b: 文字列 >= 3.1
assertSequenceEqual(a, b) a = b: 配列+タイプ >= 3.1
assertListEqual(a, b) a = b: list >= 3.1
assertTupleEqual(a, b) a = b: tuple >= 3.1
assertSetEqual(a, b) a = b: set >= 3.1
assertDictEqual(a, b) a = b: dict >= 3.1
assertRaises(exc, fun, *args, **kwds) fun(*args, **kwds) raises exc >= 3.1
※ 3.2, 3.3 で仕様変更
assertRaisesRegex(exc, re, fun, *args, **kwds) fun(*args, **kwds) raises exc \
and re.match(exc.message)
>= 3.1
※ 3.2, 3.3 で仕様変更
assertWarns(warn, fun, *args, **kwds) fun(*args, **kwds) raises warn >= 3.2
assertWarnsRegex(warn, re, fun, *args, **kwds) fun(*args, **kwds) raises warn \
and re.match(exc.message)
>= 3.2

これは Google さんの協力も得て追加されたようです。このメリットをいくつか挙げたいと思います。

まず、a=b 等の状態検証用のメソッドが増えたこと。テスト開発者の皆様も待ち望んでいたのではないでしょうか?なんでこれが幸せなのか。既存の assert* を駆使して作ってたメソッドが 1 行で検証できるようになり、バグを発見しやすくなります。

class TestTest(unittest.TestCase):
    def setUp(self):
        self.list_first = [1, 2, 3, 4, ]
        self.list_second = [1, 2, 4, 4, 5, ]

    def test_old(self):
        self.assertEqual(len(self.list_first), len(self.list_second))
        self.assertEqual(self.list_first, self.list_second)

    def test_new(self):
        self.assertCountEqual(self.list_first, self.list_second)

if __name__ == '__main__':
    unittest.main()

例がむちゃくちゃでごめんなさい。。。

======================================================================
FAIL: test_new (__main__.TestTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_test.py", line 14, in test_new
    self.assertCountEqual(self.list_first, self.list_second)
AssertionError: Element counts were not equal:
First has 1, Second has 0:  3
First has 1, Second has 2:  4
First has 0, Second has 1:  5

======================================================================
FAIL: test_old (__main__.TestTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_test.py", line 10, in test_old
    self.assertEqual(len(self.list_first), len(self.list_second))
AssertionError: 4 != 5

----------------------------------------------------------------------
Ran 2 tests in 0.000s

テスト結果を見ても、4 != 5 じゃなんのことかさっぱりですよね。以前は "テストメソッド数 = 機能数" というのが一般的でしたが、モダンなテストは "テストメソッド ≒ 検証" です。ひとつのテストメソッド (test_*) 内に複数の検証 (assert*) を書いた場合、複数のテストに失敗しても最初の失敗しか出力されません。「分割すると時間がかかるし、まとめると失敗したときに追いかけるの大変だし。。。しゃーない独自の assert* 定義するか」ってな悩みから解放されます。

また、失敗時の結果出力もバグが分かりやすいように大幅に改善されています。

### Python 2.6
AssertionError: [1, 2, 3, 4] != [1, 2, 4, 4, 5]
### Python 3.2
AssertionError: Lists differ: [1, 2, 3, 4] != [1, 2, 4, 4, 5]

First differing element 2:
3
4

Second list contains 1 additional elements.
First extra element 4:
5

- [1, 2, 3, 4]
?        ^

+ [1, 2, 4, 4, 5]
?        ^   +++

適材適所の assert* を使うことで、バグを見つけやすくなります。

次に、assertRaises も大きな変更の 1 つです。以前は try-catch で書いていた検証が 1 行で書けるようになりました。

class TestTest(unittest.TestCase):
    def test_old(self):
        try:
            int("spam")
            self.fail("Expected a ValueError")
        except (Exception, ) as e:
            self.assertTrue(isinstance(e, ValueError))

    def test_new(self):
        self.assertRaises(ValueError, int, "spam")

if __name__ == '__main__':
    unittest.main()

このテストはもう 1 つ問題があります。例外が発生しない場合は、"self.fail" が通らないことですね。テストのカバレッジも重要ですが、テスト自体のカバレッジも大切です。try 節が長くなったり、ネストしたりするとテストが複雑になってしまいます。通らない可能性があるコードなんて書かないようにしましょう (書いてて耳が痛い)。

>>> 名称の統一、重複の排除

追加された便利機能もあれば、非推奨になったものもあります。名称の統一と重複の解除により、以下のメソッドが非推奨になりました。

  • assert_: assertTrue を利用する
  • fail*: assert* を利用する
  • assertEquals: assertEqual に統一

バージョンによって推奨・非推奨は異なっています。

>>> コマンドラインからの制御をより便利に / 或いはディスカバリ

unittest モジュールはコマンドラインから使えます (参考)。これに、py.test や nose とまではいきませんが、ディスカバリ機能が追加されました!

# テストモジュールやテストクラスを指定して実行
python -m unittest test_module1 test_module2
python -m unittest test_module.TestClass
python -m unittest test_module.TestClass.test_method
# ファイル名を指定して実行
python -m unittest tests/test_something.py
# 結果の詳細出力 (verbosity=3)
python -m unittest -v test_module
# ディスカバリ実行 <- New!!!
python -m unittest
python -m unittest discover

ディスカバーと便利なオプションたち。

  • -v, --verbose: 結果を詳細に出力する
  • -f, --failfast: 最初に失敗した所でテストを終了する (>= 3.2)
  • -c, --catch: テストを中断し、それまでに実行したテスト結果を出力する (>= 3.2)
  • -b, --buffer: 出力先を指定する (>= 3.2)
  • -s: ディスカバリを開始するディレクトリ (>= 3.2)
  • -p: ディスカバリ時にテストファイルにマッチさせるパターン (>= 3.2)
  • -t: ディスカバリさせたいプロジェクトのトップレベルディレクトリ (>= 3.2)

-v, -f, -c, -b についてはディスカバリ時以外でも使えます。

python -m unittest discover -s project_directory -p '*_test.py'
python -m unittest discover project_directory '*_test.py'

ディスカバリのために、load_tests プロトコルが追加されています。テストモジュールに TestSuite を返す load_tests を定義することにより、テストを制御することができます。例えば以下の例ではディスカバリする際のテストクラスを限定しています。

test_cases = (TestCase1, TestCase2, TestCase3)

def load_tests(loader, tests, pattern):
    suite = TestSuite()
    for test_class in test_cases:
        tests = loader.loadTestsFromTestCase(test_class)
        suite.addTests(tests)
    return suite

限定しなければ、TestCase を継承するすべてのクラスがテスト対象となります。

>>> テストのスキップ

テストクラスとテストメソッドをスキップ出来るようになりました。スキップする条件も指定できます。

class TestTest(unittest.TestCase):
    @unittest.skip("無条件スキップ")
    def test_nothing(self):
        self.fail("shouldn't happen")

    @unittest.skipIf(mylib.__version__ < (1, 3),
                     "not supported in this library version")
    def test_format(self):
        # 指定したバージョンのライブラリでのみテスト
        pass

    @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
    def test_windows_support(self):
        # Windows でのみテスト
        pass

verbosity を指定して実行すると以下のように出力されます。

$ python -m unittest -v test.py
test_nothing (test_test.TestTest) ... skipped '無条件スキップ'
test_format (__main__.TestTest) ... skipped 'not supported in this library version'
test_windows_support (__main__.TestTest) ... skipped 'requires Windows'

テストクラスごとスキップする場合は、以下のように書きます。

@skip("showing class skipping")
class TestTest(unittest.TestCase):
    def test_not_run(self):
        pass

どういう場合に有効なのかわかりませんが 失敗した場合でも失敗と数えないようにするデコレータも追加されています。テスト作成時には失敗するけど、修正してテストが成功すると通知してくれます。

    @unittest.expectedFailure
    def test_fail(self):
        self.assertEqual(1, 0, "broken")
# テストに成功してしまった場合の出力
test_fail (test_test.TestTest) ... unexpected success

>>> モジュールレベル、クラスレベルのテストフィクスチャ

これもテストコードも綺麗に書きたい方にとって待ち望んでいた機能だと思います。setUp / tearDown が無駄に長かったり、テストメソッド間で共有したいクラスレベル変数をメソッドレベルに定義したり。。。これは神アップデートです。テストがシンプルに、メンバの スコープも最小限に抑えることができます!!!そういえばモダンな xUnit では setUp/tearDown をなくしちゃった言語もありますね。

# Old... orz
connection = createExpensiveConnectionObject()

class TestTest(unittest.TestCase):
    def test_spam(self):
        connection

コネクションが違う場合は別モジュール orz...

# New !!!
class TestTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls._connection = createExpensiveConnectionObject()

    @classmethod
    def tearDownClass(cls):
        cls._connection.destroy()

    def test_spam(self):
        cls._connection

スコープが素敵ですよね!スコープ範囲のためにモジュールやクラスを分割する必要がなくなります!上述のように "テストメソッド ≒ 検証" 形式で書いても、無駄に setUp を実行しないで済みます。

モジュールレベルは以下のように書けます。

def setUpModule():
    createConnection()

def tearDownModule():
    closeConnection()

setUp / tearDown の代替手段として addCleanup というメソッドが作られています。 addCleanup を使うことで、可読性が高いリソースのセットアップ処理 / 終了処理を書くことができるようになります。 これはテストの終了時 (LIFO) の動作が保障されています。tearDownsetUp 失敗時には動作しません。

def test_method(self):
    temp_dir = tempfile.mkdtemp()
    self.addCleanup(shutil.rmtree, temp_dir)
    ...

明示的に呼び出したい場合は、以下のように書けます。

def test_method(self):
    temp_dir = tempfile.mkdtemp()
    self.addCleanup(shutil.rmtree, temp_dir)
    ...
    self.doCleanups()
    ...
    self.doCleanups()

>>> まとめ

新ユニットテストフレームワーク素晴らしいですよね!バックポートも用意されていますので、是非移行しましょう!バックポートにはディスカバリ等に制限がありますが、assert* メソッドやテストフィクスチャの恩恵を受けることができます。バックポートとの後方互換を持たせるため、以下のように import することが推奨されています。

try:
    import unittest2 as unittest
except (ImportError):
    import unittest

新ユニットテストフレームワークには他にも多くの便利機能が用意されています。詳細については公式ドキュメントを参照してください。

>>> Python 3 に対応しているテストソリューション

  • テストフレームワーク: nose, py.test
  • テスト用モック: mock
  • TDD ならぬ BDD (ビヘイビアドリブン開発) フレームワーク: python-specfor

BDDフレームワークの PyCcuracy 等も今後の対応に期待したいですね。

ということで次は最近お子様が誕生された "@mopemope" 先生にバトンタッチです。

コメント

このブログの人気の投稿

Python から Win32 API 経由で印刷する

Disqus のスケール - Django 編

#PySpa アドベント (23 日目)