4
0
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

unittest の基本的な使い方 ~ 自動テストのメリット・デメリットを添えて ~

Posted at

はじめに

皆さん、ユニットテストは手動でやられてますか?
自分は今まで手動でやってきてました。
ですが、手動で行うと不便に思う部分が出てきており、回帰テストなんかを行う際は
データの準備や実施が面倒になります。

そこで業務効率化を図れないかと思い、ユニットテストのフレームワークについて調べてみました。

その中で今回は、Pythonunittestについて調べてみて、
ユニットテストフレームワークのメリット・デメリットやunittestの使い方も少し調べてみたのでそのまとめになります。

ユニットテストの概念

ユニットテストを使用してテストを作成から実施まで、以下の概念に沿った機能がunittestでは提供されています。

元々、unittestはJavaのJUnitから影響を受けているらしく、基本的な概念や機能などは似ているようです。

テストフィクスチャ (test fixture)
テストフィクスチャ (test fixture) とは、テスト実行のために必要な準備や終了処理を指します。例: テスト用データベースの作成・ディレクトリ・サーバプロセスの起動など。

テストケース (test case)
テストケース (test case) はテストの独立した単位で、各入力に対する結果をチェックします。テストケースを作成する場合は、 unittest が提供する TestCase クラスを基底クラスとして利用することができます。

テストスイート (test suite)
テストスイート (test suite) はテストケースとテストスイートの集まりで、同時に実行しなければならないテストをまとめる場合に使用します。

テストランナー (test runner)
テストランナー (test runner) はテストの実行を管理し結果を提供する要素です。
ランナーはグラフィカルインターフェースやテキストインターフェースを使用しても構いませんし、テストの実行結果を示す特別な値を返しても構いません。

今回の記事ではテストフィクスチャは一部機能とともに紹介する予定になっています。

ユニットテストのメリット

そもそもユニットテストのメリットはどういったものになるかを考えてみました。

バグの早期発見
バグを早々に発見することができれば、改修時のコストが低くなります。
逆に遅くなればなるほど改修コストが高くなり面倒なことになってしまいます。
また、修正が入らないコードはないのでその際の再テストやデグレが起きていないかの回帰テストにて変更後のバグも発見することができます。

そのコードの仕様理解を助けてくれる
テスト項目書などからそのコードの仕様的なのも判明します。
境界値分析のように「値が0を下回る値はエラーが発生する」、「値が20以上は返す結果が違う」など、こういった挙動をテストする際にテストケースにはそれぞれの境界値を分析するための内容が記載されます。
そのため、そのコードに対して理解などを手助けしてくれるものになり、コードリーディングの効率も上がります。

ユニットテストフレームワーク導入のメリット

テスト実施のコストが掛からない
ユニットテストフレームワークは手間なく何度もテストを実行することができます。回帰テストする際もすぐさまテスト実施できるので効率化が図れます。

手動テストによるミスがない
手動テストする際に、間違ったデータや手順で行ってしまい再テスト…
なんて、面倒なこともフレームワークでは書いたテストコード通りに実施してくれるのでミスがありません。テストコード自体間違っていたら、手動テストも自動テストも修正が必要です。

ユニットテストフレームワーク導入のデメリット

導入コストがかかる
ユニットテストフレームワークを使用する上である程度の知識が必要になります。
その知識をチームメンバーが理解しているかどうかも重要になってきます。
また、テストコードを書くという工程も存在しますので、実装面ではもっと工数がかかると思います。
あくまでユニットテストを効率的に行えるという事
デメリットかは微妙ですが、あくまでユニットテストフレームワークはユニットテストを効率的に行えるというだけです。
上にも書きましたが、フレームワークの知識を身に着けてテストコードを書かなくてはなりません。また、フレームワークを導入してテストしてもプロダクトの品質が手動テストより必ず良くなるとも限りません。

