Pythonのデバッグとテストモジュール

  • 56
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

デバッグとユニットテスト

assert文

変数testが偽ならAssertionError例外を発生させる。

test = 0
data = "assertion error"

try:
    assert test,data
except AssertionError:
    print data
finally:
    print "the end"

assert文の部分のみなら、次のように書く事で代用もできる。

if __debug__:
  if not test
    raise AssertionError, data

例外の分離処理や内容の表示

somedata = 1

# キャッチしたい例外をタプルでまとめておく。
fatal_exceptions = (KeyboardInterrupt, MemoryError)

try:
    assert somedata
except fatal_exceptions, inst:  # 引数instで例外の内容を受け取ることができる。
    print type(inst)    # 例外の型を表示
    print inst          # 例外の内容を表示
    raise
# それ以外の例外をまとめてキャッチ
except Exception, inst:
    print type(inst)    # 例外の型を表示
    print inst          # 例外の内容を表示
finally:
    print "the end"

 

UnitTest

公式のチュートリアルからの例を少し修正と追加。

import random
import unittest

class TestSequenceFunctions(unittest.TestCase):

    # 毎回呼ばれる初期化処理
    # この他にテスト実行後などに呼ばれるメソッドがある
    def setUp(self):
        self.seq = range(10)

    # testで始まるメソッド名を記述する。
    def test_shuffle(self):
        random.shuffle(self.seq)
        self.seq.sort()

        # 二つの引数がイコールであることをチェックする。
        # 等しくないことのチェックはassertNotEqual()で行える。
        self.assertEqual(self.seq, range(10))

        # 例外が発生することのチェックを行う。
        # assertRaises(exception, callable, *args, **kwds)
        # 第二引数の関数にargsとkwdsを渡して、第一引数で指定した例外が発生することをチェックする。
        self.assertRaises(TypeError, random.shuffle, (1,2,3))

    def test_choice(self):
        element = random.choice(self.seq)

        # 引数の値がTrueであることをチェックする。
        # bool(element) is True と等価
        self.assertTrue(element in self.seq)

    def test_sample(self):

        # exception 引数のみが渡された場合には、コンテキストマネージャが返される。
        # インラインでテスト対象のコードを書くことができる。
        with self.assertRaises(ValueError):
            random.sample(self.seq, 20)
        for element in random.sample(self.seq, 5):
            self.assertTrue(element in self.seq)

if __name__ == '__main__':

    # main()で実行できる。
    unittest.main()

    # 個別に実行するテストを取得する。
    _test_choice = TestSequenceFunctions('test_choice')
    _test_sample = TestSequenceFunctions('test_sample')

    # テストスイートに登録してランナーでまとめて実行できる。
    TestSuite = unittest.TestSuite()
    TestSuite.addTest(_test_choice)
    TestSuite.addTest(_test_sample)
    runner = unittest.TextTestRunner()
    runner.run(TestSuite)

    # ローダーでまとめてテスト関数を取得できる。
    suite = unittest.TestLoader().loadTestsFromTestCase(TestSequenceFunctions)
    unittest.TextTestRunner(verbosity=2).run(suite)

 

traceback

スタックとは現在プログラムを実行しているメモリ領域である。
スタックトレースとは、そのメモリ領域のプログラムの実行状態(関数名、呼び元関数名、行、命令文、ファイル名)などの状態を記録したものであり、Pythonのようなスクリプト言語はこれがオブジェクトとして提供されている。

まずスタックトレースの情報はsys.exc_info() から取得することができる。

exc_type, exc_value, exc_traceback = sys.exc_info()

この三つの変数を引数に使用して、traceback.print_tb()やtraceback.print_exception()で情報を表示する。

しかし、下記のtraceback.print_exc()を使うと、sys.exc_info()からの変数取得が省略できて、しかもタイプや内容も表示できるので便利。基本的にはこれを使用すればいい。

import sys
import traceback

def somework():
    try:
        print a # 定義されていない変数を使用する
    except Exception:
        print "error"
        traceback.print_exc(file=sys.stdout)
    finally:
        print "the end"

if __name__ == '__main__':
    somework()

出力結果
モジュール名、行、関数名、原因の命令文、原因のエラーメッセージが出力される。

error
Traceback (most recent call last):
  File "/Users/test.py", line 8, in somework
    print a
