【初心者向け】Pythonで行うテスト自動化入門(doctest・unittest・MagicMock)

Pythonで行うテスト自動化入門(doctest・unittest・MagicMock) プログラミング

信頼性の高いソフトウェアを開発するためには、適切なテスト戦略が不可欠です。Pythonには、テストを簡単に実装できる強力な標準モジュールが用意されています。本記事では、doctestによるドキュメンテーションテスト、unittestによる単体テスト、そしてモックテストの基本的な概念と実践的な使用方法を解説します。コードの品質向上とバグ防止に役立つ、効果的なテスト技術を学んでいきましょう。

doctest

・関数内に記載したコード内でテストとテスト結果を記述します。「>>>」にPythonの対話式のように処理と予想される出力結果を記述します。doctestモジュールをインポートして、コード内でdoctest.testmod()を実行することで、テストを実行できます。テストが問題ない場合は出力はありません。実行時の「-v」オプションで詳細なログを確認できます。コマンドラインに「doctest」オプションをつけてPythonファイルを実行することで、testmodを記述しなくてもテストできます。
・同様にテキストファイルにテスト内容を記述することもできます。その場合は、doctest.testfile(file)でテストファイルを読み込みます。
・例外処理もテストすることができます。「Traceback」から「」を挟んで記述できます。

class Calc:
    def add_num(self, num1, num2):
        """ADD NUM

        >>> c=Calc()
        >>> c.add_num(2,2)
        3

        >>> c=Calc()
        >>> c.add_num(1,2)
        Traceback (most recent call last):
        ...
        ValueError
        """
        if type(num1) is not int or type(num2) is not int:
            raise ValueError

        return num1 + num2


if __name__ == "__main__":
    import doctest

    doctest.testmod()

上記のコードを実行すると、doctestに記述した結果と出力が異なるため以下のような結果が出力されます。「Expected」と「Got」が一致していないことが示されています。

**********************************************************************
File "16.test\1.doctest.py", line 6, in __main__.Calc.add_num
Failed example:
    c.add_num(2,2)
Expected:
    3
Got:
    4
**********************************************************************
File "16.test\1.doctest.py", line 10, in __main__.Calc.add_num
Failed example:
    c.add_num(1,2)
Expected:
    Traceback (most recent call last):
    ...
    ValueError
Got:
    3
**********************************************************************
1 items had failures:
   2 of   4 in __main__.Calc.add_num
***Test Failed*** 2 failures.

unittest

unittest

unittestはユニットテストフレームワークでテストの自動化を行うことができます。

two_calc.py(テスト対象)
引数で2つの数字を受け取り、加算して返却するプログラムになります。引数がint型以外であればエラーを出力します。

class Calc():
    def add_num(self, num1, num2):
        if type(num1) is not int or type(num2) is not int:
            raise ValueError

        return num1 + num2

two_test_unittest.py(テストコード)
・unittestモジュールをインポートして、unittest.TestCaseのサブクラスを作成し、「test」で始まるテストメソッドを定義します。
・テストしたいクラス(Calc)をインポートして、オブジェクトを作成してテスト処理を実行します。assertEqual(テスト処理,予想結果)などでテストケースを記述して、最後に、unittest.main()を実行します。assertEqual以外にも、assertTrue(x),assertIsNone(x),assertIsNot(a,b)など様々なメソッドがあります。

import unittest

from two_calc import Calc

class CalTest(unittest.TestCase):

    def test_add_num(self):
        self.calc = Calc()
        self.assertEqual(self.calc.add_num(1, 2), 4)
        

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

実行結果
実行結果は「3」ですが、予想結果が「4」のため、エラーが表示されています。

======================================================================
FAIL: test_add_num (__main__.CalTest.test_add_num)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "16.test\two_test_unittest.py", line 24, in test_add_num
    self.assertEqual(self.calc.add_num(1, 2), 4)
AssertionError: 3 != 4

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

subTest

two_test_unittest.py(テストコード)
通常のテストメソッド内では1つのアサーションでエラーが発生すると後続のアサーションは実行されませんので、複数実行したい場合は、subTest()を使用します
・withブロック内でアサーションメソッドを実行し、subTestの引数に出力したいメッセージを記述します。

import unittest

from two_calc import Calc

class CalTest(unittest.TestCase):

    def test_add_num_repeat(self):
        self.calc = Calc()
        tests = [[1, 2, 3], [1, 2, 4], [1, 2, 5]]
        for idx, test in enumerate(tests):
            num1, num2, expected = test
            with self.subTest(f"{num1}+{num2}={expected}", idx=idx):
                self.assertEqual(self.calc.add_num(num1, num2), expected)


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

実行結果
複数(2件)のエラーを出力できています。

======================================================================
FAIL: test_add_num_repeat (__main__.CalTest.test_add_num_repeat) [1+2=4] (idx=1)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "16.test\two_test_unittest.py", line 39, in test_add_num_repeat
    self.assertEqual(self.calc.add_num(num1, num2), expected)