ユニットフレームワークは何度も実行するテストだと費用対効果を発揮すると思います。プロトタイプなんかで1回しかテストを実施しないのであれば、手動テストでいいかもですし、テスト実施に比べてテストコード作成に比重が偏っているならば逆にフレームワークは非効率になるかもしれません。

要は…
ユニットテストフレームワークを導入したことで、必ずしもその恩恵を受けれるわけではない

基本的なテストコード

メリット・デメリットを大まかに記載した所で、それでもプロジェクトにunittestが必要な場合、

サンプルとしてunittestで使用される基本的なテストコードを用意しました。
内容も複雑ではないので、身構えずに知識をアップデートする際の足掛かりになれば幸いです。

サンプルコード

SampleTest.py
import unittest


class MyTestCase(unittest.TestCase):
    def test_a_and_b(self):
        a = 1
        b = 1
        self.assertEqual(a, b, 'a and b are not equal') # ⓵

    def test_is_str(self):
        a = '1'
        self.assertTrue(type(a) is str, 'a is not str') # ⓶

    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world']) 
        
        # s.split(2)で空白数の違う値を指定するとTypeErrorが発生するか確認
        with self.assertRaises(TypeError): # ⓷
            s.split(2)


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

① self.assertEqual(a, b, 'a and b are not equal')
assertEqual() では、第一引数と第二引数が等しいかどうかをテストしています。
値が等しければ、テスト成功。逆に等しくなければ、テスト失敗となり第三引数のメッセージがコンソールに表示されます。

② self.assertTrue(type(a) is str, 'a is not str')
assertTrue()では、第一引数がTrueかどうかをテストしています。
こちらもテストに失敗すると第二引数のメッセージが表示されます。

③ with self.assertRaises(TypeError):
こちらは期待する例外が発生するかをテストしています。
上記のコードでは、s.split(2)として実際の空白文字とは違う数字を引数として与えると
TypeErrorが発生します。
assertRaises()では、その期待する例外を引数として、期待する例外が発生すればテスト成功となります。

独自モジュールをテストする

もちろん、独自で作成したモジュールをテストすることも可能です。
下記のコードの場合、Persons.pyというテスト対象モジュールをインポートしてテストします。

サンプルコード

テスト対象モジュール:Persons.py
Persons.py
class Persons:
    names = []

    def set_name(self, user_name):
        self.names.append(user_name)
        return len(self.names) - 1

    def get_name(self, user_id):
        if user_id >= len(self.names):
            return 'There is no such user'
        else:
            return self.names[user_id]

    def del_name(self, user_name):
        for i in range(len(self.names)):
            if user_name == self.names[i]:
                self.names.remove(user_name)
                return True
        return False


if __name__ == '__main__':
    person = Persons()

テスト対象のモジュールの中身は、
「名前」を追加したり、取得または削除したりできるものです。

テストコード:PersonsTest.py
PersonsTest.py
import unittest

# テスト対象クラスのためインポートする必要がある
import Persons as PersonsClass