NameError: global name 'a' is not defined
the end

こちらはtracebackを書かなかった場合の出力。
例外を通過するだけで何も出力されない。

error
the end

次に、スタックトレース情報をタプルを含んだリストで取得する方法を示す。
こちらはsys.exc_info()から変数を取得する例も載せておく。(それを引数に使っているから)

import sys
import traceback

def somework():
    try:
        print a # 定義されていない変数を使用する
    except Exception:
        print "error"
        exc_type, exc_value, exc_traceback = sys.exc_info()
        print traceback.extract_tb(exc_traceback) # 現在の情報だけを取得
        print traceback.extract_stack() # 呼び出し元の関数情報を含むタプルを取得

        # タプルではなく単にリストで受け取って表示しやすくしたい場合はこれを呼ぶ。
        traceback.format_tb(exc_traceback)
        traceback.format_stack()

        print "the end"

if __name__ == '__main__':
    somework()

nosetest

『Pythonプロフェッショナルプログラミング』を参考にさせていただきました。

noseのインストール

pip install nose

テスト対象のクラス
このクラスは単純に銀行のアカウントに数字を足したり減らしたりするだけ。

# coding: UTF-8

class NotEnoughFundsException(Exception):
    pass

class BankAccount(object):

    def __init__(self):
        self._balance = 0

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount

    def get_balance(self):
        return self._balance

    def set_balance(self, value):
        if value < 0:
            raise NotEnoughFundsException

        self._balance = value

    # ゲッターとセッターを使えるようにラッパーを用意する。
    # balanceに=や+でアクセスすることでこれらの関数が自動で呼ばれるようになる。
    balance = property(get_balance, set_balance)

noseでテストするコードを書く。
ここで100や0などのテスト用の固定の値を準備することをデータフィクスチャという。

# coding: UTF-8

import unittest

class BankAccountTest(unittest.TestCase):

    def _getTarget(self):
        from bankaccount import BankAccount
        return BankAccount

    # テスト対象のインスタンスを作成する。
    def _makeOne(self, *args, **kwargs):
        return self._getTarget()(*args, **kwargs)

    def test_construct(self):
        target = self._makeOne()
        self.assertEqual(target._balance, 0)

    def test_deposit(self):
        target = self._makeOne()
        target.deposit(100)
        self.assertEqual(target._balance, 100)

    def test_withdraw(self):
        target = self._makeOne()
        target._balance = 100
        target.withdraw(20)
        self.assertEqual(target._balance, 80)

    def test_get_blance(self):
        target = self._makeOne()
        target._balance = 500
        self.assertEqual(target.get_balance(), 500)

    def test_set_balance_not_enough_funds(self):
        target = self._makeOne()
        from bankaccount import NotEnoughFundsException
        try:
            target.set_balance(-1)
            self.fail()
        except NotEnoughFundsException:
            pass

上記二つを同じディレクトリに配置し、そのディレクトリでnoseを実行する。

nosetest

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

OK

 

カバレッジの取得

pip install coverage
# nosetests -v --with-coverage
test_construct (test_bankaccount.BankAccountTest) ... ok
test_deposit (test_bankaccount.BankAccountTest) ... ok
test_get_blance (test_bankaccount.BankAccountTest) ... ok
test_set_balance_not_enough_funds (test_bankaccount.BankAccountTest) ... ok
test_withdraw (test_bankaccount.BankAccountTest) ... ok

Name          Stmts   Miss  Cover   Missing
-------------------------------------------
bankaccount      16      0   100%
----------------------------------------------------------------------
Ran 5 tests in 0.006s

OK

見やすいカバレッジが出力されます。

「--with-xunit」オプションでXUNIT形式で出力する。

# nosetests -v -w . --with-coverage --with-xunit
test_construct (test_bankaccount.BankAccountTest) ... ok
test_deposit (test_bankaccount.BankAccountTest) ... ok
test_get_blance (test_bankaccount.BankAccountTest) ... ok
test_set_balance_not_enough_funds (test_bankaccount.BankAccountTest) ... ok
test_withdraw (test_bankaccount.BankAccountTest) ... ok

----------------------------------------------------------------------
XML: nosetests.xml
Name          Stmts   Miss  Cover   Missing
-------------------------------------------
bankaccount      16      0   100%
----------------------------------------------------------------------
Ran 5 tests in 0.006s

OK

