RSS2.0

Python nose でユニットテストを書いてみた

chocolablog は Python で書かれていますが、これまであまりユニットテストの整備に時間を割いてこれませんでした。しかし初期バージョンの開発から 2 年以上が経ち、その間に何度か sqlite3 をとりまく DB アクセス部分を中心に、処理を書き換えています。他のロジックはともかく、コアとなるコードはしっかり動作を担保しておくべきだなと感じ、ユニットテストの整備に着手することにしました。

nose_report.png
Python では 2.1 移行、ユニットテストフレームワークとして unittest が組み込まれています。これは個人的に大好きな JUnit をベースに作られているそうです。ただネット上で目にした Python 系のオープンソースプロジェクトでは、この uniittest モジュールではなく nose というモジュールを使ってテストコードが書かれていました。nose を使うとテスト失敗時にデバッグツールの pdb を自動で起動してステップ実行ができたり、ソースコードのカバレッジレポートを出力できたりと、テストの書き方以外の面でもいくつかメリットがあります。
今回は unittest よりさらに簡単にユニットテストを書けるらしい nose を使い、ユニットテストを書いてみました。

Python nose でユニットテストを書く

nose は unittest に基づいて書いたテストケースの他、独自の命名規則に基づくファイル/モジュールの関数/クラス/メソッドをテストケースとしてを実行できます。
またより便利でヒューマンライクなテストケースを書くための nose 独自のユーティリティも用意されています。テストケース毎に前処理、後処理をする、テストケース内で例外が発生しなければテスト失敗にするという JUnit ではお馴染みの処理も可能です。他にも引数の真偽値を評価する _ok() や 2 つの値が等しいことを評価する eq_() など人が見て読みやすいコードを書ける評価用ユーティリティなどもあります。(私個人としては、エラー時のスタックトレースが 1 階層増えるのであまり好みではないですが…)

nose のインストール

nose はテスト実行環境で、python の easy_install コマンドで nose をインストールします。

easy_install コマンドは Python のライブラリを簡単にインストールするためのコマンドです。おそらく Pyrhon がインストールされた環境であればすでにインストールされているかと思いますが、使えない場合には先に easy_install 自体をインストールしましょう。easy_install は RPM の python-setuptools に含まれています。
# yum install -y python-setuptools

easy_install コマンドが使えるようになったら、ユニットテストフレームワークである nose をインストールします。
# easy_install nose
これで準備は完了です。

テストケースの命名規則

nose は、単体テスト実行時のカレントディレクトリから、nose のテストケースの命名規則に基づき再帰的にテストケースを探し、実行してくれます。
細かいルールは公式ドキュメントを確認してほしいのですが、簡単に言うと、モジュール名(ディレクトリ名)、ファイル名、関数名、クラス名、メソッド名に "test" という単語が含まれて入ればテストケースとして認識されます。基本的にはケースセンシティブで評価されるようですが、クラス名の場合は "Test" でも認識されました。名前さえ気をつけてテストケースを書けば、実行するテストケースを一覧で逐一指定していくというようなことは必要はありません。

テストケースを書く

ユニットテストの対象となるコードはこちら。
lib/myfunc.py として置いておきます。
# -*- coding: utf-8 -*-

def add(num1, num2):
    if (num1 is None):
        raise RuntimeError('num1 is None')

    return num1 + num2
処理としては与えられた 2 つの引数を足し合わせるだけです。サンプルコードなのでシンプルなものにしてあります。2 つめの引数の None チェックをしていないのは意図的なものです。


まずはテストケースを普通の関数として定義した例です。
ここでは test/test_myfunc/test_by_funcs.py にテストケースを書いてみます。
# -*- coding: utf-8 -*-

from nose.tools import with_setup, raises
from myfunc import add 

# assert 文で評価する
def test_add_nums():
    actual = add(1, 10) 
    assert actual == 11


# テストケース実行前に実行する関数
def setup_func():
    # 好きなことをする
    pass

# テストケース実行後に実行する関数
def teardown_func():
    # 好きなことをする
    pass

# @with_setup でテストケース実行前/後に実行する関数を指定する
@with_setup(setup_func, teardown_func)
def test_addNumbers():
    actual = add(-1, 1)
    assert actual == 0