class PersonsTest(unittest.TestCase):
    """
    unittest.TestCaseを継承したPersonTestクラス
    'test_'で始まるメソッドは全てテストケースとみなす
    """

    persons = PersonsClass.Persons()  # Personクラスのインスタンス化
    user_id = []  # 取得したuser_idを格納する変数
    user_name = []  # user_nameを格納する変数

    # Person.set_nameメソッドをチェックするテストケース
    def test_0_set_name(self):
        print("Start set_name test\n")
        for i in range(4):
            # nameの生成
            name = 'name' + str(i)
            # 比較用に別でユーザー名をlistに格納して取っておく
            self.user_name.append(name)

            # Persons.set_nameを呼び出して、ユーザー名をセット
            user_id = self.persons.set_name(name)

            # user_idがNoneではないかチェック
            self.assertIsNotNone(user_id)

            # user_idを別でlistに格納して取っておく
            self.user_id.append(user_id)

        # 確認用
        print("user_id length = ", len(self.user_id))
        print(self.user_id)
        print("user_name length = ", len(self.user_name))
        print(self.user_name)
        print("\nFinish set_name test\n")

    # Person.get_nameメソッドをチェックするテストケース
    def test_1_get_name(self):
        print("\nStart get_name test\n")

        length = len(self.user_id)  # ユーザー登録情報の合計
        print("user_id length = ", length)
        print("user_name length = ", len(self.user_name))
        for i in range(6):
            # user_idの合計以下の場合は、そのuser_idに基づいたuser_nameを返す
            if i < length:
                # user_nameに登録した名前と一致するか
                self.assertEqual(self.user_name[i], self.persons.get_name(self.user_id[i]))
            else:
                print("Testing for get_name no user test")
                # もし、合計数を超えた場合に指定されたメッセージが返されるか
                self.assertEqual('There is no such user', self.persons.get_name(i))
        print("\nFinish get_name test\n")

    def test_2_del_name(self):
        print("\nStart del_name test\n")

        print("user_id length = ", len(self.user_id))
        print("user_name length = ", len(self.user_name))

        print("user_id:", self.user_id)
        print("user_name:", self.user_name)

        # 削除するユーザーのIDを指定
        within_range_index = 2

        # persons.del_name()を呼出
        within_range_del_result = self.persons.del_name(self.user_name[within_range_index])

        # 戻り値がNoneではないか
        self.assertIsNotNone(within_range_del_result)

        # 該当するユーザーIDの名前が削除成功しているかどうか
        self.assertTrue(within_range_del_result)

        # user_idとuser_nameからも削除
        self.user_id.remove(within_range_index)
        self.user_name.remove(self.user_name[within_range_index])

        print("user_id length = ", len(self.user_id))
        print("user_name length = ", len(self.user_name))

        print("user_id:", self.user_id)
        print("user_name:", self.user_name)

        # ~ 存在しないnameでもテスト ~


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

テストケースを作成する際には、メソッド名にtest_xxxから始めるとわかりやすいです。
各テストメソッドでは、テスト対象のメソッド単位で作成しています。

コメント多めでごちゃっとなっていますが、
内容は複雑ではないので読んでみて理解することも出来ると思います。

また、テストメソッド内でprint()を使用してコンソールに表示してあげると、
テスト失敗した際に、どこで失敗したかもわかりやすくなります。

実行結果
============================= test session starts =============================
collecting ... collected 3 items

PersonsTest.py::PersonsTest::test_0_set_name PASSED                      [ 33%]
Start set_name test

user_id length =  4
[0, 1, 2, 3]
user_name length =  4
['name0', 'name1', 'name2', 'name3']

Finish set_name test


PersonsTest.py::PersonsTest::test_1_get_name PASSED                      [ 66%]
Start get_name test

user_id length =  4
user_name length =  4
Testing for get_name no user test
Testing for get_name no user test

Finish get_name test


PersonsTest.py::PersonsTest::test_2_del_name PASSED                      [100%]
Start del_name test

user_id length =  4
user_name length =  4
user_id: [0, 1, 2, 3]
user_name: ['name0', 'name1', 'name2', 'name3']
user_id length =  3
user_name length =  3
user_id: [0, 1, 3]
user_name: ['name0', 'name1', 'name3']


============================== 3 passed in 0.02s ==============================

先程のテストコードを実施してみるとコンソールには上記のようなものが表示されます。

テストの準備・後片付け

pythonのunittestには、setUp()tearDown()というテストコードの作業環境整えてくれるものがあります。
作業環境を整えることを、「テストフィクスチャ」とも言います。

setUp()

テストメソッドの前に呼び出されます。
テストメソッド毎に準備する必要のあるデータなどをsetUp()メソッド内に書いてあげます。

tearDown()

テストメソッドが実行され、結果が記録された後に呼び出されます。
例外が発生した後にも呼び出されます。
テストの後片付けで行いたい処理などを記述します。

サンプルコード

