すなぶろ

Pythonistaを目指しているつもりが、Hyのおかげで無事脱線。Clojurian目指すことになりました。

Pythonのテストで悩むあなたへ送るnoseフレームワーク

f:id:sandmark:20171017200859j:plain
nose = unittest + coverage + doctest + and more!

前回はデコレータを使ってコードのインデントを浅くし、ボイラープレートとなっていた繰り返し処理をひとつにまとめました。しかしその際、「挙動が変わってしまったらどうしよう」と怯えながら書き換えていたのも事実です。

sandmark.hateblo.jp

今回はユニットテストという命綱を作ることで実装に専念できる環境を整え、また中途半端なリファクタリングもどきによってバラバラになった感のある仕様をまとめてみます。

教科書はいつものスマパイです。


テスト方法

シンプルかつ多機能なテストフレームワークnoseを使います。コードカバレッジを計測するcoverageパッケージとの連携も可能なので、こちらも使います。doctestにも対応しているので、やはり使います。

pip install nose coverage

nose

Pythonには標準でユニットテストモジュールが付属していて、assert文を使ったテストを書くことができます。慣れていればわかりやすいのですが、私は慣れていないので書きにくいです。もともとRubyrspecguardを組み合わせてテストを書いていたので、「DSLとまではいかなくても簡潔に書けるものがいいな」と思ったら、なんだかnoseが良さそうだったのです。

assertEqual(result, True)

のようなテストを

from nose import ok_
ok_(result)

と書くことができます。rspecはちょっと煩雑だったかなと思う自分にとって、現在のベストを求めるとしたらnoseになるかも、というくらい好きです。(rspecが煩雑である、という言い方は語弊があるので補足します。「ちゃんと勉強し続けないとrspecが書けなくなる」です)

コードカバレッジ

コードカバレッジとは、テストコードが実装の何割をチェックしているか、というひとつの指標です。循環的複雑度と同じで、100%にしたからといってバグがなくなるわけではありません。とはいえ、「テストされているか・されていないか」では大きな違いが出てくるので、90%以上を目安にすればいいかな、と思います。

カバレッジを100%に保つのは非常に骨が折れる作業で、「テストばっかり書いて実装を書いてない」という状況に陥りがちです。もしくは、「実装を変更するとテストがエラーだらけになる」というのも、実装とテストが密結合になりすぎていてメンテナンス性が低下しています。

要はバランスが肝心なわけです。

doctest

バランスやメンテナンス性といえば、少々強引な流れですがdocstringがあります。Pythonのコード内でhelp(func)と呼び出すとfuncdocstringが表示されるというもので、これを利用して別途ドキュメントを用意しなくても、docstringのみでドキュメントを生成することもできます。

doctestは、ずばりdocstringの中にテストを埋め込むものです。

参考: スマパイの著者、もみじあめさんの記事

def fizzbuzz(n):
    """
    FizzBuzz 実装した関数。
    引数の整数 n に応じて次のような値を返す。
      - 3 で割り切れる: 'Fizz'
      - 5 で割り切れる: 'Buzz'
      - 3 と 5 の両方で割り切れる: 'FizzBuzz'
      - いずれの値でも割り切れない: nの文字列表現

    >>> from fizzbuzz import fizzbuzz
    >>> fizzbuzz(2)
    '2'
    >>> fizzbuzz(3)
    'Fizz'
    >>> fizzbuzz(5)
    'Buzz'
    >>> fizzbuzz(15)
    'FizzBuzz'
    """

>>>Pythonのプロンプト、続くコードがテスト対象、次の行が想定される値です。doctestは最近のPythonなら組み込みモジュールとしてインストールされているので、お手軽にTDD開発したい方におすすめです。

.nosercを用意する

noseをインストールするとnosetestsというコマンドが使えるようになります。coveragedoctestと連携させたいときは--with-coverageなどの引数を渡す必要があるのですが、実行するたびに渡さなければならないのはちょっと面倒です。そこで.nosercという設定ファイルを用意して、そこに記述することにしましょう。

[nosetests]
with-doctest=1
with-coverage=1
cover-package=dictionary
cover-html=1
verbosity=2

引数のプレフィクス--を取り除いた形で、[nosetests]セクションに記述します。一時的に無効にしたい場合は、行頭に#を付けることでコメントと見なされます。

  • doctestとの連携を有効
  • coverageとの連携を有効
  • coverageカバレッジを計測するのはdictionaryモジュールのみ
  • coverageの出力形式はHTML
  • テスト実行メッセージのレベルは2

