【Python】unittest(単体テスト)モジュールで自動テストを行う


投稿日 2023年3月23日 >> 更新日 2023年4月14日

概要

Pythonの標準ライブラリである「unittest」を使ってプログラムの自動テストを実装していきます。

実際にモジュールを開発しながら単体テストを実行していきます。

コマンドによるunittestの実行や、モジュールに対してテストケースを作成します。

開発環境&使用ライブラリ

開発環境
Windows Subsystem for Linux
Python 3.10.0

テストの意味や必要性

ソフトウェアのテストとは、その名の通りプログラムがしっかり動くのかテストすることです。

単に開発したモジュールやアプリを起動してみて、エラーやバグが発生しないか確認するのもテストといえますが、規模が大きくなればなるほど、そして重大な責任が掛かればかかるほど「テストを書く」ことが重要になります。

ソフトウェアのテストには「単体テスト」「結合テスト」「統合テスト」と分ける事ができるそうです。その3つの中のテストである「単体テスト」をPythonで実装していきます。

「単体テスト」とは機能ごとにテストを行うことです。

メソッドや関数単位でそのモジュールの実行結果をテストします。

Pythonの「unittest」という標準パッケージを使うことで、テストを自動化することができます。

プログラミングやソフトウェア開発を始めたばかりの人にとってはかなり面倒でハードルが高い事だと思いますが、テストをすることによって品質の保証や信用性、そして何より自身のレベルアップに繋がるのでやらないという選択はなくなります。

テストの実装

テストを実装するにはモジュールが必要なので、簡単なスクレイピングツールを作成します。

まずは、必要な外部パッケージをインストールします。

$ pip install --upgrade pip

$ pip install requests bs4

インストールが完了したら、モジュールを作成します。

モジュールのファイル名は「scraping.py」として、以下のような処理を実装します。

# scraping.py
import requests

from bs4 import BeautifulSoup as bs4


# ------初級編------------
url = 'https://zerofromlight.com/blogs/'
tag = 'h5'
request = requests.get(url)
soup = bs4(request.content, 'html.parser')
value = soup.find_all(tag)
datas = []

for val in value:
    datas.append(val.text)

print(datas)

上記のPythonファイルを実行すると、このサイトのブログトップページ上に表示されている記事のタイトル名を一色取得しリスト変数に追加していく、と言う自作モジュールです。

この自作モジュールの現段階においては、「url」や「tag」変数の値をハードコードしているため、テストをする必要性は無く単に返ってくる値を確認するにすぎません。

なので、これから自作モジュールを複雑な処理にしていきながらテストを実装していきます(テストを書いてから開発する順番でもいい)。

テストファイルを作成してユニットテストコマンドの実行

「unittest」モジュールはPythonを導入すると標準パッケージとして付属しています。

なので、ユニットテストを実行したいときは以下のようにPythonコマンドに「-m(module)」オプションを付けて「unittest」モジュールを指定します。

$ python3 -m unittest

上記のコマンドが実行されると、以下のような実行結果が表示されます。

$ python3 -m unittest

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

0回のテストが実行されたと表示されていますが、作業ディレクトリにはテストファイルを作成していないのでユニットテストモジュールはテストファイルを見つけることなく結果を表示して終了しました。

ユニットテストモジュールがテストとして実行するには幾つか決まり事があります。

  • テストファイル名の頭文字を「test」とする

  • テストケース名の頭文字を「test」とする

  • ディレクトリを分けた場合は「__init__.py」を配置する

上記の事を守ってテストファイルを作成した場合が以下の「test_scraping.py」です。

# テストファイル名:test_scraping.py
import unittest

class ScpTest(unittest.TestCase):

    # ----初級編1----------
    def test_scp(self):
        # テストケース名:test_scp
        pass

ユニットテストのコマンドを実行する作業ディレクトリは以下です。

作業ディレクトリ
├── scraping.py
└── test_scraping.py

ユニットテストのコマンドを実行すると、1つのテストケースが走ります。

$ python3 -m unittest
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

どこのテストケースが走ったのか詳細を確認したい場合は、「-v(varbose)」オプションを付けます。