# @raises で例外が投げられるかをテストする
@raises(RuntimeError)
def test_invalid_arg1():
    actual = add(None, 1)

# 未実装の機能へのテストケース
# エラーになるはず。
@raises(RuntimeError)
def test_invalid_args2():
    # 入力値チェックで RuntimeError を投げることを機能仕様としたいが、
    # まだ未実装なので + 演算で TypeError が発生する
    actual = add(1, None)
テストケースとなる関数には関数名に test を含めて定義し、内部で assert 文で変数が期待値通りかを評価しています。

@with_setup デコレーションをつけると、任意の関数をそのテストケースの実行前/実行後に実行することができます。test_addNumbers() ではテスト実行前に setup_func() を、実行後に teardown_func() が呼び出されるようにしています。個別設定なので逐一 @with_setup をつけてやる必要がある半面、テスト実行準備のための関数をテストケースによって分けることができます。

@raises デコレーションは、そのテストケースの実行中に期待する例外が投げられるかを評価するユーティリティです。指定した例外が投げられなかった場合、テストケースは失敗となります。



次に、テストケースをクラスのメソッドとして定義した例です。
ここでは test/test_myfunc/test_by_classes.py にテストケースを書いてみます。
# -*- coding: utf-8 -*-

from myfunc import add 

class TestAdd:

    # このクラスのテストケースを実行する前に1度だけ実行する
    @classmethod
    def setup_class(clazz):
        # 好きなことをする
        pass

    # このクラスのテストケースをすべて実行した後に1度だけ実行する
    @classmethod
    def teardown_class(clazz):
        # 好きなことをする
        pass

    # このクラスの各テストケースを実行する前に実行する
    def setup(self):
        # 好きなことをする
        pass

    # このクラスの各テストケースを実行した後に実行する
    def teardown(self):
        # 好きなことをする
        pass

    # assert 文で評価する
    def test_add_nums(self):
        actual = add(1, 10) 
        assert actual == 11
テストケースの書き方自体は関数としてテストケースを書いた場合と違いはありません。クラス名に忘れずに "Test" をつけておきましょう。

テストケースの準備・終了処理については、テストケースを関数として書いた場合よりも選択肢が広がります。

クラスメソッドとして setup_class(), teardown_class() を定義すると、これらのメソッドは、そのクラスのすべてのテストケースが実行される前/後に 1 度だけ実行されます。これは JUnit の @BeforeClass / @AfterClass に該当します。

また、setup(), teardown() を定義すると、これらのメソッドは @with_setup デコレーションで定義したメソッドと同様に、そのクラスの各テストケースが実行される前/後にそれぞれ実行されます。これは JUnit の @Before / @After に該当します。

setup() や setup_class() を定義したクラスを継承した場合、継承した子クラスにも setup() や setup_class() が引き継がれることになるので、これらのメソッドは子クラスのテストケース実行に際しても有効となります。テストケース用クラスの抽象クラスで setup() を定義しておき、これを継承することで、前準備の処理を複数のテストで共有する、という使い方ができます。

ユニットテストを実行する

用意したユニットテストは nosetests コマンドで実行することができます。
デフォルトでは成功したテストケースはとりたてて表示されませんが、-v オプションを指定すると実行したテストケース名を一覧表示してくれるようになります。
$ nosetests -v
test_by_classes.TestAdd.test_add_nums ... ok
test_by_funcs.test_add_nums ... ok
test_by_funcs.test_addNumbers ... ok
test_by_funcs.test_invalid_arg1 ... ok
test_by_funcs.test_invalid_args2 ... ERROR

======================================================================
ERROR: test_by_funcs.test_invalid_args2
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/lib/python2.7/site-packages/nose-1.3.3-py2.7.egg/nose/case.py", line 197, in runTest
    self.test(*self.arg)
  File "/usr/lib/python2.7/site-packages/nose-1.3.3-py2.7.egg/nose/tools/nontrivial.py", line 60, in newfunc
    func(*arg, **kw)
  File "/home/momokan/test/test_myfunc/test_by_funcs.py", line 40, in test_invalid_args2
    actual = add(1, None)
  File "/home/momokan/myfunc.py", line 7, in add
    return num1 + num2
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'

