akiyoko blog

akiyoko の IT技術系ブログです

Python, Django 界隈の単体テスト事情(unittest / nose / django-nose)

前々から、Python, Django 界隈の単体テスト事情をまとめたいと思っていたのですが、こんな素敵なまとめ記事を見つけました。


Python用のユニットテストツールまとめ | TRIVIAL TECHNOLOGIES 4 @ats のイクメン日記


この記事では、unittest, unittest2, doctest, py.test, nose のほか、それこそ聞いたことのないようなものまで取り上げてくれているのですが、今回は、自分で使いそうな範囲のテストツールについて少しディープにまとめてみようと思います。



取り上げるのは、以下の 4種類(「Django + unittest」を unittest としてカウントすると 3種類)のテストツールです。

ほかにも、pytest や doctest も取り上げたかったのですが、まだ使ったことがなかったので割愛します(後で書くことになるかもしれません)。


テストツール 特徴 カバレッジ
unittest Python標準パッケージに含まれている単体テストライブラリ ×
Django + unittest Django の manage.py ユーティリティから、unittest が便利に使うことができる ×
nose 多彩なオプションで柔軟なテストができ、カバレッジを取ることもできる。特定のクラスを継承する必要がない
django-nose Django の manage.py ユーティリティから、nose を使えるようにしたもの


★ 2015/6/12 追記

pytest については、最近出た「Pythonプロフェッショナルプログラミング 第2版」(第1版ではありません)に、詳しめに書かれていました。pytest 派であれば、是非どうぞ。

Pythonプロフェッショナルプログラミング 第2版

Pythonプロフェッショナルプログラミング 第2版



 

1. unittest

Python 2.7 デフォルトで使えるのは、この unittest です。
全てのテストクラスは、親クラスとして「unittest.TestCase」を継承する必要があり、テストメソッド名にも命名ルールが定められています。
 

テストケース命名規則

unittest.main() が呼び出されると、そのスクリプトの中で unittest.TestCase を継承したすべてのクラスがテストケースのかたまりとして認識され、そのメソッドのうち名前の先頭が test で始まるメソッドだけがテストケースとして実行されます。


http://www.lifewithpython.com/2014/03/unittest.html

 

基本的な使い方

### パスを通す
$ export PYTHONPATH=$PYTHONPATH:`pwd -P`

### モジュール単位でテスト実行
$ python tests/test_calc.py -v

「-m unittest」を付けた場合は、「PYTHONPATH」を指定しなくても実行可能です。

### モジュール単位でテスト実行
$ python -m unittest -v tests.test_calc

### クラス単位でテスト実行
$ python -m unittest -v tests.test_calc.TestCalculator

### メソッド単位でテスト実行
$ python -m unittest -v tests.test_calc.TestCalculator.test_add_when_arg_is_int

### テストメソッドを自動検索
$ python -m unittest discover -v

### 複数のテストを実行
$ python -m unittest -v tests.test_calc tests.test_random

 

サンプルコード

.
├── calc.py
└── tests
    ├── __init__.py
    └── test_calc.py

calc.py

class Calculator(object):
    def __init__(self, num1):
        self.num1 = num1

    def add(self, num2):
        if num2 is None:
            raise Exception("Arg should not be None.")
        return self.num1 + num2

tests/test_calc.py

import unittest                                                                                        

import calc


class TestCalculator(unittest.TestCase):

    def setUp(self):
        self.calculator = calc.Calculator(2)

    def test_add_when_arg_is_int(self):
        expected = 3 
        actual = self.calculator.add(1)
        self.assertEqual(expected, actual)

    def test_add_when_arg_is_float(self):
        expected = 3.1 
        actual = self.calculator.add(1.1)
        self.assertEqual(expected, actual)

    def test_add_when_arg_is_str(self):
        self.assertRaises(TypeError, self.calculator.add, '1')

    def test_add_when_arg_is_none(self):
        with self.assertRaises(Exception) as em: 
            self.calculator.add(None)
        e = em.exception
        self.assertEqual("Arg should not be None.", str(e))


if __name__ == "__main__":
    unittest.main()

 

TestSuite を使ってまとめてテストを実行する方法

tests/test_suite.py

# -*- coding: utf-8 -*-                                                                                
import unittest                                                                                            

from tests import test_calc, test_random


