効果的なunittest - または、callFUTの秘密

https://twitter.com/tokibito/status/412074246026698753

ということで _callFUT とはなんぞって話。 簡単に言えば、 Pylons Project の Unit Testing Guidelines で使われてる用語なんだけど、 FUT = Function Under the Test の略。 callFUT というのは、テスト対象の関数を呼ぶってことだけど、テスト対象を明示するために、Pylons Project では、こういったラッパーのメソッドを作っている。

この規約は元を正せば tres seaver という、ZopeやらCMF, Plone, Repozeと活動している人の書いた Avoiding Temptation: Notes on using 'unittest' effectively がもとになっている。

原文書は2008年のものだけど、この内容は今でも間違いじゃないと思う。 また、ユニットテスト自体はやってるけども、より意味のあるテストを書きたいと思うのなら、ぜひ読んでみてほしい。 以下は僕の拙い翻訳だ。

unittest を効果的に使うための覚書

目的

ここで示すテストガイドラインの目的は、簡潔で、結合度が低く、速いテストを書くことだ

  • テストは可能な限り簡潔にする。そしてテスト対象のアプリケーション(Application Under Test = AUT)を完全にテストする。
  • テストは可能な限り速くする。何度も実行できるようにする。
  • テストは他のテストやアプリケーションの中でテスト対象と関係ないものに依存しないようにする。

開発者はこのようなテストでアプリケーションが期待通りに動作することを確かめる。 一方で、アプリケーションの動作を表現するテストケースによって、内部動作を説明するドキュメントは必要なくなる。 エンドユーザー向けのドキュメントは必要だが。

ルール: テスト対象のモジュール(module-under-test)をテストモジュールに直接importしない

テスト対象モジュール(module-under-test MUT)のimportが失敗するのは、各テストケースにおける失敗とするべきである。 テスト実行を阻むのは避けるようにしよう。 テストランナーにとって、通常のテストの失敗とimportエラーを区別するのは難しいことだ。

例えば以下の場合よりも:

# test the foo module
import unittest

from package.foo import FooClass

class FooClassTests(unittest.TestCase):

    def test_bar(self):
        foo = FooClass('Bar')
        self.assertEqual(foo.bar(), 'Bar')

こちらのほうがよい:

# test the foo module
import unittest

class FooClassTests(unittest.TestCase):

    def _getTargetClass(self):
        from package.foo import FooClass
        return FooClass

    def _makeOne(self, *args, **kw):
        return self._getTargetClass()(*args, **kw)

    def test_bar(self):
        foo = self._makeOne('Bar')
        self.assertEqual(foo.bar(), 'Bar')

ガイドライン: モジュールスコープでの依存を最小限にする

ユニットテストは、多少環境が完全でなくても実行できなければならない。 この場合は、いくつかのテストケースが失敗になるだろう。 ライブラリモジュールのimportを可能な限り引き延ばすようにしよう。

例えば以下の例だと "qux" ライブラリモジュールがimportできない場合、テスト結果が全くなくなってしまう:

# test the foo module
import unittest
import qux

class FooClassTests(unittest.TestCase):

    def _getTargetClass(self):
        from package.foo import FooClass
        return FooClass

    def _makeOne(self, *args, **kw):
        return self._getTargetClass()(*args, **kw)

    def test_bar(self):
        foo = self._makeOne(qux.Qux('Bar'))

しかし、以下のようにすれば、モジュールを利用するテストだけが失敗となる:

# test the foo module
import unittest

class FooClassTests(unittest.TestCase):

    def _getTargetClass(self):
        from package.foo import FooClass
        return FooClass

    def _makeOne(self, *args, **kw):
        return self._getTargetClass()(*args, **kw)

    def test_bar(self):
        import qux
        foo = self._makeOne(qux.Qux('Bar'))

多くのテストケースで利用される(テスト対象モジュール以外の)モジュールについては、妥協も必要だ。 こういったトレードオフは それらのモジュールの使い方が明確になった後、テストメソッドが安定したころに発生する。

ルール: 各テストメソッドでは、1つの事実だけを確認する

少ない大きなテストを書くのを避けよう。 理想的には、各テストメソッドは、関数やメソッド1つに対する1つの前提条件だけで実行できるようにしよう。 以下のテストメソッドはとても多くのことを確認しようとしている:

def test_bound_used_container(self):
    from AccessControl.SecurityManagement import newSecurityManager
    from AccessControl import Unauthorized
    newSecurityManager(None, UnderprivilegedUser())
    root = self._makeTree()
    guarded = root._getOb('guarded')

    ps = guarded._getOb('bound_used_container_ps')
    self.assertRaises(Unauthorized, ps)

    ps = guarded._getOb('container_str_ps')
    self.assertRaises(Unauthorized, ps)

    ps = guarded._getOb('container_ps')
    container = ps()
    self.assertRaises(Unauthorized, container)
    self.assertRaises(Unauthorized, container.index_html)
    try:
        str(container)
    except Unauthorized:
        pass
    else:
        self.fail("str(container) didn't raise Unauthorized!")

    ps = guarded._getOb('bound_used_container_ps')
    ps._proxy_roles = ( 'Manager', )
    ps()

    ps = guarded._getOb('container_str_ps')
    ps._proxy_roles = ( 'Manager', )
    ps()

このテストは多くの間違いを犯しているが、一番まずいのはとても多くのことを確認しようとしていることだ(8個の異なるケースが含まれている)

一般化すれば、テストメソッドの前半ではフィクスチャやモック、定数値などの前提条件を設定してから、テスト対象のオブジェクトを作成したりテスト対象の関数をimportしたりする。 その後にテスト対象のメソッドや関数を呼び出す。 テストメソッドの後半では、結果を確認する。 典型的には、戻り値や、フィクスチャやモックの状態を確認する。

