単体テストガイドライン

Pylons プロジェクトは、このドキュメントの元となった Tres Seaver によって書かれた Avoiding Temptation: Notes on using unittest effectively に倣ったユニットテスト教義をかなり厳格に守っています。

Note

このドキュメントは、ほとんど排他的に 単体 テストを扱います。 結合テストまたは機能テストに対しては私たちは特別の教義を持っていません。 しかし、下記の tips の多くはその文脈でも再利用できます。

悪い単体テストを避けるための tips

  • ある人々は “don’t repeat yourself” をむやみに信じてきました (drink the KoolAid): 私たちは、ほとんどの場合にはコードの繰り返しをしないことが 美徳であることに同意します。しかし、単体テストコードはその例外です: テストでの賢さは、テストの意図を不明瞭にし、後に起こる失敗の分析を 極めて困難にします。
  • 別の人々は、テストとドキュメンテーションの両方を書かないようにしたいと 望んでいます: 彼らは実際のテストの仕事をするテストケースを書こうとする (ほとんど常に “doctest” として) 一方で、それと同時に「読みやすい」 ドキュメントを作ろうとします。

最初の動機に関連したほとんどの問題には、このドキュメントの後で十分に 取り組みます: テストモジュール間のコードの共有を拒絶することは、ほとんど の賢さへの誘惑をなくします。残りの誘惑に対して、その対策は個別のテストを 見て次の質問をすることです:

  • テストの意図は、テストケースの名前によって明確に説明されていますか?
  • そのテストは、単体テストのための「規範的な」形式に従っていますか? つまり:
    • テスト対象のメソッド / 関数のための事前条件をセットアップする
    • 最初のステップで確立された値を渡して、メソッド / 関数を一度だけ呼ぶ
    • 戻り値と任意の副作用に関するアサーションを行う
    • それ以外には何も行わない

“don’t repeat yourself” の軸に沿って失敗するテストの修正は、通常は直裁的 (straightforward) です:

  • あらゆる「汎用的な」セットアップコードを、テストケースごとのコードに 置き換える。ここで典型的なケースとして setUp メソッドの中でテストケース クラスの self に値を格納するコードがあります: そのようなコードに対しては、 テスト単位で適切に設定されたテストオブジェクトを返すヘルパーメソッドを 使用するリファクタリングがいつでも行えます。
  • テスト対象のメソッド / 関数が二度以上呼ばれる場合は、テストケースメソッドを 複製して (そして適切に改名して)、 すべての冗長な setup / アサーションを 削除する。これを、それぞれのテストケースがテスト対象を一度だけ呼ぶように なるまで続けます。

このパターンに一致するようにテストを書き直すことには多くの利点があります:

  • 個々のテストケースは、テスト対象のメソッド / 関数を通るただ1つのコードパス を特定します。これは、「100% カバレージ」の達成が、実際にそれをすべて テストしたことを意味するということです。
  • テスト対象のメソッド / 関数のためのテストケースのセットは、きわめて明確に 契約 (contract) を定義します: 追加のテストを加えることで、どんな曖昧さも 解消することができます。
  • 失敗したテストの分析がより簡単になるでしょう。なぜなら、その名前、 事前条件、予期される結果の組み合わせがはっきりと集中しているからです。

ゴール

ここで概説されているテストのゴールは、単純さ、疎結合 (あるいは結合がないこと)、 速度です:

  • テスト対象のアプリケーション (application-under-test; AUT) をそのまま使う 一方で、テストはできるだけ単純であるべきです。
  • テストを頻繁に実行することを促進するため、可能な限り素早く動作すべきです。
  • テストは他のテストあるいはテストに関係ない AUT の一部との密結合を 避けるべきです。

開発者によって指定された契約によって AUT が不変であることを確認するため、 開発者はこのようなテストを書きます。この種のテストケースは、例えそれが テストしようとする契約の説明に役立つとしても、 API ドキュメンテーション、 あるいは narrative /「動作に関する理論」ドキュメンテーションの代わりに はなりません。また、それらはもちろんエンドユーザドキュメンテーションを 意図したものでもありません。

ルール: doctest を避ける