if __name__ == "__main__":
    loader = unittest.TestLoader()
    suite = unittest.TestSuite()
    # テストメソッド単位で追加
    suite.addTest(test_calc.TestCalculator('test_add_when_arg_is_int'))
    # テストクラス単位で追加
    suite.addTest(unittest.makeSuite(test_calc.TestCalculator))
    suite.addTest(loader.loadTestsFromTestCase(test_calc.TestCalculator))
    # テストモジュール単位で追加
    suite.addTest(loader.loadTestsFromModule(test_random))
    # テストスイートを実行
    unittest.TextTestRunner(verbosity=2).run(suite)

使い方

$ python tests/test_suite.py -v

 

2. Django + unittest

Django には、manage.py のサブコマンドに「test」を指定することで unittest ベースのテストが実行できるユーティリティコマンドが用意されており、また、Django のテストをより簡便にするために「unittest.TestCase」をラップした「django.test.TestCase」も用意されています。

Django のテストランナは、以下の二つの場所からユニットテス トを探します:

models.py ファイル。テストランナはこのモジュールから unittest.TestCase のサブクラスを探します。
アプリケーションディレクトリ、すなわち models.py の入ったディレク トリ下に置かれた tests.py という名前のファイル。上と同様に、テス トランナはこのモジュールから unittest.TestCase のサブクラスを探し ます。


http://django-docs-ja.readthedocs.org/en/latest/topics/testing.html

 

使い方

テストを実行するには、プロジェクトの manage.py ユーティリティを使います:

$ ./manage.py test

特定のアプリケーションに対してテストを実行したければ、コマンドラインにアプ リケーションの名前を追加します。例えば、 INSTALLED_APPS に myproject.polls と myproject.animals というアプリケーションが入って おり、 animals の単体テストを実行したいだけなら、以下のようにします:

$ ./manage.py test animals


http://django-docs-ja.readthedocs.org/en/latest/topics/testing.html#running-tests

### 全テストを実行(ただし、Django 本体のテストも全て実行してしまう)
$ python manage.py test -v 2

### アプリケーション単位でテスト実行
$ python manage.py test polls -v 2
$ python manage.py test polls animals -v 2

### モジュール単位でテスト実行
(不可??)

### クラス単位でテスト実行
$ python manage.py test polls.PollMethodTests -v 2

### メソッド単位でテスト実行
$ python manage.py test polls.PollMethodTests.test_was_published_recently_with_future_poll -v 2

 

サンプルコード

.
├── LICENSE
├── README.md
├── manage.py
├── mysite
│   ├── __init__.py
│   ├── db.sqlite3
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── polls
    ├── __init__.py
    ├── admin.py
    ├── models.py
    ├── templates
    │   └── polls
    │       ├── detail.html
    │       ├── index.html
    │       └── results.html
    ├── tests.py
    ├── urls.py
    └── views.py


polls/tests.py
https://github.com/akiyoko/django-poll-app/blob/master/polls/tests.py

 

(参考)

django.test.TestCaseを使うとリクエスト生成などが容易にテストできる




 

3. nose

nose には様々なオプションが用意されていて、柔軟にテストを実行することができ、カバレッジも取ることができます。

なお、 nose は Python標準パッケージに含まれていないため、pip install が必要です。
 

事前準備

nose を pip でインストールします。
必要に応じて、(カバレッジを取得するための)coverage も pip install しておきます。

$ sudo pip install nose
$ sudo pip install coverage

 

テストケース命名規則

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


Python nose でユニットテストを書いてみた / 桃缶食べたい。」より

特に、テストクラス名については、「TestXxxx」のようにクラス名の先頭が「Test」で始まらないとテストケースとして見なしてくれないので要注意です(「XxxxTest」だとダメ)。

 

基本的な使い方

### モジュール単位でテスト実行
$ nosetests -v tests.test_calc
$ nosetests -v tests/test_calc.py

### クラス単位でテスト実行
$ nosetests -v tests.test_calc:TestCalculator
$ nosetests -v tests/test_calc.py:TestCalculator

### メソッド単位でテスト実行
$ nosetests -v tests.test_calc:TestCalculator.test_add_when_arg_is_int
$ nosetests -v tests/test_calc.py:TestCalculator.test_add_when_arg_is_int

### テストメソッドを自動検索
$ nosetests -v

### 複数のテストを実行
$ nosetests -v tests.test_calc tests.test_random
$ nosetests -v tests/test_calc.py tests/test_random.py

### エラーになったときに pdb を起動
$ nosetests --pdb

その他の重要なオプションとして、「-s」を付けると、テストコード中の print文の出力もされるようになります。