----------------------------------------------------------------------
Ran 5 tests in 0.004s

FAILED (errors=1)
テストケースに失敗した場合、そのテストケースとエラーの内容が出力されます。

テストケースの中で標準出力へ出力した内容は nose によってキャプチャーされるので、実行結果としては表示されません。標準出力を握りつぶしたくない場合には、 -s オプションをつけてください。

エラー発生時にデバッグを行う

nosetests コマンドに --pdb オプションを指定すると、テストケースに失敗した時にテストを中断し、そのまま pdb によるデバッグを行うことができます。pdb は gdb ライクな python のデバッグコマンドで、ステップ実行しながら python プログラムをデバッグすることができます。
$ nosetests --pdb
....> /home/momokan/myfunc.py(7)add()
-> return num1 + num2
(Pdb) 
pdb の主なコマンドを載せておきます。

l : 指定のステップ周辺のソースコードを表示する

引数に行数を指定すると、その周辺のソースコードを表示してくれます。
引数を省略した場合、現在の行の周辺か、前に表示したソースコードの続きを表示してくれます。

w : スタックトレースを表示する

スタックにあるフレームを一覧で表示し、現在スタック上のどのフレームにいるかを表示してくれます。

p : 変数の値を表示する

引数に現在の行から参照可能な変数を指定すると、その変数に格納されている値を表示してくれます。

n : 現在の行を実行し、次の行へ進む

現在の行にある命令を実行し、次の実行行へ処理を進めます。

r : 現在の行がある関数を実行する

現在の行がある関数内の処理をすべて実行し、関数から返り値が返るところまで処理を進めます。

c : 現在の行から処理を続行する

現在の行移行の処理を順次実行していきます。
プログラム内のすべての処理を実行し終えるか、ブレークポイントが張ってある行に出会うかするまで処理は続行されます。

pdb にはこれ以外にもデバッグ用のいろいろな機能がありますので、詳細は公式ドキュメントを確認してみてください。

--pdb-failures オプションを指定した場合はテスト失敗時に、--pdb-errors オプションを指定した場合はエラー発生時に pdb で止めるタイミングを切り替えることができます。

コードカバレッジを測定する

nosetests の --with-coverage オプションでカバレッジを測定してくれます。
通常は標準出力へカバレッジが書き出されますが、HTML ファイルにレポートを出力すると、テストケース実行時に実際に実行されたソースコードを視覚的に表示してくれます。

コードカバレッジを測定するには、追加で coverage モジュールをインストールしておく必要があります。
# easy_install coverage

実際にコードカバレッジを測定するには、nosetests 実行時に --with-coverage オプションを指定します。計測されるコードカバレッジはデフォルトではステートメントカバレッジになるので、--cover-branches オプションを指定して条件分岐の網羅率についても測定するようにしましょう。
また --cover-html オプションをつけると HTML ファイルにカバレッジレポートを出力してくれます。
$ nosetests --with-coverage --cover-branches --cover-html
...
Name     Stmts   Miss Branch BrMiss  Cover   Missing
----------------------------------------------------
myfunc       4      0      2      0   100%   
----------------------------------------------------------------------
Ran 5 tests in 0.006s

FAILED (errors=1)
実行するとカレントディレクトリ内に cover というディレクトリが新しく作られ、その中に HTML 形式でレポートが書き出されます。
nose_coverage.png
モジュール毎にソースコードのどの行を実行したのかを視覚化してくれます。緑色の部分がテストケースを実行する際に処理されたコード、赤色の部分が処理されなかったコードです。--cover-branches オプションを指定している場合、さらに条件分岐の網羅率についても色分けされ、すべての条件分岐が処理されなかった場合には黄色で表示されます。

もちろんコードカバレッジで網羅されているからといって品質が担保されるということにはなりませんが、テストケースを書いていく上でのめやすとして参考にするのはいいと思います。テストされていないという事実を認識する上でも、こういうツールは上手に使っていきたいなーと思います。
  python  コメント (0)  2014/05/29 08:02:50


公開範囲:
プロフィール HN: ももかん
ゲーム作ったり雑談書いたり・・・していた時期が私にもありました。
カレンダー
<<2017, 5>>
30123456
78910111213
14151617181920
21222324252627
28293031123