各メソッドや関数に対する前提条件をきっちりと区別すると、テスト内容が明確になる。 結果として、簡潔で整った、速い実装を行えるようになる。

ルール: テストメソッドは内容を表すようにしよう

テストメソッドの名前は、テストレポートから得られる最初の役立つ情報となるべきだ。 見た人がテストを探すためにgrepするはめにならないようにしよう。

コメントをつけるよりも:

class FooClassTests(unittest.TestCase):

   def test_some_random_blather(self):
       # test the 'bar' method in the case where 'baz' is not set.

テストメソッドの名前で表そう:

class FooClassTests(unittest.TestCase):

   def test_getBar_wo_baz(self):
       #...

ガイドライン: setupはヘルパーメソッドで提供しよう。テストケースのselfで共有するのはやめよう。

必要のない処理を setUp で行うのはテスト間の依存性を増加させる。これはよくない。 例えばクラスがコンストラクタの引数でcontextを受け取るとしよう。 このcontextをsetUpで作成するより:

class FooClassTests(unittest.TestCase):

   def setUp(self):
       self.context = DummyContext()

   # ...

   def test_bar(self):
       foo = self._makeOne(self.context)

contextを作成するヘルパーメソッドを追加しよう。ローカルに保つようにしよう:

class FooClassTests(unittest.TestCase):

   def _makeContext(self, *args, **kw):
       return DummyContext(*args, **kw)

   def test_bar(self):
       context = self._makeContext()
       foo = self._makeOne(self.context)

この方法は、別々のテストで別々のモックcontextを用意できるし、依存性を排除できる。 また、contextが必要ないテストでは実行されないので、テスト実行自体も速くなるだろう。

ガイドライン: フィクスチャは可能な限り簡潔に

ダミークラスを作るときは、空の実装から始めよう:

class DummyContext:
    pass

テストを実行して、テストが成功するための最小限のモックを追加しよう。 テストの実行に関係のない振る舞いは追加しないようにしよう。

ガイドライン: フックやレジストリなどの利用は注意深く

アプリケーションが既にプラグインやコンポーネントの登録などに対応しているならば、モックへの交換などに非常に有利だ。 テストごとに環境をリセットするのを忘れないこと!

純粋にテストを簡潔にするためのフックメソッドなども利用できるかもしれない。 たとえば、 datetimeの値をを "now"にする処理を、直接 datetime.now を呼ぶのではなく、モジュールスコープにおいた関数を経由する方法などだ。 テスト時はその関数を既知の値を返すモックに置き換えてしまえばよい(テストごとにその処理をもとに戻すように。)

ガイドライン: 依存関係を明確にするためにモックを利用する

アプリケーションの依存性を可能な限り簡潔に維持すると、アプリケーションを書くのが簡単になり、変化に強くなります。 依存関係のためのとても簡単な実装だけを含むモックを書き、忍び寄る依存性からアプリケーションを守りましょう。

例えばRDBMSを使う場合、アプリケーション内のSQLクエリはキーワードパラメータを受け取ってdictのlistを返すモックに置き換えられます:

class DummySQL:

    def __init__(self, results):
        # results should be a list of lists of dictionaries
        self.called_with = []
        self.results = results

    def __call__(self, **kw):
        self.called_with.append(kw.copy())
        return results.pop(0)

依存を簡潔にしている(この場合、SQLオブジェクトは1行ごとにdictへマッピングされたlistを返すだけです)ため、このモックを使ったテストは非常に簡単です:

class FooTest(unittest.TestCase):

   def test_barflies_returns_names_from_SQL(self):
       from foo.sqlregistry import registerSQL
       RESULTS = [[{'name': 'Chuck', 'drink': 'Guiness'},
                   {'name': 'Bob', 'drink': 'Knob Creek'},
                  ]]
       query = DummySQL(RESULTS[:])
       registerSQL('list_barflies', query)
       foo = self._makeOne('Dog and Whistle')

       names = foo.barflies()

       self.assertEqual(len(names), len(RESULTS))
       self.failUnless('NAME1' in names)
       self.failUnless('NAME2' in names)

       self.assertEqual(query.called_with, {'bar', 'Dog and Whistle'})

ルール: テストモジュール間でテキストを共有しない

タイピングを減らすためにモックやフィクスチャデータを他のテストモジュールから借りてくる衝動にかられることがあるでしょう。 いったんそうしてしまうと、他のモジュールでも共有されるようになり、汎用的なフィクスチャとなっていきます。 これを禁止する理由は非常に明確です。 ユニットテストはアプリケーションをテストするのと同時に、可能な限り簡潔で明確でなければならないからです。

  • フィクスチャとテストが同じモジュールにないため、共有されるモックやフィクスチャは読むのが大変です。
  • 様々なテストからの利用に対応するため、1つのテストに使われる場合よりもフィクスチャはどんどん膨れ上がっていきます。 悪化していくと、テスト対象のアプリケーションよりも複雑になってしまいます。

ときには、同じモジュール、クラスでのテストメソッドでもフィクスチャを共有を避けたほうがよいことすらあります。

まとめ

このようなルールやガイドラインにしたがったテストは以下のような特徴があります。

  • テストは単刀直入に書かれています
  • テストは効率よくアプリケーションをカバーします
  • 予測可能な結果を示してくれます
  • 速く実行でき、何度も実行する気になります
  • まだ実装していない処理などが予想通りに失敗するのを確認できます
  • 予想外の失敗について、すぐに原因特定して修正できます
  • リグレッションテストに使えば、不具合の原因を調べるのに非常に役に立ちます
  • こういったテストを書くことでテスト対象の依存性や前提条件を明確にできます

Comments !

blogroll

social