AssertionError: 3 != 4

======================================================================
FAIL: test_add_num_repeat (__main__.CalTest.test_add_num_repeat) [1+2=5] (idx=2)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "16.test\two_test_unittest.py", line 39, in test_add_num_repeat
    self.assertEqual(self.calc.add_num(num1, num2), expected)
AssertionError: 3 != 5

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=2)

setUp/setUpClass/tearDown/tearDownClass

two_test_unittest.py(テストコード)
・seUp(self)はメソッド実行前に呼び出され、setUpClass(cls)はクラス内の一番初めに1度だけ実行されます。
・tearDown(self)はメソッド実行後に呼び出され、tearDownClass(cls)はクラスの最後に1度だけ実行されます。
・資材のセットアップやクリーンアップの用途として使用されます。

import unittest

from two_calc import Calc

class CalTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print('first')

    def setUp(self):
        print('setUp')
        self.calc = Calc()

    def tearDown(self):
        print('cleanUp')
        del self.calc
    
    @classmethod
    def tearDownClass(cls):
        print('end')

    def test_add_num1(self):
        self.assertEqual(self.calc.add_num(1, 3), 4)

    def test_add_num2(self):
        self.assertEqual(self.calc.add_num(1, 3), 4)
        

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

実行結果
・クラスの前後とメソッドの前後で処理が実行されています。「.」はテストが成功したことを示します。

first
setUp
cleanUp
.setUp
cleanUp
.end

---------------------------------------------------------------------- 
Ran 2 tests in 0.001s

unittest.mock

MagicMock

・API機能など、構築段階では利用できない機能との連携確認を仮のモック(unittest.mockMagicMock)を作成することで動作確認、テストすることができる

three_salary.py(テスト対象)
・Salaryクラスを作成し、salary_calc()メソッドで給料を計算するプログラムです。bonus_get_api()が開発中のAPIと仮定して、モックを使用したテストを行います

import requests


class Bonus:
    def bonus_get_api(self, year):
        res = requests.get("https://example.com/bonus", params={"year": year})
        return res.json()["price"]
        # return 10


class Salary:
    def __init__(self, base, year):
        self.bonus = Bonus()
        self.base = base
        self.year = year

    def salary_calc(self):
        bonus = self.bonus.bonus_get_api(self.year)
        return self.base + bonus

three_test_salary.py(テストコード)
・外部APIの実行部分bonus_get_api()をMagicMock()で置き換え、return_valueで戻り値をせっていしています。

import unittest

from unittest.mock import MagicMock

from three_salary import Salary


# MagicMock
class SalaryTest(unittest.TestCase):
    def test_salary_calc(self):
        s = Salary(100, 2017)
        s.bonus.bonus_get_api = MagicMock(return_value=10)
        self.assertEqual(s.salary_calc(), 120)

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

実行結果
・API部分を設定した値で受け取りテストが実行できています。

======================================================================
FAIL: test_salary_calc (__main__.SalaryTest.test_salary_calc)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "c:\Users\eyosh\Desktop\Bootcamp\recipi\16.test\three_test_salary.py", line 14, in test_salary_calc
    self.assertEqual(s.salary_calc(), 120)
AssertionError: 110 != 120

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

unittest.mock.patch

特定のクラスやメソッドをモックオブジェクトに置き換えたい場合には、patch()関数をデコレータやwith文で使用します。

three_test_salary.py(テストコード)
・mock.patchでデコレーターやwithステートメント、patcherを使用することで、MagicMockを繰り返し設定しなくても利用可能となる。

class SalaryTest(unittest.TestCase):
    # unittest.mock.patch
    @unittest.mock.patch("three_salary.Bonus.bonus_get_api")
    def test_salary_calc_patch(self, mock_bonus):
        mock_bonus.return_value = 10

        s = Salary(100, 2017)
        salary_price = s.salary_calc()

        self.assertEqual(salary_price, 110)

    # unittest.mock.patch(with)
    def test_salary_calc_patch_with(self):
        with unittest.mock.patch("three_salary.Bonus.bonus_get_api") as mock_bonus:
            mock_bonus.return_value = 10

            s = Salary(100, 2017)
            salary_price = s.salary_calc()

            self.assertEqual(salary_price, 110)

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

実行結果

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

モックが呼ばれたかを確認する方法

three_test_salary.py(テストコード)
・モック名.assert_called().assert_not_called()でモックが実行されたか確認できます。Trueの場合はエラーは発生せず「None」が返ってきて、falseの場合は「AsertionError」が発生します。

# MagicMock
class SalaryTest(unittest.TestCase):

    @unittest.mock.patch("three_salary.Bonus.bonus_get_api")
    def test_salary_calc_patch(self, mock_bonus):
        mock_bonus.return_value = 10

        s = Salary(100, 2017)
        salary_price = s.salary_calc()

        self.assertEqual(salary_price, 110)
        
        mock_bonus.assert_not_called()

if __name__ == "__main__":
    unittest.main()
タイトルとURLをコピーしました