$ python3 -m unittest -v
test_scp (test_scraping.ScpTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

テストファイル自身を実行可能なファイルとして扱う場合は「unittest.main()」関数を追記します。

# test_scraping.py
import unittest

class ScpTest(unittest.TestCase):

    # ----初級編1----------
    def test_scp(self):
        pass


# 追記
# test_scraping.pyが実行された場合の処理
if __name__ == '__main__':
    unittest.main()

上記ファイルを実行すると以下のようになります。

$ python3 test_scraping.py -v
test_scp (__main__.ScpTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

次に「test_scraping.py」の階層を「tests/unittests」ディレクトリに配置してテストを実行します。

各ディレクトリに「__init__.py」を配置することによって、ユニットテストモジュールはそこに配置されているPythonファイルを検索して読み込んでくれます。

作業ディレクトリ
├── scraping.py
└── tests
    ├── __init__.py
    └── unittests
        ├── __init__.py
        └── test_scraping.py

上記のディレクトリ構造でユニットテストのコマンドを実行すると以下のようなります。

$ python3 -m unittest -v
test_scp (tests.unittests.test_scraping.ScpTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

「tests」ディレクトリの「__init__.py」を削除すると、コマンドを実行しても「tests」ディレクトリ以下の階層は実行されません。

作業ディレクトリ
├── scraping.py
└── tests
    └── unittests
        ├── __init__.py
        └── test_scraping.py
$ python3 -m unittest -v

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

「__init__.py」が配置されていない場合は、ユニットテストのサブコマンドを使って、テストを開始したい作業ディレクトリを設定できます。

サブコマンド名は「discover」で、そのオプション「-s(start directory)」で開始ディレクトリを指定して実行します。

$ python3 -m unittest discover -s tests -v
test_scp (unittests.test_scraping.ScpTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

ユニットテストのサブコマンド「discover」のオプションは以下です(Python公式ドキュメントのユニットテストフレームワーク--コマンドラインオプションより)。

オプション 内容
-v 詳細情報
-s テストファイルのある開始ディレクトリ
-p テストファイル名を指定する。正規表現で指定可能
-t 「-s」で指定された開始ディレクトリのルートディレクトリを指定(デフォルトではプロジェクトの最上位ディレクトリが設定されている。)

サブコマンドのオプションを明示的に付けると順序は関係ありませんが、位置引数としてファイル名を渡す場合は以下の順番となります。

# 位置引数の場合
$ python3 -m unittest discover tests test_* tests

# オプションの場合
$ python3 -m unittest discover -s tests -p test_* -t tests
作業ディレクトリ
├── scraping.py
└── tests
    └── unittests
        ├── __init__.py
        └── test_scraping.py

ということで、テストファイルを走らせる場合は先でも言った通り「幾つかの決まり事」である以下の項目を準拠するのが無難かと思います。

  • テストファイル名の頭文字を「test」とする

  • テストケース名の頭文字を「test」とする

  • ディレクトリを分けた場合は「__init__.py」を配置する

テストケースの作成

テストケースは「TestCase」クラスを継承して作成していきます。

各テストケースのメソッド内にはテストしたいモジュールの動作結果を返す形として作成します。

# テストファイルの例
import unittest

# テストしたいモジュール内のとある機能のテストクラス
class SampleTest(unittest.TestCase):

    def test_case_1(self):
        # テストしたいモジュールの処理1
        # プログラムの結果

    def test_case_2(self):
        # テストしたいモジュールの処理2
        # プログラムの結果

    ....

実際に「scraping.py」である自作モジュール用のテストファイルを書いていきます。

自作モジュールは現在以下のようになっています。

# scraping.py
from bs4 import BeautifulSoup as bs4
import requests


# ------初級編------------
url = 'https://zerofromlight.com/blogs/'
tag = 'h5'
request = requests.get(url)
soup = bs4(request.content, 'html.parser')
value = soup.find_all(tag)
datas = []

for val in value:
    datas.append(val.text)

print(datas)

上記プログラムをテスト可能な状態にするために、関数化してきます。

# scraping.py
import time

from bs4 import BeautifulSoup as bs4
import requests


# ------初級編2------------
def set_url_and_tag(url, tag, attrs={}):

    time.sleep(1)
    request = requests.get(url)
    soup = bs4(request.content, 'html.parser')
    value = soup.find_all(tag, attrs=attrs)
    datas = []

    for val in value:
        datas.append(val.text)
    return datas

# ------初級編2デバッグ用--------
if __name__ == '__main__':
    url = 'https://zerofromlight.com/blogs/'
    tag = 'h5'
    attrs = {}
    datas = set_url_and_tag(url, tag, attrs=attrs)
    print(datas)

上記の関数に対するテストケースを「test_scraping.py」に作成していきます。

# test_scraping.py
import unittest

from scraping import set_url_and_tag


class ScpTest(unittest.TestCase):

    # ----初級編2----------
    def test_set_url_and_tag_result(self):
        url = 'https://zerofromlight.com/blogs/'
        tag = 'h5'
        datas = set_url_and_tag(url, tag)
        # データ型の判定
        self.assertIsInstance(datas, list,
                '戻り値はリスト型である')

「test_set_url_and_tag_result」メソッド内で、自作モジュールを使用する感覚でコーディングしていきます。

結果は親クラスが持っている「assertメソッド」を使って判定します。

上記の場合では、「assertIsInstance」メソッドを使って、自作モジュールの戻り値の型判定を行っています。

以下の作業ディレクトリでユニットテストコマンドを実行してみます。

作業ディレクトリ
├── scraping.py
├── test_scraping.py
$ python3 -m unittest -v
test_set_url_and_tag_result (test_scraping.ScpTest) ... ok

----------------------------------------------------------------------
Ran 1 test in 1.331s

OK

1つのテストケースが走りました。

テストが失敗した場合どのようになるのか試してみます。

テストファイルに記述している内容は、「そうであるべき結果」をテストしているため、基本的には書き換えないようにします。

なので、自作モジュールの結果を変更してテストを失敗してみます。

# scraping.py

...

# ------初級編2------------
def set_url_and_tag(url, tag, attrs={}):

    ...

    for val in value:
        datas.append(val.text)
    return tuple(datas) # タプルに変更

上記関数の結果をリスト型からタプル型に変更し、再度ユニットテストコマンドを実行してみると以下のようにテストが失敗します。

F
======================================================================
FAIL: test_set_url_and_tag_result (test_scraping.ScpTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/mnt/c/Users/test_scraping.py", line 65, in test_set_url_and_tag_result
    self.assertIsInstance(datas, list,
AssertionError: ('【Django】自作アプリを配布用にパッケージングしてpipでインストールする', '【Windows10 Home】WSL1からWSL2に切り替える', ...) is not an instance of <class 'list'> : 戻り値はリスト型である

----------------------------------------------------------------------
Ran 1 test in 1.303s

FAILED (failures=1)

テストケースに書いた通り、「戻り値はリスト型である」となっていない結果のため失敗しました。

テストに失敗すると、どこのテストケース(メソッド)で失敗したのかが表示されるので助かります。


次に例外処理に対するテストケースを実装していきます。

現在自作モジュールの「scraping.py」では例外に対する処理を書いていません。

例えば、依存関係のある「requests」ライブラリにURLを与えることによってWebページの情報を取得することができますが、URLの頭に「https://」の無いドメインのみの値が渡された場合は、エラーが返されてしまいます。

そのような値を「requests」に渡る前にバリューエラーを返す処理を追記していきます。

# scraping.py
import time

from bs4 import BeautifulSoup as bs4
import requests


# ------中級編------------
def set_url_and_tag(url, tag, attrs={}):

    # 例外処理
    if 'https://' not in url:
        raise ValueError(
                'Invalid URL:Example is https://example.com/.')

    time.sleep(1)
    request = requests.get(url)
    soup = bs4(request.content, 'html.parser')
    value = soup.find_all(tag, attrs=attrs)
    datas = []

    for val in value:
        datas.append(val.text)
    return datas

# ------中級編デバッグ用--------
if __name__ == '__main__':
    url = 'zerofromlight.com/blogs/' # https://無し
    tag = 'h5'
    attrs = {}
    datas = set_url_and_tag(url, tag, attrs=attrs)
    print(datas)

上記の「scraping.py」を実行してみると、「ValueError」と共にメッセージが発生されます。

$ python3 scraping.py
Traceback (most recent call last):
...
ValueError: Invalid URL:Example is https://example.com/.

「https://」が含まれていない値をモジュールに与えたため例外が発生しました。

例外に対するテストケースも新たに追記していきます。

# test_scraping.py
import unittest

from scraping import set_url_and_tag


class ScpTest(unittest.TestCase):

    # ----中級編----------
    def test_set_url_and_tag_result(self):
        ...

    # 例外処理のテストケース
    def test_set_url_and_tag_raise(self):
        no_https_url = 'zerofromlight.com/blogs'
        tag = 'h5'
        with self.assertRaisesRegex(ValueError,
                'Invalid URL:Example is https://example.com/.'):
            set_url_and_tag(no_https_url, tag)

「test_scraping.py」に新たなテストケース「test_set_url_and_tag_raise」メソッドを追記しました。

例外が発生することをテストしたいので、「assertRaisesRegex」メソッドを使いテストの判定を行っています。

「assertメソッド」に関してはPythonの公式ドキュメントのユニットテスト -- テストクラス項目の中にあります。

ではテストファイルを実行してみます。

$ python3 -m unittest -v
test_set_url_and_tag_raise (test_scraping.ScpTest) ... ok
test_set_url_and_tag_result (test_scraping.ScpTest) ... ok

----------------------------------------------------------------------
Ran 2 tests in 1.275s

OK

2つのテストが走ったと表示されました。

テストケースは思いつく限り作成するか、pytestという外部ライブラリを使ってテストのカバー率を見て作成したりします。

テストを作成するタイミングとしては、機能を開発する前に作成したり、機能を開発しながら一緒にテストを作成したりと、チームによって様々のようです。

setUpメソッドとtearDownメソッドを使ってテスト

テストケースは親クラスである「TestCase」のサブクラス中にテストを作成しますが、その親クラスは「setUpメソッド」と「tearDownメソッド」を持っています。

「setUp」では個々のテストケース(メソッド)が走る前に呼び出されるメソッドです。

「tearDown」は個々のテストケースが走った後に呼び出されるメソッドです。

デフォルトでは何もしませんが、メソッドをオーバーライドしてテストが走る前や走った後に行いたい処理を付け加えることができます。

例えばクラスの初期化だったり、メモリを空けるために削除するなど適当な処理を加えられます。

上記のようなテストを実行するために、自作モジュールで定義している関数をクラス化していきます。

# scraping.py
import time

from bs4 import BeautifulSoup as bs4
import requests

# ------上級編------------
class Scp(object):

    def set_url_and_tag(self, url, tag, attrs={}):

        if 'https://' not in url:
            raise ValueError(
                    'Invalid URL:Example is https://example.com/.')

        time.sleep(1)
        request = requests.get(url)
        soup = bs4(request.content, 'html.parser')
        value = soup.find_all(tag, attrs=attrs)
        datas = []

        for val in value:
            datas.append(val.text)
        return datas

# ------上級編デバッグ用--------
if __name__ == '__main__':
    scp = Scp()
    url = 'https://zerofromlight.com/blogs/'
    tag = 'h5'
    attrs = {}

    datas = scp.set_url_and_tag(url, tag, attrs=attrs)
    print(datas)

テストファイルでは、「setUp」メソッドと「tearDown」メソッドをオーバーライドします。

import unittest

from scraping import Scp # クラスをインポート


class ScpTest(unittest.TestCase):

    # -----上級編----------
    def setUp(self):
        # クラスを初期化
        self.scp = Scp()
        print('初期化')

    def tearDown(self):
        # クラスを削除
        del self.scp
        print('削除')

    def test_set_url_and_tag(self):
        url = 'https://zerofromlight.com/blogs/'
        tag = 'h5'
        datas = self.scp.set_url_and_tag(url, tag)
        # データ型の判定
        self.assertIsInstance(datas, list,
                '戻り値はリスト型である')

    def test_set_url_and_tag_raise(self):
        no_https_url = 'zerofromlight.com/blogs'
        tag = 'h5'
        with self.assertRaisesRegex(ValueError,
                'Invalid URL:Example is https://example.com/.'):
            self.scp.set_url_and_tag(no_https_url, tag)

テストが走る流れとしては、最初に「setUp」メソッドが呼ばれてクラス変数を初期化し1つ目のテストケースである「test_set_url_and_tag」メソッドが走り、その後に「tearDown」メソッドが呼ばれてクラス変数が削除されます。

実際にテストを実行してみます。

$ python3 -m unittest -v
test_set_url_and_tag (test_scraping.ScpTest) ... 初期化
削除
ok
test_set_url_and_tag_raise (test_scraping.ScpTest) ... 初期化
削除
ok

----------------------------------------------------------------------
Ran 2 tests in 1.271s

OK

個々のテストケースが走る度に「setUp」と「tearDown」が呼ばれていることが分かります。

テストケースのスキップ

テストケースをスキップしたい場合があると思います。

その際は、スキップされたいメソッドにデコレータを付け加えることでそのテストケースをスキップすることができます。

単純に、スキップのみ行いたい場合は以下のようにデコレータを付けます。

# test_scraping.py
import unittest

from scraping import Scp


class ScpTest(unittest.TestCase):

    # -----上級編----------
    def setUp(self):
        self.scp = Scp()
        print('初期化')

    def tearDown(self):
        del self.scp
        print('削除')

    def test_set_url_and_tag(self):
        ...

    @unittest.skip('スキップします。')
    def test_set_url_and_tag_raise(self):
        no_https_url = 'zerofromlight.com/blogs'
        tag = 'h5'
        with self.assertRaisesRegex(ValueError,
                'Invalid URL:Example is https://example.com/.'):
            self.scp.set_url_and_tag(no_https_url, tag)

上記を実行すると、デコレータが設定されたテストケースに「setUp」「tearDown」が呼ばれる事無くスキップをして終了しています。

$ python3 -m unittest -v
test_set_url_and_tag (test_scraping.ScpTest) ... 初期化
削除
ok
test_set_url_and_tag_raise (test_scraping.ScpTest) ... skipped 'スキップします。'

----------------------------------------------------------------------
Ran 2 tests in 1.327s

OK (skipped=1)

他にも様々なスキップ用のデコレータが用意されており、条件によってスキップを行えるデコレータもあります。

以下はモジュールのバージョンが指定されている値以上だった場合にスキップするという設定です。

# test_scraping.py
...

class ScpTest(unittest.TestCase):

    # -----上級編----------
    ...

    @unittest.skipIf(Scp.__version__ > '3.0.0',
            "依存関係のライブラリが更新されたのでスキップ")
    def test_set_url_and_tag_raise(self):
        ...

上記のデコレータを使うと第一引数に指定した値が「True」の時だけ設定されたテストケースをスキップします。

$ python3 -m unittest -v
test_set_url_and_tag (test_scraping.ScpTest) ... 初期化
削除
ok
test_set_url_and_tag_raise (test_scraping.ScpTest) ... skipped '依存関係のライブラリが更新されたのでスキップ'

----------------------------------------------------------------------
Ran 2 tests in 1.339s

OK (skipped=1)

他のデコレータはこちらのPython公式ドキュメント--テストのスキップと予期された失敗をご参照ください。

setupclassメソッドとteardownclassメソッドを使ってテスト

個々のテストケースの前後で実行される「setUpメソッド」と「tearDownメソッド」をオーバーライドして使ってきましたが、個々のテストケースではなくテストクラスが実行される前後で呼ぶことのできるメソッドがあります。

それが「setupclassメソッド」と「teardownclassメソッド」です。

使い方は「setUpメソッド」と「tearDownメソッド」と同じよう定義しますが、「setupclassメソッド」と「teardownclassメソッド」はクラスメソッドとして定義します。

なので、各メソッドには「@classmethod」デコレータを付けて、メソッドの第一引数には「self」ではなく「cls」と設定します。

# 例

class SampleTest(unittest.TestCase):

    @classmethod
    def setupclass(cls):
        ...

上記のように編集した「test_scraping.py」が以下です。

# test_scraping.py
import unittest

from scraping import Scp


class ScpTest(unittest.TestCase):

    # -----上級編----------
    @classmethod
    def setUpClass(cls):
        cls.scp = Scp()
        print('初期化')

    @classmethod
    def tearDownClass(cls):
        del cls.scp
        print('削除')

    def setUp(self):
        print('テストケースのsetup')

    def tearDown(self):
        print('テストケースのteardown')

    def test_set_url_and_tag(self):
        ...

自作モジュールであるクラス変数の初期化と削除をテストケース単位ではなく、テストクラス単位で実行されるように編集しました。

テストを実行してみます。

$ python3 -m unittest -v
初期化
test_set_url_and_tag (test_scraping.ScpTest) ... テストケースのsetup
テストケースのteardown
ok
test_set_url_and_tag_raise (test_scraping.ScpTest) ... テストケースのsetup
テストケースのteardown
ok
削除

----------------------------------------------------------------------
Ran 2 tests in 1.272s

OK

他にも「TestCase」クラスは便利なメソッドを持っているので興味があれば参照してみて下さい。

以上となります。

最後までご覧いただきありがとうございました。