Pythonで本格的?にテストを書き始めました。標準ライブラリの unittest が普通にまあまあ使いやすいので、使い方をまとめていきます。

基本的な書き方

基本は以下の構造になります。

import unittest

class TestSimple(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        """各クラスが実行される直前に一度だけ呼び出される"""
        print('setUpClass called.')

    @classmethod
    def tearDownClass(cls):
        """各クラスが実行された直後に一度だけ呼び出される"""
        print('tearDownClass called.')

    def setUp(self):
        """各テストメソッドが実行される直前に呼び出される"""
        print('setUp called.')

    def tearDown(self):
        """各テストメソッドが実行された直後に呼び出される"""
        print('tearDown called.')

    def test_lower(self):
        print(f'test_lower called. self_id: {id(self)}')
        self.assertEqual('HOGE'.lower(), 'hoge')

    def test_upper(self):
        print(f'test_upper called. self_id: {id(self)}')
        self.assertEqual('hoge'.upper(), 'HOGE')

    @unittest.skip("this test method will be skipped")
    def test_split(self):
        print(f'test_split called. self_id: {id(self)}')
        self.assertEqual(len('aa bb'.split()), 2)

if __name__ == '__main__':
    unittest.main()
  • setUpClass / tearDownClass
    • 各クラスが実行される直前と直後に一度だけ実行したい共通処理を記載する
  • setUp / tearDown
    • 各テストメソッドが実行される直前と直後に実行したい共通処理を記載する
    • テストメソッド毎に別インスタンス(selfはテストメソッド毎に別物)になるので、ここでインスタンス変数に何か設定して、テストメソッドで適宜利用することが多いと思われる
  • tearDownClass / tearDown
    • テストが失敗した場合も実行される
    • ただし、Ctrl-C (SIGINT)を実行するとKeyboardInterrupt例外がraiseされ、実行されないので要注意(絶対実行される訳ではない)
  • @unittest.skip
    • 対象テストをスキップするためのデコレータ。条件とかも書ける
  • unittest.main()
    • 実行時にpython -m unittestのようにunittestモジュールを指定しなくてもテストを実行できるようになる
  • Pythonファイル名
    • test*.pyにしておくと、テストディスカバリの検索パターンのデフォルト値なので、少し便利
  • テストメソッド名
    • test_*の形式のメソッドがテスト対象となる(この例ではtest_lower test_upper
  • assert用関数
    • unittest.TestCaseのインスタンスメソッドとして定義されている
    • self.assertEqual()self.assertIsNone()self.assertRaises()のような感じで呼び出す
    • 非推奨のエイリアスがあるのでそちらは使わないように

実行すると以下のログが出力されます。実行順番、インスタンス、スキップなどを確認できます。

$ python test_simple.py 
setUpClass called.                       # クラス単位の前処理
setUp called.                            # テストメソッド単位の前処理
test_lower called. self_id: 4555136160   # テストメソッドの実行
tearDown called.                         # テストメソッド単位の後処理
setUp called.                            # テストメソッド単位の前処理
test_upper called. self_id: 4547337472   # テストメソッドの実行(一つ目と異なるインスタンス)
tearDown called.                         # テストメソッド単位の後処理
tearDownClass called.                    # クラス単位のあと処理

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK (skipped=1)                           # 一つのテストメソッドがskipされた

実行方法

スクリプトとして実行

unittest.main()を各テストファイルに実装することで、実行時にpython -m unittestのようにunittestをモジュールとして実行しなくても対象テストファイルをスクリプトとして実行できるようになるようです。

# 特定のテストファイルを実行する
python test_simple.py

# 特定のテストファイル内の特定のテストケースを実行する
python test_simple.py TestSimple

# 特定のテストファイル内の特定のテストメソッドを実行する
python test_simple.py TestSimple.test_upper

unittestをモジュールとして実行

-m unittestでunittestをモジュールとして実行することができます。まずはhelpを確認してみます。

$ python -m unittest -h
usage: python -m unittest [-h] [-v] [-q] [--locals] [-f] [-c] [-b] [-k TESTNAMEPATTERNS] [tests [tests ...]]

positional arguments:
  tests                a list of any number of test modules, classes and test methods.

optional arguments:
  -h, --help           show this help message and exit
  -v, --verbose        Verbose output
  -q, --quiet          Quiet output
  --locals             Show local variables in tracebacks
  -f, --failfast       Stop on first fail or error
  -c, --catch          Catch Ctrl-C and display results so far
  -b, --buffer         Buffer stdout and stderr during tests
  -k TESTNAMEPATTERNS  Only run tests which match the given substring

Examples:
  python -m unittest test_module               - run tests from test_module
  python -m unittest module.TestClass          - run tests from module.TestClass
  python -m unittest module.Class.test_method  - run specified test method
  python -m unittest path/to/test_file.py      - run tests from test_file.py

usage: python -m unittest discover [-h] [-v] [-q] [--locals] [-f] [-c] [-b] [-k TESTNAMEPATTERNS] [-s START] [-p PATTERN] [-t TOP]

optional arguments:
  -h, --help            show this help message and exit
  -v, --verbose         Verbose output
  -q, --quiet           Quiet output
  --locals              Show local variables in tracebacks
  -f, --failfast        Stop on first fail or error
  -c, --catch           Catch Ctrl-C and display results so far
  -b, --buffer          Buffer stdout and stderr during tests
  -k TESTNAMEPATTERNS   Only run tests which match the given substring
  -s START, --start-directory START
                        Directory to start discovery ('.' default)
  -p PATTERN, --pattern PATTERN
                        Pattern to match tests ('test*.py' default)
  -t TOP, --top-level-directory TOP
                        Top level directory of project (defaults to start directory)

For test discovery all test modules must be importable from the top level directory of the project.

だいたい使い方が分かりますね。

  • discoverの有無
    • discover(テストディスカバリ)に引数を渡したい時だけ、discoverを指定する
  • -f, --failfast
    • 最初の失敗かエラーが発生した時点でテストを終了する
  • -c, --catch
    • Ctrl-C (SIGINT)をキャッチして、ちゃんとtearDown()/tearDownClass()を実行してくれるようになる
    • 途中でテスト止めたくなることはよくあるので、個人的には必須

基本的な実行方法は以下のような感じでした。

# 特定のテストモジュール/ファイルを実行する
python -m unittest test_simple

# 特定のテストモジュール内の特定のテストケースを実行する
python -m unittest test_simple.TestSimple

# 特定のテストモジュール内の特定のテストメソッドを実行する
python -m unittest test_simple.TestSimple.test_upper

複数のテストスクリプト(テストケース)をまとめて実行

まとめて実行する際は、テストディスカバリ(discover)の機能を確認する必要があります。先ほど確認したhelpのうち、以下の三つがdiscoverの引数になります。

  • -s START, --start-directory START
    • このフォルダ配下のテストを探索する。デフォルトはカレントディレクトリ
  • -p PATTERN, --pattern PATTERN
    • マッチさせるテストファイル名のパターン。デフォルトはtest*.py
  • -t TOP, --top-level-directory TOP
    • プロジェクトのトップレベルディレクトリ。このディレクトリからの相対パスがパッケージのパスになる。デフォルトはstart-directory

同じフォルダ配下をまとめて実行

「テストファイルの名前がtestで始まり」「同じフォルダに配置されている」場合、デフォルト値のままで何も引数を指定せずテストスクリプトをまとめて実行することができます。

python -m unittest
# これは以下と同じ
python -m unittest discover -s "." -p "test*.py" -t "."
python -m unittest discover --start-directory "." --pattern "test*.py" --top-level-directory "."

子フォルダも含めてまとめて実行

子フォルダにテストスクリプトが配置されている場合、ドキュメントに

テストディスカバリに対応するには、全テストファイルはプロジェクトの最上位のディスカバリからインポート可能な モジュール かパッケージ でなければなりません

バージョン 3.4 で変更: ディスカバリが 名前空間パッケージ をサポートしました。

と書いてあり、名前空間パッケージに対応したのであれば、sys.pathに追加してあげれば良いのかなと思い、環境変数PYTHONPATHに追加してみたのですが、それだけでは子フォルダ配下のテストファイルを認識してくれませんでした。

結局、子フォルダに__init__.pyを配置する(つまりレギュラーパッケージにする)ことで子フォルダ配下のテストファイルも認識してくれるようになりました。
ちなみに、孫フォルダにテストケースを配置する場合は、子フォルダ・孫フォルダの両方に__init__.pyを配置する必要がありました。

├── child
│   ├── __init__.py            # これを配置する
│   └── test_simple_child.py
└── test_simple.py

この状態で親フォルダにcdして、以下のコマンドを実行すると、親フォルダ+子フォルダ配下のテストを全てまとめて実行してくれました。

python -m unittest

BaseTestCaseクラスを作る(スクレイピングを例に)

実際のアプリケーションでは、テストしたいPythonモジュール/クラス単位に、一からテストケースクラスを作るのは大変なので、ベースとなるテストケースクラスを作って、各テストクラスはそれを継承すると良いと思います。

今回は、Webサーバを起動してスクレイピングのテストを行うためのBaseTestCaseクラスを作ってみようと思います。

フォルダ構成は以下の通りです。

├── document_roots
│   └── TestScraping               # テストケースクラス毎のドキュメントルート。フォルダ名=テストケースクラス名
│       └── hello_world.html
├── base_test_case.py              # スクレイピング用のBaseTestCaseクラス
└── test_scraping.py               # 各テストケースクラス

まず、base_test_case.pyBaseTestCaseを実装します。
役割は、テストケースクラスの実行前に、テストケースクラス用のドキュメントルート配下のファイルをWebサーバでserveし、テストが完了したらWebサーバを停止することです。

import unittest
from os.path import exists
import asyncio
from http.server import SimpleHTTPRequestHandler
from socketserver import TCPServer
import _thread
import requests
import time

def fire_and_forget(f):
    """対象関数を非同期で投げっぱなしにするためのデコレータ"""
    def wrapped(*args, **kwargs):
        return asyncio.get_event_loop().run_in_executor(None, f, *args, *kwargs)
    return wrapped

@fire_and_forget
def start_web_server(port_num: int, test_class_name: str):
    """テスト用のWEBサーバを起動する"""
    document_root: str = f'document_roots/{test_class_name}'
    if not exists(document_root):
        raise FileNotFoundError(f'document root is not exists. document_root: {document_root}')

    class __Handler(SimpleHTTPRequestHandler):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, directory=document_root, **kwargs)

        def do_POST(self):
            if self.path.startswith('/kill_server'):
                def kill_me_please(server):
                    server.shutdown()
                _thread.start_new_thread(kill_me_please, (httpd,))
                self.send_error(500)
                print(f'webserver stopped. port_num: {port_num}, document_root: {document_root}')

    TCPServer.allow_reuse_address = True
    with TCPServer(('', port_num), __Handler) as httpd:
        print(f'webserver started. port_num: {port_num}, document_root: {document_root}')
        httpd.serve_forever()

def stop_web_server(port_num: int):
    """テスト用のWEBサーバを停止する"""
    requests.post(f'http://localhost:{port_num}/kill_server')

class BaseTestCase(unittest.TestCase):
    port_num: int = 9000  # default値。継承先で上書きして利用する

    @classmethod
    def setUpClass(cls):
        print('BaseTestCase.setUpClass started.')
        start_web_server(cls.port_num, cls.__name__)
        time.sleep(1)  # Webサーバが立ち上がるのを1秒待機
        print('BaseTestCase.setUpClass finished.')

    @classmethod
    def tearDownClass(cls):
        print('BaseTestCase.tearDownClass started.')
        stop_web_server(cls.port_num)
        print('BaseTestCase.tearDownClass finished.')

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

実装上のポイントは以下です。

  • start_web_server()でWebサーバを起動するが、同期的に実行するとhttpd.serve_forever()で永遠待ってしまうため、@fire_and_forgetデコレータで投げっぱなしになるようにしている
    • 興味がある方は別記事で解説してるのでそちらを参照ください
  • setUpClass()でWebサーバを起動し、tearDownClass()でサーバを停止する
    • テストメソッド単位でサーバを起動/停止したい場合は、setUp() tearDown()に実装すればOK

次に、test_scraping.pyTestScrapingクラスを実装します。

import requests
from requests import Response
from scraping_base_test_case import BaseTestCase  # type: ignore
from bs4 import BeautifulSoup

class TestScraping(BaseTestCase):

    @classmethod
    def setUpClass(cls):
        print('TestScraping.setUpClass started.')
        cls.port_num = 9001
        super().setUpClass()
        print('TestScraping.setUpClass finished.')

    def test_get_body_text(self):
        print('test_get_body_text started.')
        response: Response = requests.get(f'http://localhost:{TestScraping.port_num}/hello_world.html')
        soup: BeautifulSoup = BeautifulSoup(response.text)
        self.assertEqual(soup.body.text, 'hello world!!')
        print('test_get_body_text finished.')

    def test_get_body_length(self):
        print('test_get_body_length started.')
        response: Response = requests.get(f'http://localhost:{TestScraping.port_num}/hello_world.html')
        soup: BeautifulSoup = BeautifulSoup(response.text)
        self.assertEqual(len(soup.body.text), 13)
        print('test_get_body_length finished.')

実装上のポイントは以下です。

  • BaseTestCaseを継承する
  • setUpClass()でこのクラス専用のポート番号(port_num)を指定した上で、super().setUpClass()を実行する
    • テストケースクラスやメソッド別に共通処理の振る舞いを変えたい場合は、setUp()setUpClass()をオーバーライドして、clsselfに値を設定した上でsuper()で親クラスの処理を呼び出してあげればよさそう
    • 設定ファイルを作って読み込む手もありそうだけど、ソースコードから追いにくいのでやめておいた
    • そもそもsetUp()等を使わずデコレータで作り込んでも良さそう(例えばport番号を渡してデコレータでWebサーバを立ち上げる)だが、せっかくunittestが提供してくれている機能と被るし、独自感が強すぎるのでやめておいた
  • test_get_body_text() test_get_body_length()の二つのテストメソッドを定義している

最後にhello_world.htmlを実装します。hello world!!って表示するだけのシンプルなHTMLファイルです。

<html><body>hello world!!</body></html>

この状態で、test_scraping.pyを実行すると以下のようにログが出力されます。

$ python test_scraping.py
TestScraping.setUpClass started.
BaseTestCase.setUpClass started.
webserver started. port_num: 9001, document_root: document_roots/TestScraping  # ここでWebサーバを起動
BaseTestCase.setUpClass finished.
TestScraping.setUpClass finished.
test_get_body_length started.  # 一つ目のテスト
127.0.0.1 - - [29/Nov/2020 12:30:52] "GET /hello_world.html HTTP/1.1" 200 -
test_get_body_length finished.
test_get_body_text started.    # 二つ目のテスト
127.0.0.1 - - [29/Nov/2020 12:30:52] "GET /hello_world.html HTTP/1.1" 200 -
test_get_body_text finished.
BaseTestCase.tearDownClass started.
127.0.0.1 - - [29/Nov/2020 12:30:52] code 500, message Internal Server Error
127.0.0.1 - - [29/Nov/2020 12:30:52] "POST /kill_server HTTP/1.1" 500 -
webserver stopped. port_num: 9001, document_root: document_roots/TestScraping  # ここでWebサーバを停止
BaseTestCase.tearDownClass finished.

----------------------------------------------------------------------
Ran 2 tests in 0.020s

OK

以下の点を確認できたので期待通り動いていると判断しました。

  • Webサーバ起動 => 一つ目のテスト => 二つ目のテスト => Webサーバ停止の順で実行されている
  • Webサーバのポート番号がデフォルト値の9000ではなく、テストケースクラス側で設定した9001で起動している

BaseTestCaseクラスからFeatureクラスを切り出してmixin

BaseTestCaseクラスに、Webサーバ起動・DB初期化・・・など色々な機能を詰め込んでいくと、setUp処理が重くなってしまい、シンプルなFunctionalTestをやりたいだけなのにやけに時間がかかることが気になってくると思います。

その様なケースでは、Webサーバ起動・DB初期化などを行うFeatureクラスを作って、そのテストケースクラスに必要なFeatureクラスだけmixin(多重継承の1形態)するようにするといいとも思います。

Webサーバを起動するFeatureクラス(TestWebserverクラス)は以下の通りです。

def fire_and_forget(f):
    # 省略

class TestWebserver:

    @classmethod
    @fire_and_forget
    def start_web_server(cls, port_num: int, test_class_name: str):
        # 省略

    @classmethod
    def stop_web_server(cls, port_num: int):
        # 省略
  • 元のBaseTestCaseクラスからWebサーバ起動部分を抜き出しただけ
  • 一緒に多重継承されるBaseTestCaseクラスやFeatureクラスの挙動に、影響を与えたり逆に影響を受けたりしないように、インスタンス変数は使わない(全て引数で渡す)

BaseTestCaseクラスは以下の通りです。

import unittest
import time
from test_webserver import TestWebserver

class BaseTestCase(unittest.TestCase):
    port_num: int = 9000  # default値。継承先で上書きして利用する

    @classmethod
    def setUpClass(cls):
        if TestWebserver in cls.__mro__:
            cls.start_web_server(cls.port_num, cls.__name__)
        time.sleep(1)  # Webサーバが立ち上がるのを1秒待機

    @classmethod
    def tearDownClass(cls):
        if TestWebserver in cls.__mro__:
            cls.stop_web_server(cls.port_num)
  • TestWebserverを継承しているかどうかをcls.__mro__で確認し、継承していたらwebサーバを起動する

最後にテストケースクラスを実装します。

Webサーバ起動が不要なら、

class TestScraping(BaseTestCase):

Webサーバ起動が必要なら、

class TestScraping(BaseTestCase, TestWebserver):

という感じでmixinしてあげれば大丈夫です。

さらにテストDBを提供するFeatureクラス(TestDatabaseクラス)を作ったとしたら、二つのFeatureクラスをmixinします。

class TestScraping(BaseTestCase, TestWebserver, TestDatabase):

モック

テストする際に、特定の関数やプロパティをモックしたくなるケースがあると思います。公式ドキュメントはこちらです。

色々なモックの仕方があって全部試したわけではないですが、「自分がよく使うケース」と「ハマったケース」について記載します。

自分がよく使うケース

こんな感じのhoge.pyをテストするイメージです。


def my_public_method() -> str:
    pass

def __my_private_method() -> str:
    pass

class Fuga():
    def get_piyo(self) -> str:
        pass

    def __get_piyo(self) -> str:
        pass

テストケースクラスの例です。

from hoge import Fuga
from hoge

class TestClass(unittest.TestCase):

    # クラス内のpublic関数をmockして戻り値を変更する
    @patch.object(Fuga, 'get_piyo', return_value='xxx')
    def test_case(self, mock):
        pass

    # クラス内のprivate関数をmockして戻り値を変更する
    # クラス内のプライベート関数は`_クラス名`が頭についた形で内部的に管理されている(cls.__dict__で確認できる)
    @patch.object(Fuga, '_Fuga__get_piyo', return_value='xxx')
    def test_case(self, mock):
        pass

    # モジュール直下のpublic関数をmockして戻り値を変更する
    @patch.object(hoge, 'my_public_method', return_value='xxx')
    def test_case(self, mock):
        pass

    # モジュール直下のprivate関数をmockして戻り値を変更する
    # モジュール直下のprivate関数は普通にそのままmockできる
    @patch.object(hoge, '__my_private_method', return_value='xxx')
    def test_case(self, mock):
        pass

    # @patchを利用する
    # @patch.object(Fuga, 'get_piyo', return_value='xxx')と同じ
    @patch('hoge.Fuga.get_piyo', return_value='xxx')
    def test_case(self, mock):
        pass

    # patch.objectを利用する
    # @patch.object(Fuga, 'get_piyo', return_value='xxx')と同じ
    def test_case(self):
        with patch.object(Fuga, 'get_piyo', return_value='xxx') as mock:  # ここでスコープをきっている
            pass

    # 呼び出しをチェックする
    @patch.object(Fuga, 'get_piyo', return_value='xxx')
    def test_case(self, mock):
        mock.assert_not_called()  # 呼び出されないことを確認
        mock.assert_called_once()  # 一度だけ呼び出されることを確認
        mock.assert_called_once_with('x', y='z')  # 一度だけ指定した引数で呼び出されることを確認
        mock.assert_called_with('x', y='z')  # 指定した引数で呼び出されることを確認
        self.assertEqual(mock.call_count, 5)  # 5会呼び出されることを確認

ハマったケース(解決済み)

あるテストメソッドでモックしたものが他のテストに影響する

from hoge import Fuga

# ダメな例
# mockがメソッドに閉じない
# Fuga.get_piyoがMagicMockオブジェクトに置き換えられた状態で後続のテストメソッドが実行されてしまう
def test_case(self):
    mock = patch.object(Fuga, 'get_piyo', return_value='xxx')

# OKな例
# mockがwith句に閉じる
# with句の外側ではFuga.get_piyoは元の関数オブジェクトに戻る
def test_case(self):
    with patch.object(Fuga, '_Fuga__get_piyo', return_value='xxx') as mock:
        pass

# OKな例
# mockがメソッドに閉じる
# 他のメソッドではFuga.get_piyoは元の関数オブジェクトに戻る
@patch.object(Fuga, '_Fuga__get_piyo', return_value='xxx')
def test_case(self, mock):
    pass

テスト対象のモジュールやクラスでimportされた、モジュール直下の関数をモックできない

hoge.py

from fuga import get_value
def get_fuga_value() -> str:
    return get_value()

fuga.py

from fuga import get_value
def get_value() -> str:
    return 'fugafuga'

このように、テスト対象のhoge.pyにおいて、fuga.pyモジュール直下の関数get_valueという関数をimportして利用しているのですが、このようなケースはモック時に注意が必要です。

# ダメな例
# このテストケースクラス内でfuga.get_value()するケースはモックされるが
# hoge.get_fuga_value() -> fuga.get_value()のように呼び出す時はモックされない
import fuga
@patch.object(fuga, 'get_value', return_value='mocked_value')
def test_case(self, mock):
    pass

# ダメな例
# hoge.get_fuga_value() -> fuga.get_value()のように呼び出す時はモックされない
@patch('fuga.get_value', return_value='mocked_value')
def test_case(self, mock):
    pass

# OKな例
# hoge.get_fuga_value() -> fuga.get_value()のように呼び出す時にちゃんとモックされる!!
# hoge.pyでの`from fuga import get_value`により、`hoge`というnamespaceに`get_value`という名前でget_value関数が定義される
# そこをモックすればよいので、`hoge.get_value`を指定するのが正解
@patch('hoge.get_value', return_value='mocked_value')
def test_case(self, mock):
    pass

さいごに

標準ライブラリのunittestは機能的は十分満足なものの、いくつか不満な点も見えてきました。

  • テストディスカバリのために__init__.pyを配置する必要がある
  • テスト結果のログが見にくい
  • テストカバレッジなどが見れない
  • 並列テストできない

実運用でガツガツ使い出すともしかしたら不満点が表に出てくるのかもしれませんが、しばらくはこのまま使い続けてみようと思います。

まだ全然調べてませんが pytest や nose2 はいずれ調べてみたいと思います。