テスト対象モジュール:Student.py
Student.py
class Student:

    def __init__(self, first_name, last_name, lessons=None, is_graduated=False):
        self.first_name = first_name
        self.last_name = last_name
        self.lessons = [] if lessons is None else lessons
        self.is_graduated = is_graduated

    def get_student_full_name(self):
        return self.first_name + " " + self.last_name

__init__() (コンストラクター) にて初期値を多数あり、
そのデフォルト値がちゃんと入っているかどうかのテストも実施すると思います。

テストコード:StudentTest.py
StudentTest.py
import unittest

import Student as StudentClass


class MyTestCase(unittest.TestCase):
    """
    unittest.TestCaseを継承したPersonTestクラス
    'test_'で始まるメソッドは全てテストケースとみなす
    """

    def setUp(self):
        print("\nset up")

        # Studentクラスをインスタンス化(テストの前処理)
        self.student = StudentClass.Student("Taro", "Suzuki")

    def tearDown(self):
        print("clean up")

        # インスタンス化したStudentクラスを削除
        del self.student

    def test_full_name(self):
        print('start test_full_name')

        # Setup()にテストの前処理を任せる
        # student = StudentClass.Student("Taro", "Suzuki")

        # 比較用にフルネームを用意
        full_name = 'Taro Suzuki'

        # 初期値のフルネームが一致するかどうか
        self.assertEqual(full_name, self.student.get_student_full_name())

    def test_student_default_lessons(self):
        print('start test_student_default_lessons')

        # Studentクラスをインスタンス化
        # student = StudentClass.Student("Taro", "Suzuki")

        # 初期値のlessonsを確認
        self.assertIsNotNone(self.student.lessons)
        self.assertIsInstance(self.student.lessons, list)

    def test_student_default_is_graduated(self):
        print('start test_student_default_is_graduated')

        # Studentクラスをインスタンス化
        # student = StudentClass.Student("Taro", "Suzuki")

        # 初期値のis_graduatedを確認
        self.assertFalse(False, self.student.is_graduated)


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

それを実施するにはテスト毎にテスト対象モジュールをインスタンス化する必要があります。
その繰り返し処理をsetUp()にて、またその後片付けをtearDown()に任せると重複を排除できます。

実行結果
============================= test session starts =============================
collecting ... collected 3 items

StudentTest.py::MyTestCase::test_full_name PASSED                        [ 33%]
set up
start test_full_name
clean up

StudentTest.py::MyTestCase::test_student_default_is_graduated PASSED     [ 66%]
set up
start test_student_default_is_graduated
clean up

StudentTest.py::MyTestCase::test_student_default_lessons PASSED          [100%]
set up
start test_student_default_lessons
clean up


============================== 3 passed in 0.04s ==============================

assertメソッド一覧

他のassertメソッドは以下になります。
どれもシンプルなので、場面によって使い分けてみてください。

メソッド 確認事項 初出
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b
assertTrue(x) bool(x) is True
assertFalse(x) bool(x) is False
assertIs(a, b) a is b 3.1
assertIsNot(a, b) a is not 3.1
assertIsNone(x) x is None 3.1
assertIsNotNone(x) x is not None 3.1
assertIn(a, b) a in b 3.1
assertNotIn(a, b) a not in b 3.1
assertIsInstance(a, b) isinstance(a, b) 3.2
assertNotIsInstance(a, b) not isinstance(a, b) 3.2

さいごに

ここまで、ユニットテストフレームワークのメリット・デメリットや、
その使い方を簡単にまとめてみました。

ユニットテストフレームワークについて調べてみて、
導入したところで必ずしも業務効率化に繋がるわけではないという所にはっとしました。

ユニットテストフレームワークを導入することが目的となっているなら、危険ですね…

プロジェクトごとにユニットテストを導入する必要があるかどうか、
また、デメリットに関してもちゃんと受け入れられるかも考える必要がありそうです。

参考資料

参考にした記事などを載せておきます。
以下の記事たちの方が分かりやすく書かれているので参考にしてみてください。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0