doctest はドキュメンテーションとテストの 両方 を提供して、一石二鳥を 達成するように思えます。実際には、 doctest を使用して書かれたテストは、 ほとんど常に貧弱なテストと貧弱なドキュメンテーションにしかなりません。

  • よいテストはしばしば曖昧なエッジケースをテストする必要があります。 そして、曖昧なエッジケースに対するテストはドキュメンテーションとして 特に読んで面白いものではありません。
  • doctest は「通常の」単体テストよりデバッグするのがより困難です。 通常の単体テストであれば簡単に “pdb” でステップ実行することができますが、 doctest に対してそれを行うことははるかに困難です。
  • doctest は (出力された時のクラスの表現フォーマットのような) インタープリタの実装詳細を過度に露出します。インタープリタの バージョンを変更しただけでしばしば doctest は壊れ、バージョン間の 表現の違いを吸収するための改良が doctest を醜く脆弱にします。
  • doctest は、このドキュメント中の他の多くのルールに従うことを難しくする 実行モデルを持っています。
  • doctest はしばしば悪いテスト習慣 (関数呼び出しの結果を検証せずに切り取り、 テストスートにそれを貼り付ける) を促進します。

ルール: テスト対象のモジュールをテストモジュールのスコープでインポートしない

テスト対象のモジュール (module-under-test; MUT) でインポートに問題があると、 それぞれのテストケースは失敗します: インポートエラーによってテスト実行が 阻害されるべきではありません。テストランナーにもよりますが、 通常のテスト失敗に比べてインポートの問題は一目で識別することがはるかに 難しい場合があります。

例えば以下のようにするのではなく:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 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')

このようにしてください:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 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')

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

単体テストは必要な機能の一部が存在しない環境でも実行できるようにする必要が あります: その場合、1つ以上のテストケースメソッド (TCM) は失敗するでしょう。 あらゆる必要なライブラリーモジュールのインポートをできるだけ遅らせてください。

例えば、 qux モジュールがインポートできない場合、この例ではテストの 失敗がまったく発生しません:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 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'))

一方、この例は不足しているモジュールを使用する各 TCM の失敗を報告します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 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'))

いくつかの場合では、テストケース内で広く使用されるモジュール (しかし MUT ではない!) をインポートすることは合理的なトレードオフかもしれません。 使用法のパターンを明白に理解した後で、おそらくそのようなトレードオフが TCM の寿命の中でやがて生じることがあるでしょう。

ルール: 各テストケースメソッドは、 1つのことだけをテストする

少数の大きなテストを書こうとする誘惑を避けてください。理想的には、それぞれの TCM は1つのメソッドあるいは関数の特定の事前条件の組み合わせに対してテストを 行います。例えば、次のテストケースはあまりにも多くをテストしようとしています:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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つの異なるケース) をテストしているということです。

一般に TCM のプロローグは、 fixture / モックオブジェクト / 静的な値 のセットアップによって特定の事前条件の組み合わせを確立し、次にクラスを インスタンス化するかテスト対象の関数 (function-under-test; FUT) を インポートします。その後 TCM はメソッド / 関数を呼びます。エピローグは 結果をテストします。典型的には、戻り値あるいは1つ以上の fixture / モックオブジェクトの状態のいずれかの検査によって行われます。

テスト対象の関数あるいはメソッドそれぞれに対する事前条件の組み合わせについて 考えることは、契約を明確にすることを助け、より単純な / クリーンな / より速い 実装の動機となるでしょう。

ルール: TCM にそれが何をテストしているかを示す名前を付ける

テストの名前は失敗レポートを見る場合の最初にして最も有用な手掛かりと なるでしょう: 何がテストされていたかを考えるために、読者 (一番ありえるのは あなた自身です) にテストモジュールを grep させないでください。

コメントを追加する代わりに、

1
2
3
4
5
class FooClassTests(unittest.TestCase):

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

テストの目的を示すために TCM 名を使用するようにしてください:

1
2
3
4
class FooClassTests(unittest.TestCase):

   def test_getBar_wo_baz(self):
       ...

ガイドライン: self の属性によってではなく、ヘルパーメソッドによってセットアップを共有する

テストケースクラスの setUp メソッドで不要な仕事をすることは、 TCM 同士の結合性を急激に増加させます。それは悪いこと (Bad Thing) です。 例えば、テスト対象のクラス (class-under-test; CUT) がそのコンストラクタに 引数としてコンテキストを受け取ると仮定してください。 setUp の中で コンテキストをインスタンス化するのではなく:

1
2
3
4
5
6
7
8
9
class FooClassTests(unittest.TestCase):

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

  # ...

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

コンテキストをインスタンス化するヘルパーメソッドを追加して、そのインスタンス をローカルに保持するようにしてください:

1
2
3
4
5
6
7
8
class FooClassTests(unittest.TestCase):

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

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

このプラクティスは、結合性を回避しながら異なるテストが異なった風に モックのコンテキストを作成することを可能にします。さらに、コンテキストを 必要としないテストがコンテキスト作成の代価を払わないので、それはテストの 実行をより速くします。

ガイドライン: fixture を可能な限り単純にしてください

モックオブジェクトを書く場合、空のクラスから初めてください。例えば:

1
2
class DummyContext:
    pass

テストを実行して、依存するテストを通過させるのに必要なメソッドだけを モックオブジェクトに加えてください。モックオブジェクトにテストを通過 させるのに必要ない振る舞いを与えないようにしてください。

フックとレジストリを賢く使ってください

アプリケーションが既にプラグインまたはコンポーネントを登録できるように なっている場合は、モックオブジェクトを挿入するためにその事実を利用して ください。各テストの後でクリーンアップを忘れないでください!

アプリケーションに純粋にテストの単純性を考慮したフックメソッドを追加 することも許容可能です。例えば、通常は datetime 属性を「今」にセットする コードで、 datetime.now() を直接呼ぶのではなく、モジュールスコープの 関数を使用するように調整を加えることができます。その後、テストはその 関数を既知の値を返す関数に置き替えることができます (ただし、テストが実行 された後でオリジナル版に戻す必要があります)。

ガイドライン: 依存的契約を明確化するために、モックオブジェクトを使用してください

AUT が依存する契約を可能な限り単純に維持することは、 AUT を書きやすくして、 また変更に対する弾力性を高めます。そのような契約の可能な限り単純な実装 だけを提供するモックオブジェクトを書くことで、AUT が「依存性の劣化 (dependency creep)」を起こさないようにします。

例えば、リレーショナルアプリケーションでは、アプリケーションによって 使用される SQL クエリは、キーワードパラメータを取って辞書のリストを返す ダミー実装としてモック化することができます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 オブジェクトが列当たり一つの マッピングをリストにして返す) ことに加えて、モックオブジェクトは、 それが AUT によってどのように使われるかを簡単にテストできるようにします:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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'}])

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

ここでの誘惑は、モックオブジェクトや fixture コードを別のテストモジュール から借りてくることでタイプ数を節約しようとすることです。一旦そのようなことを 許してしまうと、やがてそのような「汎用的な」 fixture を共有モジュールに 移動しようとする人が現れます。

これを禁止する根拠は、単純性です: 単体テストでは、 AUT をできるだけ明白で 単純なまま使うことが必要です。

  • それらが同じモジュールの中にないため。共有のモックオブジェクトや fixture は読者に検索の負担を課します。
  • それらが多数のクライアントによって使用される API をサポートしなければ ならないため。共有の fixture は1つのクライアントによってのみ必要と される API / データ構造を拡大する傾向があります: ひどい場合には、 置き換えようとするアプリケーションと同じくらい複雑になります!

場合によっては、同じモジュール / クラス内のテストケースメソッド (TCM) の 中でさえ fixture を共有しないようにするほうがクリーンかもしれません。

結論

これらのルールとガイドラインに従うテストは次の特性を持ちます:

  • そのようなテストは直裁的に書くことができます。
  • そのようなテストは AUT の優れたカバレージを生みます。
  • 予測可能なフィードバック (例えば通過したテストに対してドットのリストが 増え続けるなど) によって、開発者にもメリットがあります。
  • テストが素早く実行され、そのためテストを頻繁に行うように開発者を促します。
  • 予期された失敗は、不足している / 不完全な実装を確認します。
  • 予期しない失敗は、簡単に分析して修正することができます。
  • 退行テストとして使用された場合、失敗は退行の正確な原因を知る助けになります (例えば変更された契約、あるいは指定されていない制約)。
  • そのようなテストを書くことは、テストしているコードの契約に加えてその コードの依存性に関する思考を明確化します。