を指定しています。verbosityのデフォルト値が2なのでわざわざ指定する必要もないんですが、将来的に3にしたくなったときのために記述するだけしておきます。

また、他のモジュールのテストが追加されたときは、cover-package=unmo, dictionaryとカンマ区切りで増やしていけます。

呼び出すときはnosetests -c .nosercというように、設定ファイルとして.nosercを参照するように指定します。

dictionary.pyをテストする

すでにUnmoモジュールは充分巨大になっています。戻り値にランダム性があったり、ファイルへの書き込みがあったり。とりあえずの対象として、Dictionaryのスタティックメソッド2つに的を絞りましょう。pattern_to_linemake_patternです。

@staticmethod
def pattern_to_line(pattern):
    """
    パターンのハッシュを文字列に変換する。

    >>> pattern = {'pattern': 'Pattern', 'phrases': ['phrases', 'list']}
    >>> Dictionary.pattern_to_line(pattern)
    'Pattern\\tphrases|list'
    """
    return '{}\t{}'.format(pattern['pattern'], '|'.join(pattern['phrases']))

@staticmethod
def make_pattern(line):
    """
    文字列lineを\tで分割し、{'pattern': [0], 'phrases': [1]}の形式で返す。
    [1]はさらに`|`で分割し、文字列のリストとする。

    >>> line = 'Pattern\\tphrases|list'
    >>> Dictionary.make_pattern(line)
    {'pattern': 'Pattern', 'phrases': ['phrases', 'list']}
    """
    pattern, phrases = line.split('\t')
    if pattern and phrases:
        return {'pattern': pattern, 'phrases': phrases.split('|')}

メソッド名からしてすでに不適切な気がするのはひとまず置いておいて、doctestを追加してみました。docstring内ではエスケープシーケンスに気を配る必要があります。\tと記述してしまうとdocstringが解釈された時点で空白文字列へ変換されてしまうので、戻り値のチェックをするには\\tエスケープしてやらなければなりません。

エスケープされないようにダブルクォートではなくバッククォートで括る方法もありますが、現在は非推奨になっているみたいです。この辺はdoctestの情報を調べてみる必要がありそうです。

次は肝心のtest_dictionary.pyです。

from nose.tools import eq_
from dictionary import Dictionary


class TestDictionary:
    TEST_PATTERN = {'pattern':
                    {'pattern': 'Test', 'phrases': ['This', 'is', 'test', 'phrases']},
                    'line': 'Test\tThis|is|test|phrases',
                    }

    def test_pattern_to_line(self):
        test_dict = TestDictionary.TEST_PATTERN['pattern']
        test_result = TestDictionary.TEST_PATTERN['line']
        eq_(Dictionary.pattern_to_line(test_dict), test_result)

    def test_make_pattern(self):
        test_line = TestDictionary.TEST_PATTERN['line']
        test_result = TestDictionary.TEST_PATTERN['pattern']
        eq_(Dictionary.make_pattern(test_line), test_result)

勉強不足による「テストケースそのものが読みづらい」という構造的欠陥を抱えていますが、こちらもまた情報を調べていくうちに改善していきます。unittestではなくnoseを使用しているので、TestDictionaryクラスはunittest.TestCaseクラスを継承する必要はありません。多分。

テストの実行

では実行してみましょう。

D:\Users\sandmark\repos\unmo>nosetests -c .noserc
Doctest: dictionary.Dictionary.make_pattern ... ok
Doctest: dictionary.Dictionary.pattern_to_line ... ok
test_dictionary.TestDictionary.test_make_pattern ... ok
test_dictionary.TestDictionary.test_pattern_to_line ... ok

Name            Stmts   Miss  Cover
-----------------------------------
dictionary.py     114     74    35%
----------------------------------------------------------------------
Ran 4 tests in 1.284s

OK

テストは全部通りました。小規模なテストなので当然ですね。指定したモジュールdictionary.pyカバレッジは35%になっています。多いように見えますが、defclassなどの宣言はPython処理系によってテストされていると見なされるため、底上げされています。ここからカバレッジを増やすのが難しいのです。

実際にどのコードがテストされたかを見るには、nosetests(と連携しているcoverage)が自動的に生成したcoverディレクトリのindex.htmlを開きます。繰り返しコードを見ることでリファクタリングのヒントが掴めるかもしれません。

まとめ

unittest, coverage, doctest……noseが連携できるプラグインは他にもたくさんあります。また、プラグインを自作してよりスマートにテストを行うこともできます。

日本語のドキュメントが少ないのが非常に残念ですが、引き続きnoseによるテストは追加していくので、何らかのリソースになれば幸いです。