カバレッジ取得方法

$ nosetests --with-coverage --cover-html --cover-erase

### 条件網羅(C1)カバレッジを取得する場合
$ nosetests --with-coverage --cover-branches --cover-html --cover-erase

$ coverage html --include './*' --omit 'tests/*'
オプション 効果
--cover-html HTMLファイルとしてカバレッジレポートを出力
--cover-erase テスト実行前に前回のテスト結果を削除

f:id:akiyoko:20141231193342p:plain
f:id:akiyoko:20141231193352p:plain
 

サンプルコード

.
├── calc.py
└── tests
    ├── __init__.py
    └── test_calc.py

calc.py
(同上)


tests/test_calc.py

import nose
from nose.tools import raises, assert_true, assert_equal, assert_raises

import calc


class TestCalculator(object):

    def setup(self):
        self.calculator = calc.Calculator(2)

    def test_add_when_arg_is_int(self):
        expected = 3
        actual = self.calculator.add(1)
        assert_equal(expected, actual)

    def test_add_when_arg_is_float(self):
        expected = 3.1
        actual = self.calculator.add(1.1)
        assert_equal(expected, actual)

    @raises(TypeError)
    def test_add_when_arg_is_str(self):
        self.calculator.add('1')

    def test_add_when_arg_is_none(self):
        with assert_raises(Exception) as em:
            self.calculator.add(None)
        e = em.exception
        assert_equal("Arg should not be None.", str(e))

ちなみに、ok_, eq_ といった assert_true, assert_equal のエイリアスも用意されています(が、名前が気に入らないので個人的には使いません・・)。

 
なお、

if __name__ == "__main__":
    import nose
    nose.main(argv=['nose', '-v'])

というコードを加えておくと、

$ export PYTHONPATH=$PYTHONPATH:`pwd -P`
$ python tests/test_calc.py

という形でも、テストコードを実行することができます。



 

4. django-nose

Django上で「Django + unittest」のような使い方をすることができ、さらに「nose」の柔軟なオプションが使えるようにしたのが、この「django-nose」です。


django-nose は Python標準パッケージに含まれていないので、pip install が必要です(なお、nose も必要なのですが、django-nose インストール時に自動的にインストールされます)。
 

事前準備

django-nose をインストールします。
必要に応じて、(カバレッジを取得するための)coverage も pip install しておきます。

$ sudo pip install django-nose
$ sudo pip install coverage

settings.py に以下を追加します。

INSTALLED_APPS = (
    ・
    ・
    'django_nose',
    'polls',
)

TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'

TEST_APPS = (
    'polls',
)

NOSE_ARGS = [
    '--with-coverage',
    '--cover-package=' + ",".join(TEST_APPS),
    '--cover-branches',
    '--cover-erase',
]

nosetests のオプションは上記のように「NOSE_ARGS」に書くか、あるいは

$ python manage.py test polls/tests.py --with-coverage --cover-package=polls --cover-branches --cover-erase

のように直接、実行時に指定します。

 

テストケース命名規則

「Django + unittest」方式のテストケース命名規則と「nose」方式のテストケース命名規則のどちらでも、テストケースとして見なしてくれる(探索してくれる)ようです。

 

基本的な使い方

### 全テストを実行
$ python manage.py test -v 2

### アプリケーション単位でテスト実行
$ python manage.py test polls -v 2

### モジュール単位でテスト実行
$ python manage.py test polls.tests -v 2
$ python manage.py test polls/tests.py -v 2

### クラス単位でテスト実行
$ python manage.py test polls.tests:PollMethodTests -v 2
$ python manage.py test polls/tests.py:PollMethodTests -v 2

### メソッド単位でテスト実行
$ python manage.py test polls.tests:PollMethodTests.test_was_published_recently_with_future_poll -v 2
$ python manage.py test polls/tests.py:PollMethodTests.test_was_published_recently_with_future_poll -v 2

 

カバレッジ取得方法

$ coverage html --include 'polls/*' --omit '*/tests/*'


 

5. pytest

正直、まだ使ったことがありません。

nose, unittest.py, doctest.py を統合したテスト実行や、様々なオプションで柔軟なテストが実行できるようです。もちろん、カバレッジも取得可能です。

使い方

$ python -m pytest [...]
$ py.test test_mod.py   # モジュール内のテストを実行
$ py.test somepath      # 指定したパスの全てのテストを実行

使用方法とテスト実行」より