jenkinsで読めるようにXMLで出力する。

coverage xml

mock

普通の使い方

テスト対象のクラス
testモジュールのTest.py

# coding: UTF-8

class Widget(object):

    def __init__(self):
        self.value = 10

    def Additional(self, add):
        self.value += add
        return self.value

テストコード

from mock import Mock
from test import Test

def mock_value(value):
    return 20 + value

if __name__ == '__main__':

    # 関数を置き換えて、100を固定で返すモックにする。
    Test.Widget.Additional = Mock(return_value=100)

    w = Test.Widget()
    print w.Additional(1)

    # 固定値ではなく、なんらかの計算をする場合はside_effectでダミー関数を渡す。
    Test.Widget.Additional = Mock(side_effect=mock_value)
    print w.Additional(1)
100
21

クラスをモックに置き換える

from mock import Mock
from test import Test

def mock_value(value):
    return 20 + value

if __name__ == '__main__':

    # クラス自体を置き換える。
    Test.Widget = Mock()
    # 関数の返す値を固定してしまう。
    Test.Widget.return_value.Additional.return_value = 10

    w = Test.Widget()
    print w.Additional(1)

    # 関数を入れ替える
    Test.Widget.return_value.Additional = mock_value
    print w.Additional(1)

patch

関数実行中のmockオブジェクトと本物のオブジェクトを差し替える。

# coding: UTF-8

from mock import patch

# この関数実行中だけ置き換えるメソッドと戻り値を指定
@patch("Test.Widget.Additional", return_value=10)
def test_func(m): 

    import Test
    w = Test.Widget()
    print w.Additional(1)
    assert w.Additional(1) == 10

    # ちなみに引数で受け取ったmはモックに置き換えられたAdditional関数
    # だからこちらも結果は10になる。
    # 今回はメソッドを置き換えたが、クラスの場合はクラスになる。
    print m()

if __name__ == '__main__':
    test_func()

withを使ったコンテキストマネージャ形式の場合

# coding: UTF-8

from mock import patch

def test_func():

    # このwithスコープ実行中だけ置き換えるメソッドと戻り値を指定
    with patch("Test.Widget.Additional", return_value=10) as m:
        import Test
        w = Test.Widget()
        print w.Additional(1)
        assert w.Additional(1) == 10
        print m()

if __name__ == '__main__':
    test_func()

 

クラス型指定

mock = Mock(spec=SomeClass)
isinstance(mock, SomeClass) #これが成功する

 

Mockに特定のアトリビュートを持たせる

# coding: UTF-8
from mock import Mock, patch

if __name__ == '__main__':
    # methodという名前で3を返すメソッドと、
    # otherという名前でKeyError例外を発生させるメソッド
    attrs = {'method.return_value': 3, 'other.side_effect': KeyError}

    # 宣言と同時にアトリビュート追加もできる(some_attribute)。
    mock = Mock(some_attribute='eggs', **attrs)

    print mock.some_attribute
    print mock.method()
    print mock.other()

その他 

他に便利そうなのは、

@patch('sys.stdout', new_callable=StringIO)

で、生成時にStringIOのオブジェクトとして生成してくれる。

わざわざmockを使わなくてもテスト用の疑似クラスを書いて「Test.Widget = 」で代入してもいい。mockのバリデーション機能を使わないのなら、そちらの手段の方がはやいかもしれない。

django-webtestモジュール

Djangoのテストに非常に便利です。

pip install webtest
pip install django-webtest

Djangoのアプリケーションは次のようになっている。

mysite
|-mysite
|   |-__init__.py
|   |-settings.py
|   |-urls.py
|   |-wsgi.py
|-test app
|   |-__init__.py
|   |-form.py
|   |-modes.py
|   |-views.py
|   |-tests.py  このファイルに今回のテストを書く|
|-templates
|-manage.py

tests.pyの内容を次のように書く

from django_webtest import WebTest

class TestIndex(WebTest):

    def test_index(self):
        res = self.app.get("/") #レスポンスを取得する

        # ステータスコードやコンテンツ内容をチェックする。
        assert res.status == '200 OK'
        assert 'html' in res

manage.py runserverで開発サーバーを立ち上げておいてから、次のコマンドでテストを実行する。

# sudo python manage.py test testapp

出力結果

Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.175s

OK
Destroying test database for alias 'default'...